diff --git a/Cargo.lock b/Cargo.lock index b08dfabf4..547fc862a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -495,6 +495,7 @@ dependencies = [ "artisan-migrations", "artisan-native-engine", "artisan-protocol", + "artisan-tracing", "artisan-transport", "base64 0.22.1", "bytes", @@ -613,6 +614,7 @@ dependencies = [ "artisan-domain", "artisan-editor-cli", "artisan-protocol", + "artisan-tracing", "artisan-transport", "artisan-ui", "base64 0.22.1", @@ -717,6 +719,14 @@ dependencies = [ "gpui_ce_platform", ] +[[package]] +name = "artisan-tracing" +version = "0.0.0" +dependencies = [ + "artisan-build-info", + "serde_json", +] + [[package]] name = "artisan-transport" version = "0.0.0" @@ -3117,7 +3127,7 @@ dependencies = [ [[package]] name = "gpui-ce" version = "0.2.2" -source = "git+https://github.com/artisanstreet/gpui-ce?rev=ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6#ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" +source = "git+https://github.com/artisanstreet/gpui-ce?rev=8d8c07348532be87463aa666a01c1dbcb28651df#8d8c07348532be87463aa666a01c1dbcb28651df" dependencies = [ "accesskit", "anyhow", @@ -3191,7 +3201,7 @@ dependencies = [ [[package]] name = "gpui_ce_apple" version = "0.1.0" -source = "git+https://github.com/artisanstreet/gpui-ce?rev=ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6#ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" +source = "git+https://github.com/artisanstreet/gpui-ce?rev=8d8c07348532be87463aa666a01c1dbcb28651df#8d8c07348532be87463aa666a01c1dbcb28651df" dependencies = [ "anyhow", "block", @@ -3215,7 +3225,7 @@ dependencies = [ [[package]] name = "gpui_ce_collections" version = "0.2.2" -source = "git+https://github.com/artisanstreet/gpui-ce?rev=ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6#ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" +source = "git+https://github.com/artisanstreet/gpui-ce?rev=8d8c07348532be87463aa666a01c1dbcb28651df#8d8c07348532be87463aa666a01c1dbcb28651df" dependencies = [ "gpui_ce_util", "indexmap", @@ -3225,7 +3235,7 @@ dependencies = [ [[package]] name = "gpui_ce_derive_refineable" version = "0.2.2" -source = "git+https://github.com/artisanstreet/gpui-ce?rev=ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6#ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" +source = "git+https://github.com/artisanstreet/gpui-ce?rev=8d8c07348532be87463aa666a01c1dbcb28651df#8d8c07348532be87463aa666a01c1dbcb28651df" dependencies = [ "proc-macro2", "quote", @@ -3235,7 +3245,7 @@ dependencies = [ [[package]] name = "gpui_ce_linux" version = "0.1.0" -source = "git+https://github.com/artisanstreet/gpui-ce?rev=ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6#ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" +source = "git+https://github.com/artisanstreet/gpui-ce?rev=8d8c07348532be87463aa666a01c1dbcb28651df#8d8c07348532be87463aa666a01c1dbcb28651df" dependencies = [ "accesskit", "accesskit_unix", @@ -3283,7 +3293,7 @@ dependencies = [ [[package]] name = "gpui_ce_macos" version = "0.1.0" -source = "git+https://github.com/artisanstreet/gpui-ce?rev=ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6#ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" +source = "git+https://github.com/artisanstreet/gpui-ce?rev=8d8c07348532be87463aa666a01c1dbcb28651df#8d8c07348532be87463aa666a01c1dbcb28651df" dependencies = [ "accesskit", "accesskit_macos", @@ -3329,7 +3339,7 @@ dependencies = [ [[package]] name = "gpui_ce_macros" version = "0.1.0" -source = "git+https://github.com/artisanstreet/gpui-ce?rev=ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6#ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" +source = "git+https://github.com/artisanstreet/gpui-ce?rev=8d8c07348532be87463aa666a01c1dbcb28651df#8d8c07348532be87463aa666a01c1dbcb28651df" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3340,7 +3350,7 @@ dependencies = [ [[package]] name = "gpui_ce_media" version = "0.2.2" -source = "git+https://github.com/artisanstreet/gpui-ce?rev=ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6#ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" +source = "git+https://github.com/artisanstreet/gpui-ce?rev=8d8c07348532be87463aa666a01c1dbcb28651df#8d8c07348532be87463aa666a01c1dbcb28651df" dependencies = [ "anyhow", "bindgen", @@ -3354,7 +3364,7 @@ dependencies = [ [[package]] name = "gpui_ce_platform" version = "0.1.0" -source = "git+https://github.com/artisanstreet/gpui-ce?rev=ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6#ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" +source = "git+https://github.com/artisanstreet/gpui-ce?rev=8d8c07348532be87463aa666a01c1dbcb28651df#8d8c07348532be87463aa666a01c1dbcb28651df" dependencies = [ "console_error_panic_hook", "gpui-ce", @@ -3367,7 +3377,7 @@ dependencies = [ [[package]] name = "gpui_ce_refineable" version = "0.2.2" -source = "git+https://github.com/artisanstreet/gpui-ce?rev=ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6#ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" +source = "git+https://github.com/artisanstreet/gpui-ce?rev=8d8c07348532be87463aa666a01c1dbcb28651df#8d8c07348532be87463aa666a01c1dbcb28651df" dependencies = [ "gpui_ce_derive_refineable", ] @@ -3375,7 +3385,7 @@ dependencies = [ [[package]] name = "gpui_ce_scheduler" version = "0.2.2" -source = "git+https://github.com/artisanstreet/gpui-ce?rev=ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6#ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" +source = "git+https://github.com/artisanstreet/gpui-ce?rev=8d8c07348532be87463aa666a01c1dbcb28651df#8d8c07348532be87463aa666a01c1dbcb28651df" dependencies = [ "async-task", "backtrace", @@ -3391,7 +3401,7 @@ dependencies = [ [[package]] name = "gpui_ce_shared_string" version = "0.1.0" -source = "git+https://github.com/artisanstreet/gpui-ce?rev=ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6#ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" +source = "git+https://github.com/artisanstreet/gpui-ce?rev=8d8c07348532be87463aa666a01c1dbcb28651df#8d8c07348532be87463aa666a01c1dbcb28651df" dependencies = [ "schemars", "serde", @@ -3401,7 +3411,7 @@ dependencies = [ [[package]] name = "gpui_ce_sum_tree" version = "0.2.2" -source = "git+https://github.com/artisanstreet/gpui-ce?rev=ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6#ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" +source = "git+https://github.com/artisanstreet/gpui-ce?rev=8d8c07348532be87463aa666a01c1dbcb28651df#8d8c07348532be87463aa666a01c1dbcb28651df" dependencies = [ "heapless", "log", @@ -3412,7 +3422,7 @@ dependencies = [ [[package]] name = "gpui_ce_util" version = "0.2.6" -source = "git+https://github.com/artisanstreet/gpui-ce?rev=ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6#ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" +source = "git+https://github.com/artisanstreet/gpui-ce?rev=8d8c07348532be87463aa666a01c1dbcb28651df#8d8c07348532be87463aa666a01c1dbcb28651df" dependencies = [ "anyhow", "log", @@ -3422,7 +3432,7 @@ dependencies = [ [[package]] name = "gpui_ce_web" version = "0.1.0" -source = "git+https://github.com/artisanstreet/gpui-ce?rev=ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6#ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" +source = "git+https://github.com/artisanstreet/gpui-ce?rev=8d8c07348532be87463aa666a01c1dbcb28651df#8d8c07348532be87463aa666a01c1dbcb28651df" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3446,7 +3456,7 @@ dependencies = [ [[package]] name = "gpui_ce_wgpu" version = "0.1.0" -source = "git+https://github.com/artisanstreet/gpui-ce?rev=ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6#ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" +source = "git+https://github.com/artisanstreet/gpui-ce?rev=8d8c07348532be87463aa666a01c1dbcb28651df#8d8c07348532be87463aa666a01c1dbcb28651df" dependencies = [ "anyhow", "bytemuck", @@ -3472,7 +3482,7 @@ dependencies = [ [[package]] name = "gpui_ce_windows" version = "0.1.0" -source = "git+https://github.com/artisanstreet/gpui-ce?rev=ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6#ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" +source = "git+https://github.com/artisanstreet/gpui-ce?rev=8d8c07348532be87463aa666a01c1dbcb28651df#8d8c07348532be87463aa666a01c1dbcb28651df" dependencies = [ "accesskit", "accesskit_windows", diff --git a/Cargo.toml b/Cargo.toml index b0bda1ac2..bb6ab1465 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "modules/native_engine", "modules/protocol", "modules/transport", + "modules/tracing", "modules/ui", "scripts/capnp_codegen", "scripts/native_dev", @@ -36,8 +37,8 @@ capnpc = "=0.27.0" command-group = { version = "=5.0.1", default-features = false, features = ["with-tokio"] } fs2 = "=0.4.3" getrandom = { version = "=0.4.3", default-features = false } -gpui = { package = "gpui-ce", git = "https://github.com/artisanstreet/gpui-ce", rev = "ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6" } -gpui_platform = { package = "gpui_ce_platform", git = "https://github.com/artisanstreet/gpui-ce", rev = "ee668021b2be6b3af6ff1c37ae19aa2005c3c4b6", features = ["wgpu"] } +gpui = { package = "gpui-ce", git = "https://github.com/artisanstreet/gpui-ce", rev = "8d8c07348532be87463aa666a01c1dbcb28651df" } +gpui_platform = { package = "gpui_ce_platform", git = "https://github.com/artisanstreet/gpui-ce", rev = "8d8c07348532be87463aa666a01c1dbcb28651df", features = ["wgpu"] } http = { version = "=1.5.0", default-features = false, features = ["std"] } http-body-util = { version = "=0.1.5", default-features = false } hyper = { version = "=1.11.0", default-features = false, features = ["client", "http1"] } diff --git a/docs/reference-scene-interface.md b/docs/reference-scene-interface.md index 132b42fdb..b8963e7e1 100644 --- a/docs/reference-scene-interface.md +++ b/docs/reference-scene-interface.md @@ -128,11 +128,15 @@ life, and work and non-promoted prose after the steer stay session details. session: `superseded = true` iff some other block follows the first segment. A superseded session never narrates (renderer hides its live line); the turn-level status row at turn end narrates current work. -- Disclosure. A section cannot be collapsed before its turn settles: while - the turn lifecycle is live the panel is forced open and the header has no - chevron or toggle; the disclosure machine refuses a close for active work - and reopens on re-activation. Once settled, one toggle hides the rows of - every segment while the steer bubbles stay visible. +- Disclosure. A section cannot be collapsed by hand before its turn + settles: while the turn lifecycle is live the panel is forced open and the + header has no chevron or toggle; the disclosure machine refuses a close + for active work and reopens on re-activation. The one exception is the + reply: the moment the promoted reply's first whole word or block is + visibly revealed, the renderer folds the live section (still without a + toggle), and it reopens if that prose is demoted back into the work. + Once settled, one toggle hides the rows of every segment while the steer + bubbles stay visible. - Backend ordering. The Forge opens an assistant item ahead of its first text; a steer projected while that item is still empty retires it, so the acknowledging prose opens a new item after the steer and the seam rule diff --git a/docs/runbooks/flight-recorder.md b/docs/runbooks/flight-recorder.md new file mode 100644 index 000000000..86ac050f0 --- /dev/null +++ b/docs/runbooks/flight-recorder.md @@ -0,0 +1,114 @@ +# Editor and Forge flight recorder + +Debug payloads from `nix run .#dev` include the local flight recorder and start +it automatically. Use the app normally. A stalled UI, stalled operation, busy +admission, failed request, or failed navigation saves a Chrome Trace Event JSON +file that opens directly in [Perfetto](https://ui.perfetto.dev/). + +Press **Ctrl+Shift+F11** to save recent Editor activity immediately. Automatic +incidents retain two seconds after the trigger; normal exit saves another +snapshot. This works for detached Windows launches without stdout capture. + +Files go to `%TEMP%\artisan-traces` on Windows and `$TMPDIR/artisan-traces` +on Unix, normally `/tmp/artisan-traces`. `ARTISAN_TRACE_DIR` selects another +directory. Editor and Forge write on their respective machines, so a WSL +Forge's files are on Linux. The newest sixteen JSON files per application are +retained across process restarts. `ARTISAN_TRACE=0` disables recording at launch. +Set these variables in the environment of the actual Editor/Forge process; +an already running Forge service does not inherit a new terminal environment. + +## What to inspect + +The history covers up to two minutes or 32,768 events, whichever fits first. +High frame rates shorten the retained time window. Traces contain: + +- UI and transport heartbeats, with stalled/resumed breadcrumbs; +- GPUI CPU draws and renderer submission durations, dirty-to-draw latency, + animation intervals, and foreground task polls over ten milliseconds; +- UI handlers, route mounting, snapshots, patch processing, and event batches; +- command queue depth, admission/rejection, queue waits, execution spans, + producer-to-consumer flow arrows, and thread identities; +- correlated wire requests, response-family expectations, outcomes, and + reconnect/subscription work; +- recent-thread waits, thread-switch generations/phases, project intake, + navigation gates, pending unsubscribe, and connection holds; +- matching Forge request spans and event-loop stall reports. + +Async spans have unique IDs and use `b`/`e`, so concurrent work and executor +migration do not produce falsely nested synchronous slices. Unfinished spans +are closed at the capture boundary with `unfinished=true`. A beginning that +aged out is marked `begin_truncated=true`. `dropped_records` and +`evicted_records` make recording loss explicit. Thread-switch state includes +its owning span ID; request spans include the command span that issued them. + +For the delayed busy notification, search for `recent_thread.failed`, then +inspect `navigation.gates` immediately before it. The ten-second recent-thread +wait ends with that failure if navigation is still inadmissible. Follow the +switch phase, outstanding command, and request rather than inferring that the +clicked thread itself was busy. + +Use `python3 scripts/trace.py summary TRACE.json` for a text report of the +longest operations and the last navigation gates. To combine local Editor and +Forge exports into one Perfetto timeline: + +```sh +python3 scripts/trace.py merge combined.json editor-TRACE.json forge-TRACE.json +``` + +Matching `request_id` values connect processes. Timestamps use Unix +microseconds anchored to each process's monotonic clock. Different machines +need synchronized wall clocks for visual alignment; IDs remain useful when +their clocks differ. Merge remaps process IDs to avoid Windows/Linux PID +collisions and distinguishes process restarts. Overlapping captures are +deduplicated; real span completions supersede synthetic snapshot boundaries. + +## Build-time removal + +The shared `artisan-tracing` module has no default features. Without `enabled`, +it contains only macros that erase their arguments. It has no recorder, +serialization dependency, environment handling, filesystem writer, or watchdog. +Each application also gates all tracing fields, startup tasks, and adapters +behind its `flight-recorder` Cargo feature. Production's Nix feature list is +empty, so the tracing implementation is absent from the binaries. + +For a Cargo diagnostic build, enable `artisan-frontend/flight-recorder` and +`artisan-backend/flight-recorder`. Build without either for the normal +production behavior. Cargo features are additive; deliberately enabling these +features on a custom release build deliberately includes the recorder. + +## Cost and limits + +UI and transport producers use bounded, nonblocking sends. History collection +and filesystem output run on separate threads. Saturation drops records rather +than waiting. Arguments accept only bounded scalar values. Instrumentation +records fixed operation names, opaque IDs, enum states, counters, and source +locations; it does not record prompts, thread titles, file targets, credentials, +URLs, snapshots, screenshots, or provider payloads. Files are local, and Unix +exports are created with owner-only permissions. + +The UI watchdog triggers after 500 ms without a foreground heartbeat. A long +navigation/project/request span triggers after five seconds. Automatic +captures have a ten-second cooldown. The existing Forge watchdog triggers at +two seconds. Whole-process suspension is reported separately by the watchdogs. +Recording is instrumentation, not native stack sampling: an uninstrumented +blocking function may still need an OS profiler. Completed GPUI task records +can attribute a slow poll to its source location after the UI resumes. + +File writing never blocks the UI while the application runs. Normal process +exit joins the recorder and writer to finish the final file; force-killing the +process can lose the latest in-memory history. Export failures go to stderr. +The recorder does not require a GUI viewer to be open and makes no network +requests. + +## Verification + +```sh +cargo test -p artisan-tracing --features enabled +cargo test -p artisan-tracing --no-default-features +python3 tests/tracing/merge.py +``` + +Set `ARTISAN_TRACE_KEEP_FIXTURE=1` on the enabled test command and add +`-- --nocapture` to retain its generated stall, manual, and shutdown captures +for an importer check. Handoff arrows use one-microsecond endpoint markers; +their duration represents a marker rather than measured application work. diff --git a/docs/runbooks/project-icons.md b/docs/runbooks/project-icons.md new file mode 100644 index 000000000..0d0b42ca9 --- /dev/null +++ b/docs/runbooks/project-icons.md @@ -0,0 +1,36 @@ +# Recent-thread project icons + +The icon beside a thread's project subtitle is 14 pixels wide. Disk-only +projects and Git repositories without a network remote show a folder. + +For a remote repository, the first available icon wins: + +1. A project file, checked in this order: `.artisan/icon.png`, + `.artisan/icon.webp`, `icon.png`, `favicon.png`, `public/favicon.png`. +2. Repository artwork supplied by the Git host. +3. Organization, namespace, or user artwork supplied by the Git host. +4. The hosting service's bundled logo, or the Git logo for unknown hosts. + +GitHub resolves the public organization/user avatar. GitLab, Bitbucket, +Codeberg, and Gitea resolve repository artwork before owner artwork. GitHub +has no dedicated repository avatar endpoint, so a local project file provides +its project-specific artwork. Private or inaccessible metadata requires no +login and falls back to the logo. No credentials are sent by this resolver. + +Forge discovers and downloads icons in the background. Subtitles and icons +come from cached repository observations; listing threads and painting the +sidebar perform no Git, file, or network I/O. Successful artwork is refreshed +after an hour, absent artwork after five minutes. Changes are pushed through +the existing recent-thread subscription. A changed remote invalidates the +previous icon. Detached projects leave the cache. + +Downloaded input is capped at 512 KiB and decoding runs off the Forge event +loop with image allocation and dimension limits. Artwork is normalized to a +PNG within 48 by 48 pixels and 16 KiB before crossing the protocol. Editor +reuses the decoded image identity between repaints. The added protocol fields +are optional, so an older Forge supplies folder fallback and an older Editor +ignores the artwork. + +Metadata field references: [GitHub account avatars](https://docs.github.com/en/rest/users/users#get-a-user), +[GitLab project and namespace avatars](https://docs.gitlab.com/api/projects/), +and [Gitea repository metadata](https://docs.gitea.com/api/operations/repo-get/). diff --git a/modules/assets/fonts/FONTS.md b/modules/assets/fonts/FONTS.md index 82e7f0131..b9f8f51b4 100644 --- a/modules/assets/fonts/FONTS.md +++ b/modules/assets/fonts/FONTS.md @@ -1,8 +1,8 @@ # Bundled native fonts Spline Sans serves UI, headings and body. Spline Sans Mono serves code. -Artisan Neo serves the native titlebar wordmark at its single SemiBold -weight. Cal Sans remains vendored but is not currently requested by any +Test Founders Grotesk serves the native titlebar wordmark at Bold weight. +Artisan Neo remains vendored at its single SemiBold weight. Cal Sans remains vendored but is not currently requested by any native surface. Spline faces are licensed under SIL OFL 1.1; Cal Sans is licensed under SIL OFL 1.1; the Artisan Neo license record is the verbatim in-repo frontend OFL file (see provenance below). @@ -59,3 +59,11 @@ Registration therefore uses static instances at 300, 400, 500, 600 and 700 for e | `spline-sans-mono-700.ttf` | 700 | `74189c4811dbf9abd88f2ad83950d7afcc9d230e7ecc092d36097efffa67090c` | Twemoji Mozilla is registered as a color emoji fallback through the same catalog. Body and code family selections remain Spline. See [TWEMOJI.md](TWEMOJI.md) for the unmodified Mozilla v0.7.0 font, SHA-256, measured coverage, and licenses. Platform precedence is verified in the shared text backend tests. + +## Founders Grotesk header evaluation + +`test-founders-grotesk-bold.otf` is the user-supplied Klim desktop test font, +family `Test Founders Grotesk`, style Bold, weight 700. The native loader accepts +its OpenType CFF container directly. Size: 13928 bytes. +SHA-256: `d5b49fe5e53634e63f22d727f4a2d6bf09d399696eb2a4827e3e0838225e2c7b`. +See `../licenses/founders-grotesk-test.txt` for provenance. diff --git a/modules/assets/fonts/test-founders-grotesk-bold.otf b/modules/assets/fonts/test-founders-grotesk-bold.otf new file mode 100644 index 000000000..12d6fa5d6 Binary files /dev/null and b/modules/assets/fonts/test-founders-grotesk-bold.otf differ diff --git a/modules/assets/licenses/founders-grotesk-test.txt b/modules/assets/licenses/founders-grotesk-test.txt new file mode 100644 index 000000000..8e9fde7c8 --- /dev/null +++ b/modules/assets/licenses/founders-grotesk-test.txt @@ -0,0 +1,3 @@ +Test Founders Grotesk Bold, Klim Type Foundry test font. +User-supplied desktop OTF from KlimTestFonts/Test desktop fonts (Static, OTF)/Test Founders Grotesk Collection/Test Founders Grotesk/TestFoundersGrotesk-Bold.otf. +Included for local font evaluation at the user's request. This provenance note does not grant redistribution or production rights. diff --git a/modules/assets/src/fonts.rs b/modules/assets/src/fonts.rs index 39bf2377e..83996e6dc 100644 --- a/modules/assets/src/fonts.rs +++ b/modules/assets/src/fonts.rs @@ -1,5 +1,5 @@ //! Bundled Artisan Neo, Cal Sans, Spline Sans and Spline Sans Mono -//! TrueType fonts. +//! native OpenType fonts, plus Test Founders Grotesk Bold for evaluation. //! //! Files are embedded at compile time and registered once through //! `artisan_ui::fonts::register_bundled_fonts`. Family names and weight ranges @@ -7,8 +7,8 @@ //! weights 300–700 plus the conversation-prose weights 410 and 630 are //! registered because the WGPU text backend matches faces by metadata and //! does not apply variable weight axes. See `fonts/FONTS.md` for upstream -//! sources, licenses and SHA-256 hashes. TrueType is required by -//! DirectWrite's in-memory loader; WOFF2 is not supported there. +//! sources, licenses and SHA-256 hashes. Native sfnt containers are required +//! by DirectWrite's in-memory loader; WOFF2 is not supported there. use core::fmt; use std::borrow::Cow; @@ -26,7 +26,7 @@ pub struct BundledFont { pub license_path: &'static str, /// Embedded license or provenance note contents. pub license_text: &'static str, - /// Unmodified upstream TrueType bytes (provenance in `fonts/FONTS.md`). + /// Unmodified upstream font bytes (provenance in `fonts/FONTS.md`). pub bytes: &'static [u8], /// Static weight instances registered by renderers without variable-axis support. pub static_faces: &'static [&'static [u8]], @@ -102,6 +102,15 @@ pub const ALL: &[BundledFont] = &[ include_bytes!("../fonts/spline-sans-mono-700.ttf"), ], }, + BundledFont { + file_name: "test-founders-grotesk-bold.otf", + family: "Test Founders Grotesk", + weights: (700, 700), + license_path: "licenses/founders-grotesk-test.txt", + license_text: include_str!("../licenses/founders-grotesk-test.txt"), + bytes: include_bytes!("../fonts/test-founders-grotesk-bold.otf"), + static_faces: &[include_bytes!("../fonts/test-founders-grotesk-bold.otf")], + }, BundledFont { file_name: "twemoji-mozilla.ttf", family: "Twemoji Mozilla", @@ -146,17 +155,18 @@ mod tests { /// Expected `(file name, byte length)` pins: any truncation or /// re-encode of a vendored binary fails here before it can reach a /// renderer and silently fall back to a system face. - const EXPECTED_LENGTHS: [(&str, usize); 5] = [ + const EXPECTED_LENGTHS: [(&str, usize); 6] = [ ("artisan-neo-600.ttf", 343_516), ("cal-sans-700.ttf", 219_644), ("spline-sans-variable.ttf", 146_896), ("spline-sans-mono-variable.ttf", 118_744), + ("test-founders-grotesk-bold.otf", 13_928), ("twemoji-mozilla.ttf", 1_474_284), ]; #[test] fn catalog_carries_declared_text_and_emoji_faces() { - assert_eq!(ALL.len(), 5, "four text families plus the emoji fallback"); + assert_eq!(ALL.len(), 6, "five text families plus the emoji fallback"); let families: Vec<&str> = ALL.iter().map(|font| font.family).collect(); assert_eq!( families, @@ -165,6 +175,7 @@ mod tests { "Cal Sans", "Spline Sans", "Spline Sans Mono", + "Test Founders Grotesk", "Twemoji Mozilla" ], "catalog order is by family name for binary search" @@ -180,22 +191,18 @@ mod tests { } #[test] - fn embedded_bytes_are_truetype_sfnt_containers() { - // TrueType sfnt version `00 01 00 00`: catches text-encoding damage - // (e.g. line-ending conversion) at compile-test time, and pins the - // DirectWrite-loadable container — WOFF2 (`wOF2`) is rejected by the - // in-memory loader on Windows (`DWRITE_E_FILEFORMAT`) and must never - // be vendored here again. + fn embedded_bytes_are_native_sfnt_containers() { + // Accept TrueType and CFF OpenType sfnt containers. DirectWrite + // rejects WOFF2 (`wOF2`) in its in-memory loader. for font in ALL { assert!( font.bytes.len() > 48, - "{}: implausibly small for a variable font", + "{}: implausibly small for a font", font.file_name ); - assert_eq!( - &font.bytes[0..4], - b"\x00\x01\x00\x00", - "{}: missing TrueType sfnt magic; the binary is damaged or substituted", + assert!( + matches!(&font.bytes[0..4], b"\x00\x01\x00\x00" | b"OTTO"), + "{}: missing native sfnt magic; the binary is damaged or substituted", font.file_name ); } @@ -204,7 +211,7 @@ mod tests { #[test] fn bundled_fonts_shapes_borrowed_slices_for_add_fonts() { let shaped = bundled_fonts(); - assert_eq!(shaped.len(), 15); + assert_eq!(shaped.len(), 16); let expected = ALL.iter().flat_map(|font| font.static_faces.iter()); for (shaped, bytes) in shaped.iter().zip(expected) { assert_eq!(shaped.as_ref(), *bytes); diff --git a/modules/backend/Cargo.toml b/modules/backend/Cargo.toml index 0d586c210..140ac72c3 100644 --- a/modules/backend/Cargo.toml +++ b/modules/backend/Cargo.toml @@ -9,7 +9,11 @@ version.workspace = true name = "forge" path = "src/main.rs" +[features] +flight-recorder = ["artisan-tracing/enabled"] + [dependencies] +artisan-tracing = { path = "../tracing" } mimalloc.workspace = true artisan-catalog = { path = "../catalog" } artisan-database = { path = "../database" } diff --git a/modules/backend/src/composer_state_handler.rs b/modules/backend/src/composer_state_handler.rs index ec45d3404..78e67e13b 100644 --- a/modules/backend/src/composer_state_handler.rs +++ b/modules/backend/src/composer_state_handler.rs @@ -104,6 +104,12 @@ impl RequestHandler { .read_latest_run_usage(&query.run_id, &query.thread_id) .await .map_err(|error| usage_failure(&error, request_id))?; + let report = match report { + Some(report) => { + Some(crate::context_compaction_policy::with_catalog_window(report).await) + } + None => None, + }; let compaction_at = report .as_ref() .and_then(crate::context_compaction_policy::compaction_at_tokens); diff --git a/modules/backend/src/context_compaction_policy.rs b/modules/backend/src/context_compaction_policy.rs index 8580d10bf..785db9667 100644 --- a/modules/backend/src/context_compaction_policy.rs +++ b/modules/backend/src/context_compaction_policy.rs @@ -16,6 +16,21 @@ const CODEX_COMPACTION_PERCENT: u64 = 90; /// Claude Sonnet 5's documented default compaction capacity in tokens. const CLAUDE_SONNET_5_COMPACTION_TOKENS: u64 = 967_000; +/// Fills a window the engine did not report (Claude reports none) from the +/// cached model catalog, so the context meter has a denominator. Native +/// engines report their engine id as the provider route. +pub(crate) async fn with_catalog_window(report: RunUsageReport) -> RunUsageReport { + if report.context_window_tokens().is_some() { + return report; + } + let window = crate::model_discovery::cached_context_window( + report.provider_route_id().as_str(), + report.model_id().as_str(), + ) + .await; + report.with_catalog_window(window) +} + /// Returns the context size, in tokens, at which the engine that reported /// `report` compacts, or `None` when no documented policy applies (the /// window boundary is then the only limit) or the report carries no window. @@ -68,6 +83,29 @@ mod tests { .expect("report") } + #[test] + fn a_catalog_window_fills_only_a_missing_one() { + let missing = report("claude", "claude-opus-5-5", None); + assert_eq!( + missing + .clone() + .with_catalog_window(Some(1_000_000)) + .context_window_tokens(), + Some(1_000_000) + ); + assert_eq!( + missing.with_catalog_window(Some(0)).context_window_tokens(), + None + ); + let reported = report("codex", "gpt-5", Some(272_000)); + assert_eq!( + reported + .with_catalog_window(Some(1_000_000)) + .context_window_tokens(), + Some(272_000) + ); + } + #[test] fn codex_compacts_at_ninety_percent_of_its_window() { assert_eq!( diff --git a/modules/backend/src/conversation_delivery_driver.rs b/modules/backend/src/conversation_delivery_driver.rs index 2524f8a33..537c26ae6 100644 --- a/modules/backend/src/conversation_delivery_driver.rs +++ b/modules/backend/src/conversation_delivery_driver.rs @@ -304,6 +304,7 @@ impl ConversationDeliveryDriver { let Some(report) = report else { return Ok(()); }; + let report = crate::context_compaction_policy::with_catalog_window(report).await; let compaction_at = crate::context_compaction_policy::compaction_at_tokens(&report); let Ok(usage) = RunUsageResult::new(thread_id.clone(), run_id, Some(report)) .map(|usage| usage.with_compaction_at(compaction_at)) diff --git a/modules/backend/src/engine_owner/claude/adapter.rs b/modules/backend/src/engine_owner/claude/adapter.rs index 370b99947..68302fe6a 100644 --- a/modules/backend/src/engine_owner/claude/adapter.rs +++ b/modules/backend/src/engine_owner/claude/adapter.rs @@ -22,7 +22,7 @@ use super::protocol::{ use super::text::{ClaudeTextLedger, ClaudeTextSettlement}; use super::thinking::ClaudeThinkingTracker; use super::tools::ClaudeToolTracker; -use super::usage::{ClaudeUsageScope, project_usage_sample}; +use super::usage::{ClaudeUsageSample, ClaudeUsageScope, project_usage_sample}; /// Builds one validated subagent discovery row. /// @@ -214,6 +214,9 @@ pub(crate) struct ClaudePendingTracker { background_tasks: usize, semantic_failure: bool, summary_title: Option, + /// The latest per-response context gauge, kept for the result frame: + /// its totals carry no gauge, and its report must not drop the reading. + usage_context: Option, } impl ClaudePendingTracker { @@ -691,6 +694,9 @@ async fn apply_assistant_frame( } } } + if let Some(context) = frame.usage.and_then(|sample| sample.context) { + tracker.usage_context = Some(context); + } match frame.usage.as_ref() { Some(sample) => { match project_usage_sample(observations, run_id, usage, frame_sequence, sample).await { @@ -867,6 +873,12 @@ pub(crate) async fn apply_event( tracker.semantic_failure = true; } tracker.note_permission_denials(permission_denials); + // The result's totals carry the turn's window but no gauge; the + // last response's gauge rides along so the reading stays whole. + let sample = sample.map(|sample| ClaudeUsageSample { + context: sample.context.or(tracker.usage_context), + ..sample + }); if let Some(sample) = sample.as_ref() && let Some(state) = project_usage_sample(observations, run_id, usage, frame_sequence, sample).await diff --git a/modules/backend/src/engine_owner/claude/protocol.rs b/modules/backend/src/engine_owner/claude/protocol.rs index 1e1f39e55..9b36bea3b 100644 --- a/modules/backend/src/engine_owner/claude/protocol.rs +++ b/modules/backend/src/engine_owner/claude/protocol.rs @@ -10,7 +10,10 @@ use super::content::{ ClaudeAssistantFrame, assistant_content, decode_block_start, decode_block_stop, decode_thinking_delta, }; -use super::usage::{ClaudeUsageSample, parse_claude_assistant_usage, parse_claude_result_usage}; +use super::usage::{ + ClaudeUsageSample, parse_claude_assistant_usage, parse_claude_context_window, + parse_claude_result_usage, +}; pub(crate) const CLAUDE_MAX_FRAME_BYTES: usize = 1_048_576; @@ -409,7 +412,11 @@ pub(crate) fn parse_frame( Ok(sample) => sample, Err(_) => return Ok(ClaudeEvent::Unknown), }, - }; + } + .map(|sample| ClaudeUsageSample { + window: parse_claude_context_window(&Value::Object(object.clone())), + ..sample + }); return Ok(ClaudeEvent::TurnResult { success: !is_error, session_id: object @@ -605,7 +612,11 @@ fn decode_result(envelope: &Value) -> ClaudeEvent { Ok(sample) => sample, Err(_) => return ClaudeEvent::Unknown, }, - }; + } + .map(|sample| ClaudeUsageSample { + window: parse_claude_context_window(envelope), + ..sample + }); ClaudeEvent::TurnResult { success: subtype == "success" && !is_error, session_id: envelope diff --git a/modules/backend/src/engine_owner/claude/usage.rs b/modules/backend/src/engine_owner/claude/usage.rs index 12b53dfc9..e2891d096 100644 --- a/modules/backend/src/engine_owner/claude/usage.rs +++ b/modules/backend/src/engine_owner/claude/usage.rs @@ -28,6 +28,23 @@ pub(crate) struct ClaudeUsageSample { /// taken from terminal totals, which re-count the context on every model /// call. Absent stays absent rather than becoming a wrong zero. pub context: Option, + /// The context window the turn ran in, from the result frame's + /// `modelUsage` (see [`parse_claude_context_window`]). + pub window: Option, +} + +/// Reads the context window from a `result` frame's `modelUsage`: one entry +/// per model the turn used, each with its `contextWindow`. The largest is +/// the root model's; subagents on smaller models report smaller windows. +/// Absent or non-positive values report no window. +pub(crate) fn parse_claude_context_window(envelope: &Value) -> Option { + envelope + .get("modelUsage")? + .as_object()? + .values() + .filter_map(|model| model.get("contextWindow").and_then(Value::as_u64)) + .filter(|window| *window > 0) + .max() } fn claude_token_field( @@ -73,6 +90,7 @@ pub(crate) fn parse_claude_result_usage( cached_input: claude_token_field(object, "cache_read_input_tokens")?, output: claude_token_field(object, "output_tokens")?, context: None, + window: None, }; Ok( if sample.input.is_none() && sample.cached_input.is_none() && sample.output.is_none() { @@ -112,6 +130,7 @@ pub(crate) fn parse_claude_assistant_usage( cached_input: read, output: claude_token_field(object, "output_tokens")?, context, + window: None, }; Ok( if sample.input.is_none() @@ -188,7 +207,7 @@ pub(crate) fn claude_usage_report( cached_input_tokens: sample.cached_input, output_tokens: sample.output, context_tokens: sample.context, - context_window_tokens: None, + context_window_tokens: sample.window, observed_at: context.observed_at, }) .ok() diff --git a/modules/backend/src/engine_owner/operation/claude.rs b/modules/backend/src/engine_owner/operation/claude.rs index b88177636..e83c88432 100644 --- a/modules/backend/src/engine_owner/operation/claude.rs +++ b/modules/backend/src/engine_owner/operation/claude.rs @@ -497,18 +497,15 @@ async fn run_claude_title( .take(CLAUDE_TITLE_MAX_BYTES); let stderr = child.stderr.take(); let mut output = Vec::new(); - let (read, ()) = tokio::join!( - stdout.read_to_end(&mut output), - async { - if let Some(stderr) = stderr { - let _ = tokio::io::copy( - &mut stderr.take(CLAUDE_TITLE_MAX_BYTES), - &mut tokio::io::sink(), - ) - .await; - } + let (read, ()) = tokio::join!(stdout.read_to_end(&mut output), async { + if let Some(stderr) = stderr { + let _ = tokio::io::copy( + &mut stderr.take(CLAUDE_TITLE_MAX_BYTES), + &mut tokio::io::sink(), + ) + .await; } - ); + }); read.map_err(|error| format!("reading the naming run's output: {error}"))?; let status = child .wait() diff --git a/modules/backend/src/engine_owner/socket/codex_session.rs b/modules/backend/src/engine_owner/socket/codex_session.rs index 10e2e7599..33659a914 100644 --- a/modules/backend/src/engine_owner/socket/codex_session.rs +++ b/modules/backend/src/engine_owner/socket/codex_session.rs @@ -328,9 +328,14 @@ async fn await_preflight_result( ) -> CodexPreflightWait { let deadline = phase_deadline(context.limits.prompt, context.attempt_deadline); loop { - if let Err(read) = - codex_runtime::read_codex_line(reader, line, deadline, context.shutdown, context.control) - .await + if let Err(read) = codex_runtime::read_codex_line( + reader, + line, + deadline, + context.shutdown, + context.control, + ) + .await { // An oversized reply is the adapter's bound, not the engine's // failure: its stderr holds no reason for it. diff --git a/modules/backend/src/event_loop_watchdog.rs b/modules/backend/src/event_loop_watchdog.rs index e839dad10..bb6e115e9 100644 --- a/modules/backend/src/event_loop_watchdog.rs +++ b/modules/backend/src/event_loop_watchdog.rs @@ -93,12 +93,20 @@ fn watch(origin: Instant, stamp: &AtomicU64, running: &AtomicBool, mut report: i let lag = now.saturating_sub(last); match blocked_since { None if lag > millis(STALL) => { + #[cfg(feature = "flight-recorder")] + artisan_tracing::instant!("watchdog", "forge.event_loop_stalled", "lag_ms" => lag); + #[cfg(feature = "flight-recorder")] + artisan_tracing::incident!("forge.event_loop_stalled"); report(format!( "Forge event loop blocked for {lag} ms: lease heartbeats, request deadlines and conversation delivery are stalled" )); blocked_since = Some(last); } Some(since) if lag <= millis(STALL) => { + #[cfg(feature = "flight-recorder")] + artisan_tracing::instant!("watchdog", "forge.event_loop_resumed", "duration_ms" => last.saturating_sub(since)); + #[cfg(feature = "flight-recorder")] + artisan_tracing::incident!("forge.event_loop_resumed"); report(format!( "Forge event loop resumed after being blocked for {} ms", last.saturating_sub(since) diff --git a/modules/backend/src/forge_runtime/startup.rs b/modules/backend/src/forge_runtime/startup.rs index c0b7cfcd6..291da4f9f 100644 --- a/modules/backend/src/forge_runtime/startup.rs +++ b/modules/backend/src/forge_runtime/startup.rs @@ -352,6 +352,7 @@ pub async fn run(config: ForgeLaunchConfig) -> Result<(), ForgeRuntimeError> { return Err(ForgeRuntimeError::ApplicationStartup(error)); } }; + crate::model_discovery::warm_in_background(); Box::pin(run_with_context(ForgeRunContext { app, diff --git a/modules/backend/src/lib.rs b/modules/backend/src/lib.rs index a35469b68..b2eb36f7a 100644 --- a/modules/backend/src/lib.rs +++ b/modules/backend/src/lib.rs @@ -42,6 +42,7 @@ pub mod orchestration_intake_policy; pub mod preview_service_policy; pub mod process_custody; pub mod product_telemetry_capture_policy; +mod project_icon_service; pub mod project_repository_service; pub mod project_subtitle_policy; pub mod project_subtitles; @@ -118,6 +119,10 @@ pub use storage::{ForgeStorage, ForgeStorageCloseError, ForgeStorageOpenError}; /// handle attached to the signal and process futures. #[must_use] pub fn run() -> ExitCode { + #[cfg(feature = "flight-recorder")] + let _trace_session = artisan_tracing::start("forge"); + #[cfg(feature = "flight-recorder")] + artisan_tracing::name_thread("forge.event_loop"); let runtime = match tokio::runtime::Builder::new_current_thread() .enable_all() .build() diff --git a/modules/backend/src/model_discovery/grok.rs b/modules/backend/src/model_discovery/grok.rs index 758f179e9..c33dcbd7d 100644 --- a/modules/backend/src/model_discovery/grok.rs +++ b/modules/backend/src/model_discovery/grok.rs @@ -1,9 +1,11 @@ //! Grok Build model discovery (`grok models`). //! -//! The CLI lists the account-visible models under a `models:` header. The -//! command is the same bounded invocation the readiness probe already uses -//! (`--no-auto-update models`); here its stdout is parsed into rows. Unknown -//! columns after the id are kept as the display label. +//! The CLI lists the account-visible models under a header ending in +//! `models:` (`Available models:` since 1.0.4x), marking the default with +//! `*` and a trailing `(default)`. The command is the same bounded +//! invocation the readiness probe already uses (`--no-auto-update models`); +//! here its stdout is parsed into rows. Unknown columns after the id are kept +//! as the display label; the `(default)` annotation is a marker, not a label. use std::time::Duration; @@ -15,10 +17,18 @@ const DEADLINE: Duration = Duration::from_secs(4); /// Output bound for the listing command. const MAX_BYTES: usize = 1024 * 1024; +/// What a Grok Build model listing proved. +#[derive(Debug)] +pub(super) enum GrokListing { + /// The account's models. + Models(Vec), + /// The CLI is signed out. It still prints a built-in fallback list, + /// which is not the account's and is discarded. + SignedOut, +} + /// Probes Grok Build; `None` when it does not answer. -pub(super) async fn discover_grok( - program: Option<&super::EngineProgram>, -) -> Option> { +pub(super) async fn discover_grok(program: Option<&super::EngineProgram>) -> Option { let executable = program?; let output = Box::pin(run_bounded( executable, @@ -30,16 +40,32 @@ pub(super) async fn discover_grok( if !output.success { return None; } - Some(parse_models(&output.stdout)) + if is_signed_out(&output.stdout) { + return Some(GrokListing::SignedOut); + } + Some(GrokListing::Models(parse_models(&output.stdout))) +} + +/// Whether the listing opens with the CLI's signed-out notice +/// (`You are not authenticated.` in Grok Build 1.0.46). +fn is_signed_out(output: &str) -> bool { + output + .lines() + .map(str::trim) + .take_while(|line| !line.to_ascii_lowercase().ends_with("models:")) + .any(|line| { + let line = line.to_ascii_lowercase(); + line.contains("not authenticated") || line.contains("not logged in") + }) } -/// Parses the `models:` listing. Lines before the header are ignored when the -/// header is present; ids must be single tokens. +/// Parses the model listing. Lines before a header ending in `models:` are +/// ignored when one is present; ids must be single tokens. fn parse_models(output: &str) -> Vec { let lines = output.lines().collect::>(); let header = lines .iter() - .position(|line| line.trim().eq_ignore_ascii_case("models:")); + .position(|line| line.trim().to_ascii_lowercase().ends_with("models:")); let candidates = match header { Some(index) => &lines[index + 1..], None => &lines[..], @@ -63,7 +89,15 @@ fn parse_models(output: &str) -> Vec { if !is_model_id(id) || !(indented || id_has_signal(id)) { continue; } - let label = parts.collect::>().join(" "); + let mut default = trimmed.starts_with('*'); + let label = parts + .filter(|part| { + let marker = part.eq_ignore_ascii_case("(default)"); + default |= marker; + !marker + }) + .collect::>() + .join(" "); rows.push(DiscoveredModel { engine_id: "grok", provider: if id.to_ascii_lowercase().starts_with("composer-") { @@ -81,7 +115,7 @@ fn parse_models(output: &str) -> Vec { }, description: None, hidden: false, - default: trimmed.starts_with('*'), + default, thinking: DiscoveredThinking::Native { description: "Grok Build manages reasoning effort for this model through the harness; the catalogue does not report a separate effort control." @@ -153,6 +187,31 @@ mod tests { assert_eq!(rows[0].name, "Composer Fast"); } + #[test] + fn default_annotation_marks_the_default_instead_of_naming_it() { + // Grok Build 1.0.46 output, verbatim. + let rows = parse_models( + "Default model: grok-4.6\n\nAvailable models:\n * grok-4.6 (default)\n - grok-4.5\n", + ); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].native_model_id, "grok-4.6"); + assert_eq!(rows[0].name, "Grok 4.6"); + assert!(rows[0].default); + assert_eq!(rows[1].native_model_id, "grok-4.5"); + assert_eq!(rows[1].name, "Grok 4.5"); + assert!(!rows[1].default); + } + + #[test] + fn signed_out_listing_is_not_the_accounts() { + assert!(is_signed_out( + "You are not authenticated.\n\nDefault model: grok-4.6\n\nAvailable models:\n * grok-4.6 (default)\n - grok-4.5\n" + )); + assert!(!is_signed_out( + "Default model: grok-4.7\n\nAvailable models:\n * grok-4.7 (default)\n" + )); + } + #[test] fn ignores_prose_and_missing_header() { assert!(parse_models("not authenticated\n").is_empty()); diff --git a/modules/backend/src/model_discovery/mod.rs b/modules/backend/src/model_discovery/mod.rs index 2c015420a..c687f986f 100644 --- a/modules/backend/src/model_discovery/mod.rs +++ b/modules/backend/src/model_discovery/mod.rs @@ -21,7 +21,7 @@ use tokio::sync::Mutex; use claude::discover_claude; use codex::discover_codex; use cursor::discover_cursor; -use grok::discover_grok; +use grok::{GrokListing, discover_grok}; use opencode2::discover_opencode2; /// How long one discovery snapshot is served before re-probing. @@ -128,6 +128,9 @@ pub(crate) struct DiscoveryBundle { /// Engines whose CLI is not installed on this machine; their harnesses /// are hidden from the picker until the engine appears. pub(crate) missing_engines: Vec<&'static str>, + /// Installed engines whose CLI reported it is signed out. They report no + /// models and are not runnable, so the picker greys their tab out. + pub(crate) unauthenticated_engines: Vec<&'static str>, } impl DiscoveryBundle { @@ -146,21 +149,92 @@ type Cache = Mutex)>>; static CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); +/// Admits one probe at a time: a cold read waits for it, a stale read only +/// starts one when none is running. +static PROBE: Mutex<()> = Mutex::const_new(()); + fn cache() -> &'static Cache { CACHE.get_or_init(|| Mutex::new(None)) } -/// Returns the current discovery bundle, probing all engines when the cache is -/// cold or stale. Probes run concurrently and each adapter is independently +/// The context window the catalog lists for one engine's model, from the +/// bundle already cached; never probes, so a usage read never waits on an +/// engine process. +pub(crate) async fn cached_context_window(engine_id: &str, model_id: &str) -> Option { + let (_, _, bundle) = cache().lock().await.clone()?; + bundle + .models + .iter() + .find(|model| model.engine_id == engine_id && model.native_model_id == model_id) + .and_then(|model| model.context_window_tokens) +} + +/// Returns the current discovery bundle. +/// +/// Once any probe has completed this never waits on engine processes: a +/// stale bundle is served as is while one background probe refreshes it, +/// so catalog reads and sends (which resolve their model against the +/// catalog) answer immediately. Only a cold Forge waits, once, for its +/// first probe. Probes run concurrently, each adapter independently /// bounded; an adapter that fails contributes nothing. pub(crate) async fn discovery_bundle() -> Arc { - let mut guard = cache().lock().await; - if let Some((observed, ttl, bundle)) = guard.as_ref() - && observed.elapsed() < *ttl - { - return Arc::clone(bundle); + if let Some((observed, ttl, bundle)) = cache().lock().await.clone() { + if observed.elapsed() >= ttl { + refresh_in_background(); + } + return bundle; + } + let _probe = PROBE.lock().await; + // A concurrent cold read may have probed while this one waited. + if let Some((_, _, bundle)) = cache().lock().await.clone() { + return bundle; + } + probe_and_store().await +} + +/// Warms discovery at Forge startup so the first catalog read rarely waits: +/// reads that arrive meanwhile join this probe instead of starting their own. +pub(crate) fn warm_in_background() { + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async { + let _ = discovery_bundle().await; + }); } +} +/// Starts a background probe unless one is already running. Outside a Tokio +/// runtime the stale bundle simply keeps serving until the next read. +fn refresh_in_background() { + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + return; + }; + let Ok(probe) = PROBE.try_lock() else { + return; + }; + drop(probe); + runtime.spawn(async { + let Ok(_probe) = PROBE.try_lock() else { + return; + }; + let stale = cache() + .lock() + .await + .as_ref() + .is_none_or(|(observed, ttl, _)| observed.elapsed() >= *ttl); + if stale { + probe_and_store().await; + } + }); +} + +/// Probes every engine and stores the bundle. The cache lock is never held +/// across the probes, so readers keep answering from the previous bundle. +async fn probe_and_store() -> Arc { + let previous = cache() + .lock() + .await + .as_ref() + .map(|(_, _, bundle)| Arc::clone(bundle)); let codex_program = managed_program(ManagedEngine::Codex); let cursor_program = managed_program(ManagedEngine::Cursor); let grok_program = managed_program(ManagedEngine::Grok); @@ -177,6 +251,14 @@ pub(crate) async fn discovery_bundle() -> Arc { let mut models = Vec::new(); let mut probed_engines = Vec::new(); let mut missing_engines = Vec::new(); + let mut unauthenticated_engines = Vec::new(); + let grok = grok.map(|listing| match listing { + GrokListing::Models(rows) => rows, + GrokListing::SignedOut => { + unauthenticated_engines.push("grok"); + Vec::new() + } + }); for (engine_id, installed, rows) in [ ("codex", codex_program.is_some(), codex), ("claude", true, claude), @@ -198,7 +280,7 @@ pub(crate) async fn discovery_bundle() -> Arc { retry_needed = true; // Retain only previously discovered runtime rows during a // transient failure. A successful probe replaces them. - if let Some((_, _, previous)) = guard.as_ref() { + if let Some(previous) = previous.as_ref() { models.extend( previous .models @@ -215,13 +297,14 @@ pub(crate) async fn discovery_bundle() -> Arc { models, probed_engines, missing_engines, + unauthenticated_engines, }); let ttl = if retry_needed { Duration::from_secs(10) } else { DISCOVERY_TTL }; - *guard = Some((Instant::now(), ttl, Arc::clone(&bundle))); + *cache().lock().await = Some((Instant::now(), ttl, Arc::clone(&bundle))); bundle } diff --git a/modules/backend/src/native_model_catalog.rs b/modules/backend/src/native_model_catalog.rs index 3811edef0..2a0f0c2d6 100644 --- a/modules/backend/src/native_model_catalog.rs +++ b/modules/backend/src/native_model_catalog.rs @@ -257,7 +257,10 @@ pub(crate) fn from_catalog_result_with_discovery( favorites: &ModelFavoritesSnapshot, ) -> Result { let catalog = from_catalog_result(result, favorites)?; - if discovery.models.is_empty() && discovery.missing_engines.is_empty() { + if discovery.models.is_empty() + && discovery.missing_engines.is_empty() + && discovery.unauthenticated_engines.is_empty() + { return Ok(catalog); } let mut runtime = catalog.runtime(); @@ -274,6 +277,7 @@ pub(crate) fn from_catalog_result_with_discovery( runtime.routes.push(route); } } + drop_unauthenticated(&mut runtime.runnable_harness_ids, discovery); runtime.catalog_revision = Some(format!( "{base_revision}+discovery-{:016x}", discovery_revision_hash(discovery) @@ -292,15 +296,17 @@ pub(crate) fn from_discovery( .map_err(|_| NativeModelCatalogBridgeError::HarnessManifest)? .manifest; let routes = apply_discovery(&mut manifest, discovery, true); + let mut runnable_harness_ids = RUNNABLE_ENGINE_IDS + .iter() + .map(|harness| (*harness).to_owned()) + .collect(); + drop_unauthenticated(&mut runnable_harness_ids, discovery); let runtime = NativeCatalogRuntime { catalog_revision: Some(format!( "discovery-{:016x}", discovery_revision_hash(discovery) )), - runnable_harness_ids: RUNNABLE_ENGINE_IDS - .iter() - .map(|harness| (*harness).to_owned()) - .collect(), + runnable_harness_ids, routes, ..NativeCatalogRuntime::default() }; @@ -310,6 +316,20 @@ pub(crate) fn from_discovery( Ok(catalog) } +/// Removes engines whose CLI reported it is signed out from the runnable +/// harnesses: none of their models can run until the account signs in. +fn drop_unauthenticated( + runnable_harness_ids: &mut Vec, + discovery: &crate::model_discovery::DiscoveryBundle, +) { + runnable_harness_ids.retain(|engine_id| { + !discovery + .unauthenticated_engines + .iter() + .any(|unauthenticated| *unauthenticated == engine_id) + }); +} + /// Stable revision contribution for one discovery bundle. fn discovery_revision_hash(discovery: &crate::model_discovery::DiscoveryBundle) -> u64 { let mut hash: u64 = 0xcbf2_9ce4_8422_2325; diff --git a/modules/backend/src/native_model_catalog/tests.rs b/modules/backend/src/native_model_catalog/tests.rs index daa35316d..5c349f4b1 100644 --- a/modules/backend/src/native_model_catalog/tests.rs +++ b/modules/backend/src/native_model_catalog/tests.rs @@ -436,6 +436,7 @@ fn discovered_codex_rows_carry_reported_fields_and_context_policy() { ], probed_engines: vec!["codex"], missing_engines: Vec::new(), + unauthenticated_engines: Vec::new(), }; let catalog = from_catalog_result_with_discovery( fixture_result(), @@ -517,6 +518,7 @@ fn cli_discovered_opencode2_rows_carry_runnable_route_identity() { ], probed_engines: vec!["opencode2"], missing_engines: Vec::new(), + unauthenticated_engines: Vec::new(), }; let catalog = from_discovery(&discovery).expect("catalog builds"); let id = artisan_catalog::wire::opencode2_catalog_id("kimi-k3", "opencode-go", None) @@ -566,6 +568,7 @@ fn missing_engine_harnesses_are_hidden() { models: Vec::new(), probed_engines: Vec::new(), missing_engines: vec!["cursor", "grok"], + unauthenticated_engines: Vec::new(), }; let catalog = from_discovery(&discovery).expect("catalog builds"); let hidden = |id: &str| catalog.manifest.harness(id).expect("harness exists").hidden; @@ -580,6 +583,27 @@ fn missing_engine_harnesses_are_hidden() { assert!(!hidden("opencode2")); } +#[test] +fn signed_out_engines_stay_visible_but_cannot_run() { + let discovery = crate::model_discovery::DiscoveryBundle { + models: Vec::new(), + probed_engines: vec!["grok"], + missing_engines: Vec::new(), + unauthenticated_engines: vec!["grok"], + }; + let catalog = from_discovery(&discovery).expect("catalog builds"); + assert!( + !catalog + .manifest + .harness("grok") + .expect("grok harness") + .hidden + ); + assert!(!catalog.runnable_harness_ids.iter().any(|id| id == "grok")); + assert!(!catalog.engine_has_models("grok")); + assert!(catalog.runnable_harness_ids.iter().any(|id| id == "codex")); +} + #[test] fn discovered_display_names_are_dehyphenated() { let discovery = crate::model_discovery::DiscoveryBundle { @@ -603,6 +627,7 @@ fn discovered_display_names_are_dehyphenated() { ], probed_engines: vec!["codex"], missing_engines: Vec::new(), + unauthenticated_engines: Vec::new(), }; let catalog = from_catalog_result_with_discovery( fixture_result(), @@ -648,6 +673,7 @@ fn discovery_adds_claude_rows_with_1m_policy_only_when_eligible() { ], probed_engines: vec!["claude"], missing_engines: Vec::new(), + unauthenticated_engines: Vec::new(), }; let catalog = from_catalog_result_with_discovery( fixture_result(), @@ -707,6 +733,7 @@ fn hidden_discovered_rows_are_never_surfaced() { ], probed_engines: vec!["codex"], missing_engines: Vec::new(), + unauthenticated_engines: Vec::new(), }; let catalog = from_catalog_result_with_discovery( fixture_result(), @@ -740,6 +767,7 @@ fn picker_rows_follow_the_engine_reported_order() { .collect(), probed_engines: vec!["codex"], missing_engines: Vec::new(), + unauthenticated_engines: Vec::new(), }) .expect("scope-free catalog builds"); let rows = catalog.models_for_engine("codex", "", None); @@ -765,6 +793,7 @@ fn discovery_only_catalog_contains_exactly_the_discovered_rows() { )], probed_engines: vec!["codex"], missing_engines: Vec::new(), + unauthenticated_engines: Vec::new(), }) .expect("scope-free catalog builds"); assert_eq!( @@ -800,6 +829,7 @@ fn discovery_rows_survive_wire_validation() { )], probed_engines: vec!["codex"], missing_engines: Vec::new(), + unauthenticated_engines: Vec::new(), }; let catalog = from_catalog_result(fixture_result(), &ModelFavoritesSnapshot::empty()) .expect("base catalog"); diff --git a/modules/backend/src/project_icon_service.rs b/modules/backend/src/project_icon_service.rs new file mode 100644 index 000000000..4a258cf9c --- /dev/null +++ b/modules/backend/src/project_icon_service.rs @@ -0,0 +1,212 @@ +//! Background discovery of small repository icons. No I/O runs in a listing. + +use std::io::{Cursor, Read}; +use std::path::Path; +use std::sync::OnceLock; +use std::time::Duration; + +use artisan_domain::{RECENT_PROJECT_ICON_MAX_BYTES, RecentProjectIcon, RootPath}; +use reqwest::Url; +use serde_json::Value; +use tokio::sync::Semaphore; + +use crate::git_remote_url_policy::{ + repository_host_for, repository_path_for, repository_web_url_for, +}; +use crate::project_repository_service::RepositoryObservation; + +const INPUT_LIMIT: usize = 512 * 1024; +static DOWNLOADS: Semaphore = Semaphore::const_new(4); +static CLIENT: OnceLock> = OnceLock::new(); + +pub(crate) fn identity(observation: Option<&RepositoryObservation>) -> Option { + let artisan_protocol::ProjectRepository::Repository(repository) = &observation?.repository + else { + return None; + }; + repository_web_url_for(repository.default_remote_projection()?.url()) +} + +pub(crate) fn fallback(remote: Option<&str>) -> RecentProjectIcon { + RecentProjectIcon { + host: remote.and_then(|remote| { + artisan_domain::DisplayName::parse(repository_host_for(remote).as_str()).ok() + }), + png: Default::default(), + } +} + +pub(crate) async fn resolve(root: &RootPath, remote: &str) -> RecentProjectIcon { + let mut result = fallback(Some(remote)); + let local_root = root.clone(); + if let Ok(Some(png)) = + tokio::task::spawn_blocking(move || local_icon(Path::new(local_root.as_str()))).await + { + result.png = png.into(); + return result; + } + // Best-effort metadata is bounded independently of Git observations. + if let Ok(Some(png)) = tokio::time::timeout(Duration::from_secs(8), remote_icon(remote)).await { + result.png = png.into(); + } + result +} + +fn local_icon(root: &Path) -> Option> { + for relative in [ + ".artisan/icon.png", + ".artisan/icon.webp", + "icon.png", + "favicon.png", + "public/favicon.png", + ] { + let file = std::fs::File::open(root.join(relative)).ok(); + let Some(file) = file else { continue }; + let mut bytes = Vec::new(); + if file + .take((INPUT_LIMIT + 1) as u64) + .read_to_end(&mut bytes) + .is_ok() + && let Some(png) = normalize(&bytes) + { + return Some(png); + } + } + None +} + +fn normalize(bytes: &[u8]) -> Option> { + if bytes.len() > INPUT_LIMIT { + return None; + } + let mut reader = image::ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(4096); + limits.max_image_height = Some(4096); + limits.max_alloc = Some(32 * 1024 * 1024); + reader.limits(limits); + let image = reader.decode().ok()?.thumbnail(48, 48); + let mut output = Cursor::new(Vec::new()); + image.write_to(&mut output, image::ImageFormat::Png).ok()?; + let png = output.into_inner(); + (png.len() <= RECENT_PROJECT_ICON_MAX_BYTES).then_some(png) +} + +fn client() -> Option<&'static reqwest::Client> { + CLIENT + .get_or_init(|| { + reqwest::Client::builder() + .timeout(Duration::from_secs(3)) + .redirect(reqwest::redirect::Policy::limited(3)) + .user_agent("Artisan-Editor-project-icons") + .build() + .ok() + }) + .as_ref() +} + +async fn download(url: Url) -> Option> { + if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() { + return None; + } + let _permit = DOWNLOADS.acquire().await.ok()?; + let mut response = client()? + .get(url) + .send() + .await + .ok()? + .error_for_status() + .ok()?; + if response + .content_length() + .is_some_and(|n| n > INPUT_LIMIT as u64) + { + return None; + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.ok()? { + if bytes.len() + chunk.len() > INPUT_LIMIT { + return None; + } + bytes.extend_from_slice(&chunk); + } + Some(bytes) +} + +async fn remote_icon(remote: &str) -> Option> { + let base = Url::parse(remote).ok()?; + let path = repository_path_for(remote)?; + // Never send a private repository name to another host. GitHub's public + // account endpoint also works when its repository cannot be read. + let metadata_url = metadata_url(&base, &path)?; + let metadata: Value = serde_json::from_slice(&download(metadata_url).await?).ok()?; + for candidate in candidates(&metadata, base.host_str()?) { + let Ok(url) = base.join(candidate) else { + continue; + }; + if let Some(bytes) = download(url).await + && let Ok(Some(png)) = tokio::task::spawn_blocking(move || normalize(&bytes)).await + { + return Some(png); + } + } + None +} + +fn metadata_url(base: &Url, path: &str) -> Option { + use crate::git_remote_url_policy::RepositoryHost; + let mut url = base.clone(); + url.set_query(None); + url.set_fragment(None); + match repository_host_for(base.as_str()) { + RepositoryHost::Github if base.host_str() == Some("github.com") => { + url = Url::parse("https://api.github.com/users/").ok()?; + url.path_segments_mut() + .ok()? + .pop_if_empty() + .push(path.split('/').next()?); + } + RepositoryHost::Gitlab => { + url.set_path("/api/v4/projects/"); + url.path_segments_mut().ok()?.pop_if_empty().push(path); + } + RepositoryHost::Bitbucket if base.host_str() == Some("bitbucket.org") => { + url = Url::parse("https://api.bitbucket.org/2.0/repositories/").ok()?; + for segment in path.split('/') { + url.path_segments_mut().ok()?.pop_if_empty().push(segment); + } + } + RepositoryHost::Codeberg | RepositoryHost::Gitea => { + url.set_path("/api/v1/repos/"); + for segment in path.split('/') { + url.path_segments_mut().ok()?.pop_if_empty().push(segment); + } + } + _ => return None, + } + Some(url) +} + +fn candidates<'a>(metadata: &'a Value, host: &str) -> Vec<&'a str> { + let fields: &[&str] = match repository_host_for(&format!("https://{host}")) { + crate::git_remote_url_policy::RepositoryHost::Github => &["/avatar_url"], + crate::git_remote_url_policy::RepositoryHost::Gitlab => { + &["/avatar_url", "/namespace/avatar_url", "/owner/avatar_url"] + } + crate::git_remote_url_policy::RepositoryHost::Bitbucket => { + &["/links/avatar/href", "/owner/links/avatar/href"] + } + _ => &["/avatar_url", "/owner/avatar_url"], + }; + fields + .iter() + .filter_map(|field| metadata.pointer(field)?.as_str()) + .filter(|url| !url.is_empty()) + .collect() +} + +#[cfg(test)] +#[path = "../../../tests/backend/project_icon_service.rs"] +mod tests; diff --git a/modules/backend/src/project_subtitles.rs b/modules/backend/src/project_subtitles.rs index 7d6e7eb4a..a1d22e9da 100644 --- a/modules/backend/src/project_subtitles.rs +++ b/modules/backend/src/project_subtitles.rs @@ -21,7 +21,7 @@ use std::sync::{Arc, Mutex, PoisonError}; use std::time::{Duration, Instant}; use artisan_database::Repository; -use artisan_domain::{DisplayName, ProjectId, ProjectSummary, RootPath}; +use artisan_domain::{DisplayName, ProjectId, ProjectSummary, RecentProjectIcon, RootPath}; use artisan_transport::CancelHandle; use crate::conversation_commit_notifier::ConversationCommitNotifier; @@ -62,6 +62,10 @@ struct Observed { at: Instant, /// Consecutive failed reads, zero after a successful one. failures: u32, + icon: RecentProjectIcon, + icon_identity: Option, + icon_at: Option, + icon_pending: bool, } impl Observed { @@ -112,6 +116,24 @@ impl ProjectSubtitles { self.shared.generation.load(Ordering::Acquire) } + /// Reads only cached artwork; never waits on files, Git, or a network. + #[must_use] + pub fn icons(&self, projects: &[ProjectSummary]) -> HashMap { + let state = self.lock(); + projects + .iter() + .map(|project| { + ( + project.project_id.clone(), + state + .observed + .get(&project.root_path) + .map_or_else(RecentProjectIcon::default, |observed| observed.icon.clone()), + ) + }) + .collect() + } + /// Resolves every project's subtitle from its cached observation, and /// starts one background refresh for roots that are due. Roots of /// projects no longer attached leave the cache. @@ -226,7 +248,8 @@ impl ProjectSubtitles { break; } let observation = self.shared.service.observe_root(&root).await.ok(); - changed |= self.record(root, observation); + changed |= self.record(root.clone(), observation); + self.refresh_icon(root, notifier.clone()); } if changed { self.shared.generation.fetch_add(1, Ordering::AcqRel); @@ -243,17 +266,74 @@ impl ProjectSubtitles { (Some(_), _) => 0, (None, previous) => previous.map_or(0, |previous| previous.failures) + 1, }; + let icon_identity = crate::project_icon_service::identity(observation.as_ref()); + let retained = previous.filter(|previous| previous.icon_identity == icon_identity); + let icon = retained.map_or_else( + || crate::project_icon_service::fallback(icon_identity.as_deref()), + |previous| previous.icon.clone(), + ); + let icon_at = retained.and_then(|previous| previous.icon_at); + let icon_pending = retained.is_some_and(|previous| previous.icon_pending); state.observed.insert( root, Observed { observation, at: Instant::now(), failures, + icon, + icon_identity, + icon_at, + icon_pending, }, ); changed } + fn refresh_icon(&self, root: RootPath, notifier: ConversationCommitNotifier) { + let identity = { + let mut state = self.lock(); + let Some(observed) = state.observed.get_mut(&root) else { + return; + }; + let Some(identity) = observed.icon_identity.clone() else { + return; + }; + let freshness = if observed.icon.png.is_empty() { + Duration::from_secs(300) + } else { + Duration::from_secs(3600) + }; + if observed.icon_pending || observed.icon_at.is_some_and(|at| at.elapsed() < freshness) + { + return; + } + observed.icon_pending = true; + identity + }; + let subtitles = self.clone(); + tokio::spawn(async move { + let icon = crate::project_icon_service::resolve(&root, &identity).await; + let changed = { + let mut state = subtitles.lock(); + let Some(observed) = state.observed.get_mut(&root) else { + return; + }; + if observed.icon_identity.as_ref() != Some(&identity) { + return; + } + observed.icon_pending = false; + observed.icon_at = Some(Instant::now()); + let changed = observed.icon != icon; + observed.icon = icon; + changed + }; + if changed { + subtitles.shared.generation.fetch_add(1, Ordering::AcqRel); + notifier.wake_any(); + } + }); + } + fn lock(&self) -> std::sync::MutexGuard<'_, CacheState> { self.shared .state diff --git a/modules/backend/src/request_handler.rs b/modules/backend/src/request_handler.rs index 60c4436e2..3169fc3bf 100644 --- a/modules/backend/src/request_handler.rs +++ b/modules/backend/src/request_handler.rs @@ -785,6 +785,8 @@ impl RequestHandler { request_id: &RequestId, request: &ClientRequest, ) -> Result { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("forge.request", "request.handle", "request_id" => request_id.as_str()); match request { ClientRequest::Query(query) => self.query_outcome(request_id, query).await, ClientRequest::Command(command) => self.command_outcome(request_id, command).await, @@ -865,6 +867,8 @@ impl RequestHandler { request: &ClientRequest, ) -> RequestHandlerResponse { context.observe_usage(); + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("forge.request", "request.with_receipt", "request_id" => request_id.as_str()); match request { ClientRequest::Conversation(ConversationRequest::Subscribe(subscribe)) => { self.subscribe_with_receipt_in_context(context, request_id, subscribe) @@ -1078,14 +1082,8 @@ impl RequestHandler { self.answer_questions_outcome(request_id, answer).await } Command::SetThreadEngineConfig(config) => { - let response = self - .set_thread_engine_config_outcome(request_id, config.as_ref()) - .await; - if response.is_ok() { - // A configuration the user saves is the new default. - self.remember_default_engine_config(config.config()).await; - } - response + self.set_thread_engine_config_outcome(request_id, config.as_ref()) + .await } Command::WithdrawQueuedMessage(command) => { self.withdraw_composer_message(request_id, command).await @@ -1110,6 +1108,9 @@ impl RequestHandler { Command::SubmitComposerDraft(submit) => { self.submit_composer_draft_outcome(request_id, submit).await } + Command::SaveModelPreference(save) => { + self.save_model_preference_outcome(request_id, save).await + } Command::RecordNavigation(record) => { self.record_navigation_outcome(request_id, record).await } diff --git a/modules/backend/src/request_handler/model_selection.rs b/modules/backend/src/request_handler/model_selection.rs index f7f581a28..028df1a6c 100644 --- a/modules/backend/src/request_handler/model_selection.rs +++ b/modules/backend/src/request_handler/model_selection.rs @@ -394,11 +394,7 @@ impl RequestHandler { }) .await { - Ok(_) => { - // The selection a send saved is the user's latest choice. - self.remember_default_engine_config(config).await; - Ok(true) - } + Ok(_) => Ok(true), Err(artisan_database::RepositoryError::EngineConfigRevisionConflict { .. }) => { Ok(false) } diff --git a/modules/backend/src/request_handler/project_draft_submission.rs b/modules/backend/src/request_handler/project_draft_submission.rs index 0a652a620..7bc4d26de 100644 --- a/modules/backend/src/request_handler/project_draft_submission.rs +++ b/modules/backend/src/request_handler/project_draft_submission.rs @@ -37,12 +37,8 @@ const NEW_TASK_TITLE: &str = "New task"; /// How a new task's first message is admitted. enum NewTaskAdmission { - /// Create the thread with `config`; `chosen` when the user's selection - /// resolved to it, so it becomes the default. - Admitted { - config: EngineRunConfig, - chosen: bool, - }, + /// Create the thread with `config` without changing user preferences. + Admitted { config: EngineRunConfig }, /// Refuse the send; nothing is created. Refused(SubmissionRefusal), } @@ -67,8 +63,8 @@ impl RequestHandler { .answer_project_submission(request_id, submit, replay) .await; } - let (config, chosen) = match self.admit_new_task(project, submit).await { - NewTaskAdmission::Admitted { config, chosen } => (config, chosen), + let config = match self.admit_new_task(project, submit).await { + NewTaskAdmission::Admitted { config } => config, NewTaskAdmission::Refused(refusal) => { let refused = DraftSubmissionOutcome::Refused(refusal); return Ok(submitted(request_id, submit, refused)); @@ -108,15 +104,6 @@ impl RequestHandler { DraftSubmissionError::Queue(error) => repository_failure(&error, request_id), DraftSubmissionError::Draft(error) => draft_failure(&error, request_id), })?; - let first = matches!( - &submission, - ProjectDraftSubmission::Queued { result, .. } - if result.receipt.disposition == artisan_domain::ReceiptDisposition::Accepted - ); - if first && chosen { - // The selection a send saves is the user's latest choice. - self.remember_default_engine_config(&config).await; - } self.answer_project_submission(request_id, submit, submission) .await } @@ -141,10 +128,7 @@ impl RequestHandler { .ok() .and_then(|preferences| preferences.default_engine_config); if let Some(config) = default { - return NewTaskAdmission::Admitted { - config, - chosen: false, - }; + return NewTaskAdmission::Admitted { config }; } None } @@ -160,10 +144,7 @@ impl RequestHandler { SubmissionPlan::Refuse(refusal) => NewTaskAdmission::Refused(refusal), SubmissionPlan::Admit { save: Some(config), .. - } => NewTaskAdmission::Admitted { - config: *config, - chosen: true, - }, + } => NewTaskAdmission::Admitted { config: *config }, SubmissionPlan::Admit { save: None, .. } => { unreachable!("an admitted selection on an unconfigured thread is always saved") } diff --git a/modules/backend/src/request_handler/recent_threads.rs b/modules/backend/src/request_handler/recent_threads.rs index 3d1f1df70..3ecf3dc7b 100644 --- a/modules/backend/src/request_handler/recent_threads.rs +++ b/modules/backend/src/request_handler/recent_threads.rs @@ -75,6 +75,7 @@ async fn read_recent_threads( let unobserved = ConversationCommitNotifier::new(); subtitles.subtitles(projects.projects(), notifier.unwrap_or(&unobserved)) }); + let icons = subtitles.map(|subtitles| subtitles.icons(projects.projects())); let subtitle = |thread: &ThreadSummary| -> Option { resolved .as_ref() @@ -92,6 +93,11 @@ async fn read_recent_threads( .filter_map(|thread| { Some(RecentThread { subtitle: subtitle(&thread)?, + project_icon: icons + .as_ref() + .and_then(|icons| icons.get(&thread.project_id)) + .cloned() + .unwrap_or_default(), thread, }) }) diff --git a/modules/backend/src/request_handler/user_preferences.rs b/modules/backend/src/request_handler/user_preferences.rs index 3307f21a6..62b7a66b6 100644 --- a/modules/backend/src/request_handler/user_preferences.rs +++ b/modules/backend/src/request_handler/user_preferences.rs @@ -1,14 +1,14 @@ //! The Forge user's preferences, navigation record, and account profile. //! //! The Forge owns every rule: a recorded navigation moves its project to the -//! front and remembers its thread; a configuration the user saves on a -//! thread becomes the default new threads start from; a legacy import only +//! front and remembers its thread; an explicit user model preference sets +//! the default new threads start from; a legacy import only //! fills what the Forge does not have yet. The account profile is the host //! account the Forge runs as. use artisan_domain::{ - Command, EngineRunConfig, ImportLegacyPreferences, LegacyPreferencesImported, RecordNavigation, - RequestId, + Command, ImportLegacyPreferences, LegacyPreferencesImported, ModelPreferenceSource, + RecordNavigation, RequestId, SaveModelPreference, }; use artisan_protocol::{ProtocolFailure, ResponsePayload, ServerResponse}; @@ -99,27 +99,57 @@ impl RequestHandler { )) } - /// Makes a configuration the user saved on a thread the default new - /// threads start from. Best effort: the thread's own save already - /// succeeded, and a default that could not be stored leaves the previous - /// one. - /// Wakes every connection to push preferences a command may have changed - /// (a navigation, an import, or a saved configuration); only a changed - /// value crosses the wire. + /// Saves only explicit user choices. Runtime choices from agents or + /// background work never update the user's default. + pub(super) async fn save_model_preference_outcome( + &self, + request_id: &RequestId, + save: &SaveModelPreference, + ) -> Result { + if save.source != ModelPreferenceSource::User { + return self.read_user_preferences_outcome(request_id).await; + } + let catalog = crate::composer_catalog_handler::host_catalog(self.account_usage.as_deref()) + .await + .map_err(|_| { + super::failures::typed_failure( + artisan_protocol::ErrorCode::Internal, + "The model catalog is unavailable. Retry saving your preference.", + true, + request_id, + ) + })?; + let config = super::model_selection::resolve_in_catalog(catalog, &save.selection, None) + .map_err(|refusal| { + super::failures::typed_failure( + artisan_protocol::ErrorCode::InvalidInput, + refusal.message(), + false, + request_id, + ) + })? + .into_config(); + let stored = self + .repository + .save_model_preference(&config, save.source) + .await + .map_err(|error| repository_failure(&error, request_id))?; + Ok(outcome( + request_id, + ResponsePayload::UserPreferences(crate::account_profile::user_preferences(stored)), + )) + } + + /// Wakes connections after explicit preference changes. pub(super) fn wake_preferences(&self, command: &Command) { if matches!( command, Command::RecordNavigation(_) | Command::ImportLegacyPreferences(_) - | Command::SetThreadEngineConfig(_) - | Command::SubmitComposerDraft(_) + | Command::SaveModelPreference(_) ) && let Some(notifier) = &self.conversation_commit_notifier { notifier.publish_host_state(); } } - - pub(super) async fn remember_default_engine_config(&self, config: &EngineRunConfig) { - let _ = self.repository.remember_default_engine_config(config).await; - } } diff --git a/modules/build_info/src/lib.rs b/modules/build_info/src/lib.rs index 049265001..050e1ac8d 100644 --- a/modules/build_info/src/lib.rs +++ b/modules/build_info/src/lib.rs @@ -253,25 +253,29 @@ impl BuildIdentity { } } - /// In-app badge beside the wordmark: the title marker of installed - /// non-stable builds. Unstaged binaries are marked only in the OS window - /// title, so test and fixture renders keep the product's own chrome. - /// - /// The badge shares the narrow sidebar column with the wordmark, so it - /// carries a seven-character commit. + /// In-app badge beneath the wordmark, with a channel prefix and an + /// uppercase seven-character commit. Stable and beta payloads use `P`, + /// development payloads `D`, and nightly payloads `N`. Unstaged binaries + /// have no payload identity and omit the badge. #[must_use] pub fn badge(&self) -> Option { match self { - Self::Installed(info) if info.channel == Channel::Stable => None, - Self::Installed(info) => Some(match &info.commit { - Some(commit) => format!( - "{} {}{}", - info.channel.label(), - commit.get(..7).unwrap_or(commit), - if info.dirty { "+" } else { "" } - ), - None => info.channel.label().to_owned(), - }), + Self::Installed(info) => { + let prefix = match info.channel { + Channel::Stable | Channel::Beta => "P", + Channel::Dev => "D", + Channel::Nightly => "N", + }; + Some(match &info.commit { + Some(commit) => { + format!( + "{prefix}-{}", + commit.get(..7).unwrap_or(commit).to_ascii_uppercase() + ) + } + None => prefix.to_owned(), + }) + } Self::Unstaged(_) => None, } } diff --git a/modules/catalog/rust/policy.rs b/modules/catalog/rust/policy.rs index 7b22087b9..f70318557 100644 --- a/modules/catalog/rust/policy.rs +++ b/modules/catalog/rust/policy.rs @@ -612,6 +612,17 @@ impl NativeModelCatalog { } } + /// Returns whether `engine_id` reported at least one enabled model. An + /// engine that reported nothing, or whose listing the Forge dropped as + /// incomplete (a signed-out CLI's fallback list), has none. + #[must_use] + pub fn engine_has_models(&self, engine_id: &str) -> bool { + self.manifest + .models + .iter() + .any(|model| model.harness == engine_id && model.disabled.is_none()) + } + /// Returns filtered model rows for an engine, retaining unavailable rows /// for truthful preview while sorting favorites to the top. #[must_use] diff --git a/modules/database/src/repository/message_dispatch/lease.rs b/modules/database/src/repository/message_dispatch/lease.rs index e98be1443..7e746bdd1 100644 --- a/modules/database/src/repository/message_dispatch/lease.rs +++ b/modules/database/src/repository/message_dispatch/lease.rs @@ -25,7 +25,7 @@ use super::{DispatchLeaseOwner, TransitionedMessageDispatch}; const RENEW_DISPATCH_LEASE_SQL: &str = r" UPDATE message_dispatches SET lease_expires_at_ms = ?, - updated_at_ms = CASE WHEN state = 'leased' THEN ? ELSE updated_at_ms END + updated_at_ms = CASE WHEN state = 'leased' THEN MAX(updated_at_ms, ?) ELSE updated_at_ms END WHERE message_id = ? AND state IN ('leased', 'running') AND lease_owner = ? @@ -111,7 +111,7 @@ impl Repository { .await; }; let transitioned = - match renewed_from_row(&row, message_id, operated_at_ms, lease_expires_at_ms) { + match renewed_from_row(&row, message_id, lease_expires_at_ms) { Ok(transitioned) => transitioned, Err(error) => { return rollback_transition( @@ -134,7 +134,6 @@ impl Repository { fn renewed_from_row( row: &QueryResult, message_id: &MessageId, - operated_at_ms: i64, lease_expires_at_ms: i64, ) -> Result { let returned_id = row_value::(row, 0, "message_id", "message_dispatches")?; @@ -157,12 +156,12 @@ fn renewed_from_row( reason: "renewed lease returned inconsistent lease timestamps", }); } + // The stamp may be later than this renewal's clock reading: a running + // dispatch keeps the stamp its last commit wrote, commits never move it + // backwards, and the wall clock can step back (the WSL host-time sync + // does). That is the clock, not corruption, so it must not fail the + // heartbeat; the renewal itself never moves the stamp backwards. let updated_at_ms = row_value::(row, 4, "updated_at_ms", "message_dispatches")?; - if updated_at_ms > operated_at_ms { - return Err(RepositoryError::Invariant { - reason: "renewed lease returned inconsistent update timestamps", - }); - } Ok(TransitionedMessageDispatch { message_id: message_id.clone(), diff --git a/modules/database/src/repository/user_preferences.rs b/modules/database/src/repository/user_preferences.rs index 1b3eb32c3..3a7ce3eaa 100644 --- a/modules/database/src/repository/user_preferences.rs +++ b/modules/database/src/repository/user_preferences.rs @@ -10,8 +10,8 @@ use sea_orm::{ConnectionTrait, DatabaseTransaction, DbBackend, Statement, Value}; use artisan_domain::{ - EngineRunConfig, LegacyImportOutcome, NavigationProject, NavigationRecord, NavigationRoute, - ProjectId, ThreadId, UnixMillis, + EngineRunConfig, LegacyImportOutcome, ModelPreferenceSource, NavigationProject, + NavigationRecord, NavigationRoute, ProjectId, ThreadId, UnixMillis, }; use super::{Repository, RepositoryError, corrupt_data, database_error}; @@ -76,9 +76,25 @@ impl Repository { finish(transaction, result, "navigation record").await } - /// Makes `config` the default engine configuration. It follows a thread - /// save that already took its own acceptance instant, so it records no - /// time of its own. + /// Saves a user model preference. Agent and system choices leave the + /// stored preference and its revision unchanged. + /// + /// # Errors + /// + /// Returns an encoding or database failure. + pub async fn save_model_preference( + &self, + config: &EngineRunConfig, + source: ModelPreferenceSource, + ) -> PreferencesResult { + if source != ModelPreferenceSource::User { + return self.read_user_preferences().await; + } + self.remember_default_engine_config(config).await + } + + /// Stores a resolved default configuration. Runtime callers should use + /// `save_model_preference` with an explicit source instead. /// /// # Errors /// diff --git a/modules/domain/src/commands.rs b/modules/domain/src/commands.rs index 137f2f5d0..fe97c3881 100644 --- a/modules/domain/src/commands.rs +++ b/modules/domain/src/commands.rs @@ -17,7 +17,8 @@ use crate::{ ImportLegacyPreferences, ListFailedMessages, ListQueuedMessages, QueueStoredMessage, ReadAccountUsage, ReadComposerAttachment, ReadComposerDraft, ReadRecalledMessage, ReadRunUsage, ReadUserPreferences, RecordNavigation, RecoverFailedMessage, RetryFailedMessage, - SaveComposerDraft, SubmitComposerDraft, UploadComposerAttachment, WithdrawQueuedMessageCommand, + SaveComposerDraft, SaveModelPreference, SubmitComposerDraft, UploadComposerAttachment, + WithdrawQueuedMessageCommand, }; pub use crate::catalog_selection::ResolveModelSelection; @@ -302,6 +303,8 @@ pub enum Command { SubmitComposerDraft(SubmitComposerDraft), /// See [`RecordNavigation`]. RecordNavigation(RecordNavigation), + /// Saves an explicit user model preference without changing a thread. + SaveModelPreference(SaveModelPreference), /// See [`ImportLegacyPreferences`]. ImportLegacyPreferences(ImportLegacyPreferences), } @@ -329,6 +332,7 @@ impl Command { Self::RecoverFailedMessage(command) => &command.request_id, Self::SubmitComposerDraft(command) => &command.request_id, Self::RecordNavigation(command) => &command.request_id, + Self::SaveModelPreference(command) => &command.request_id, Self::ImportLegacyPreferences(command) => &command.request_id, } } diff --git a/modules/domain/src/lib.rs b/modules/domain/src/lib.rs index 33168c60c..ad3c8cc34 100644 --- a/modules/domain/src/lib.rs +++ b/modules/domain/src/lib.rs @@ -242,13 +242,14 @@ pub use manual_engine_configuration::{ pub mod recent_threads; pub use recent_threads::{ - RECENT_THREADS_MAX, ReadRecentThreads, RecentThread, RecentThreadListing, + RECENT_PROJECT_ICON_MAX_BYTES, RECENT_THREADS_MAX, ReadRecentThreads, RecentProjectIcon, + RecentThread, RecentThreadListing, }; pub mod user_preferences; pub use user_preferences::{ AccountProfile, ImportLegacyPreferences, LegacyImportOutcome, LegacyPreferencesImported, - NAVIGATION_PROJECTS_MAX, NavigationProject, NavigationRecord, NavigationRecordError, - NavigationRoute, ReadUserPreferences, RecordNavigation, UserPreferences, - UserPreferencesRevision, + ModelPreferenceSource, NAVIGATION_PROJECTS_MAX, NavigationProject, NavigationRecord, + NavigationRecordError, NavigationRoute, ReadUserPreferences, RecordNavigation, + SaveModelPreference, UserPreferences, UserPreferencesRevision, }; diff --git a/modules/domain/src/recent_threads.rs b/modules/domain/src/recent_threads.rs index 0baf8f40e..562db8529 100644 --- a/modules/domain/src/recent_threads.rs +++ b/modules/domain/src/recent_threads.rs @@ -6,7 +6,7 @@ //! project publishes to, or the project's name). The Editor only groups the //! rows by age for presentation. -use std::collections::HashSet; +use std::{collections::HashSet, sync::Arc}; use crate::model::{ThreadListingError, ThreadSummary}; use crate::text::DisplayName; @@ -15,6 +15,18 @@ use crate::time::UnixMillis; /// Maximum number of threads in one recent-threads listing. pub const RECENT_THREADS_MAX: usize = 100; +/// Maximum size of one normalized project icon carried with a recent row. +pub const RECENT_PROJECT_ICON_MAX_BYTES: usize = 16_384; + +/// Cached project identity artwork. An absent host denotes a disk-only project. +#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)] +pub struct RecentProjectIcon { + /// Repository hosting family, such as `github` or `gitlab`. + pub host: Option, + /// A normalized 48-pixel PNG; empty means use the hosting logo. + pub png: Arc<[u8]>, +} + /// Reads the recent threads across every attached project. The connection /// that read them receives later changes pushed as /// [`Event::RecentThreads`](crate::Event::RecentThreads). @@ -29,6 +41,8 @@ pub struct RecentThread { /// The resolved repository (`owner/repo`, with the branch of a linked /// worktree) or the project's display name. pub subtitle: DisplayName, + /// Project icon, resolved outside the listing and UI render paths. + pub project_icon: RecentProjectIcon, } impl RecentThread { diff --git a/modules/domain/src/run_usage.rs b/modules/domain/src/run_usage.rs index 99ddddb95..63d33ec13 100644 --- a/modules/domain/src/run_usage.rs +++ b/modules/domain/src/run_usage.rs @@ -125,6 +125,18 @@ impl RunUsageReport { self } + /// Fills the context window the provider did not report, from the + /// model catalog: a reported window is never replaced, and zero is no + /// window. + #[must_use] + pub fn with_catalog_window(mut self, window: Option) -> Self { + if self.context_window_tokens.is_none() { + self.context_window_tokens = + window.filter(|window| (1..=RUN_USAGE_MAX_TOKEN_COUNT).contains(window)); + } + self + } + /// Observed reference tokens per second, multiplied by 1000. #[must_use] pub const fn streaming_millitokens_per_second(&self) -> Option { diff --git a/modules/domain/src/user_preferences.rs b/modules/domain/src/user_preferences.rs index a74eddd38..8778e60f3 100644 --- a/modules/domain/src/user_preferences.rs +++ b/modules/domain/src/user_preferences.rs @@ -7,7 +7,7 @@ //! restarted or reconnected Editor resumes where the user was. The Editor //! renders them and reports navigation; the Forge owns every rule (the //! most-recently-used order, which thread is remembered, when a saved -//! configuration becomes the default). +//! explicit user model choices become the default). use thiserror::Error; @@ -179,6 +179,31 @@ pub struct LegacyPreferencesImported { pub preferences: UserPreferences, } +/// Origin of a model preference mutation. This is provenance, not authentication. +/// Only an explicit user choice may replace the user's default. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ModelPreferenceSource { + /// An agent's runtime choice, including a subagent spawned through MCP. + #[default] + Agent, + /// A choice made by the user in the Editor. + User, + /// An automatic configuration or background operation. + System, +} + +/// Saves the model and its options as the user's default, independently of +/// a thread or a send. Non-user sources leave the existing preference intact. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SaveModelPreference { + /// Mutation identity. + pub request_id: RequestId, + /// Catalog identities for the model, effort, permission, and other options. + pub selection: CatalogSelection, + /// Who made the choice. Callers must supply it explicitly. + pub source: ModelPreferenceSource, +} + #[cfg(test)] mod tests { use super::*; diff --git a/modules/frontend/Cargo.toml b/modules/frontend/Cargo.toml index 604115f26..fe599f0b7 100644 --- a/modules/frontend/Cargo.toml +++ b/modules/frontend/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" # Debug-stage facilities (the GPUI element inspector). Never part of a # Production build. debug-tools = ["gpui/inspector"] +flight-recorder = ["artisan-tracing/enabled"] visual-proof = ["gpui/test-support", "gpui_platform/test-support"] [[example]] @@ -21,6 +22,7 @@ path = "examples/parity_visual_proof.rs" required-features = ["visual-proof"] [dependencies] +artisan-tracing = { path = "../tracing" } artisan-assets = { path = "../assets" } artisan-build-info = { path = "../build_info" } artisan-catalog = { path = "../catalog" } diff --git a/modules/frontend/src/composer_queue_state/state.rs b/modules/frontend/src/composer_queue_state/state.rs index eac66722b..77a9440be 100644 --- a/modules/frontend/src/composer_queue_state/state.rs +++ b/modules/frontend/src/composer_queue_state/state.rs @@ -32,6 +32,15 @@ struct UsageScope { thread_id: ThreadId, run_id: RunId, generation: u64, + /// The launch policy reports must match, when the Editor knows the ids + /// the engine reports under (`OpenCode` 2). A native engine reports + /// under its own ids; its thread and run are the attribution. + policy: Option, +} + +/// The model, route and variant an `OpenCode` 2 run reports under. +#[derive(Clone, Debug, Eq, PartialEq)] +struct UsagePolicy { model_id: EngineModelId, route_id: EngineRouteId, variant_id: Option, @@ -509,18 +518,41 @@ impl ComposerQueueState { route_id: EngineRouteId, variant_id: Option, ) -> bool { - if self.current_thread.as_ref() != Some(&thread_id) || self.current_generation != generation - { - return false; - } - let next = UsageScope { + self.replace_usage_scope(UsageScope { thread_id, run_id, generation, - model_id, - route_id, - variant_id, - }; + policy: Some(UsagePolicy { + model_id, + route_id, + variant_id, + }), + }) + } + + /// Starts or retains a reporting scope for one native engine run, which + /// accepts the run's reports under whatever model and route the engine + /// names. + pub(crate) fn begin_native_usage_scope( + &mut self, + thread_id: ThreadId, + generation: u64, + run_id: RunId, + ) -> bool { + self.replace_usage_scope(UsageScope { + thread_id, + run_id, + generation, + policy: None, + }) + } + + fn replace_usage_scope(&mut self, next: UsageScope) -> bool { + if self.current_thread.as_ref() != Some(&next.thread_id) + || self.current_generation != next.generation + { + return false; + } if self.usage_scope.as_ref() != Some(&next) { self.usage_scope = Some(next); self.usage = None; @@ -658,9 +690,11 @@ impl ComposerQueueState { }; if report.thread_id() != &scope.thread_id || report.run_id() != &scope.run_id - || report.model_id() != &scope.model_id - || report.provider_route_id() != &scope.route_id - || report.variant_id() != scope.variant_id.as_ref() + || scope.policy.as_ref().is_some_and(|policy| { + report.model_id() != &policy.model_id + || report.provider_route_id() != &policy.route_id + || report.variant_id() != policy.variant_id.as_ref() + }) { return Err(UsageResultError { result: RunUsageResult { @@ -707,7 +741,9 @@ impl ComposerQueueState { ) -> Option { let scope = self.usage_scope.as_ref()?; let usage = self.usage.as_ref()?; - if current_run_id != Some(scope.run_id.as_str()) { + // An idle thread keeps showing its last run's reading; a different + // live run has not reported into this scope yet. + if current_run_id.is_some_and(|run| run != scope.run_id.as_str()) { return None; } let report = &usage.report; diff --git a/modules/frontend/src/composer_queue_state/tests.rs b/modules/frontend/src/composer_queue_state/tests.rs index 6796dd792..b957bb6f1 100644 --- a/modules/frontend/src/composer_queue_state/tests.rs +++ b/modules/frontend/src/composer_queue_state/tests.rs @@ -356,6 +356,37 @@ fn stale_usage_is_rejected_without_reinterpreting_the_reporting_model() { assert_eq!(usage.context_window_tokens, Some(100)); } +#[test] +fn a_native_run_reports_under_its_own_ids_and_its_reading_outlives_the_run() { + let thread_id = thread("thread-a"); + let run_id = RunId::parse("run-a").expect("run id"); + let mut state = ComposerQueueState::new(); + state.set_scope(Some(thread_id.clone()), 3); + assert!(state.begin_native_usage_scope(thread_id.clone(), 3, run_id.clone())); + // Claude reports under its engine route and the model it ran. + let pushed = RunUsageResult { + thread_id: thread_id.clone(), + run_id: run_id.clone(), + report: Some(report(&thread_id, &run_id, 1, "claude-opus-5-5", "claude")), + compaction_at_tokens: None, + }; + assert_eq!( + state.accept_pushed_usage(pushed, "claude-opus-5-5".to_owned()), + Ok(UsageResultDisposition::Updated) + ); + assert!(state.reporting_usage_for(Some(run_id.as_str())).is_some()); + // The run settled: the idle thread keeps its last reading. + assert_eq!( + state + .reporting_usage_for(None) + .expect("the reading outlives its run") + .run_id, + "run-a" + ); + // Another live run has not reported into this scope. + assert!(state.reporting_usage_for(Some("run-b")).is_none()); +} + #[test] fn absent_usage_fields_remain_absent_and_scope_mismatch_is_rejected() { let thread_id = thread("thread-a"); diff --git a/modules/frontend/src/conversation_observation_projection.rs b/modules/frontend/src/conversation_observation_projection.rs index 5a12a6a66..d8dc9a4b7 100644 --- a/modules/frontend/src/conversation_observation_projection.rs +++ b/modules/frontend/src/conversation_observation_projection.rs @@ -400,6 +400,7 @@ pub fn project_activities( kind: CandidateKind::Approval { prompt: truncate_bounded(row.description(), MAX_ACTIVITY_BODY_BYTES), approval_id, + decision: row.approved(), }, }); } @@ -544,9 +545,11 @@ pub fn project_activities( CandidateKind::Approval { prompt, approval_id, + decision, } => SceneFactKind::Approval { prompt, approval_id, + decision, }, CandidateKind::Question { prompt, answer } => { SceneFactKind::Question { prompt, answer } @@ -672,6 +675,7 @@ enum CandidateKind { Approval { prompt: String, approval_id: ObservationId, + decision: Option, }, Question { prompt: String, @@ -702,11 +706,12 @@ mod tests { use crate::conversation_state_machine::SceneFactKind; use crate::engine_observation_state::EngineObservationState; use artisan_domain::{ - ConversationCursor, ConversationLifecycle, ConversationSnapshot, ConversationTurn, - EngineObservationAttribution, EngineObservationEvent, FileAction, FileObservation, - Observation, ObservationId, ObservationSequence, Revision, RunId, TerminalActivityInput, - TerminalActivityObservation, TerminalActivityState, ThreadId, ToolAction, ToolObservation, - TurnId, TurnOrdinal, UnixMillis, + ApprovalObservation, ApprovalRequest, ConversationCursor, ConversationLifecycle, + ConversationSnapshot, ConversationTurn, EngineObservationAttribution, + EngineObservationEvent, FileAction, FileObservation, Observation, ObservationId, + ObservationSequence, Revision, RunId, TerminalActivityInput, TerminalActivityObservation, + TerminalActivityState, ThreadId, ToolAction, ToolObservation, TurnId, TurnOrdinal, + UnixMillis, }; fn observation_id(value: &str) -> ObservationId { @@ -796,6 +801,62 @@ mod tests { ); } + #[test] + fn a_replayed_resolution_carries_the_decision_into_the_approval_fact() { + // Reopening a thread replays its history into fresh state: the + // durable resolution must settle the card, not leave it answerable. + let thread = ThreadId::parse("thread-approval").expect("thread"); + let turn = TurnId::parse("turn-approval").expect("turn"); + let run = RunId::parse("run-approval").expect("run"); + let request = || { + ApprovalRequest::command(String::from("npm view @sveltejs/kit"), None, None) + .expect("fixture approval request is valid") + }; + let requested = attributed_event( + &thread, + &run, + &turn, + 1, + Observation::Approval( + ApprovalObservation::requested( + observation_id("obs-approval"), + sequence(5), + observation_id("approval-1"), + String::from("May I access the npm registry?"), + request(), + ) + .expect("fixture requested approval is valid"), + ), + ); + let resolved = attributed_event( + &thread, + &run, + &turn, + 2, + Observation::Approval( + ApprovalObservation::resolved( + observation_id("obs-approval-resolved"), + sequence(6), + observation_id("approval-1"), + String::from("May I access the npm registry?"), + request(), + true, + ) + .expect("fixture resolved approval is valid"), + ), + ); + let mut state = EngineObservationState::new(thread.clone()); + let _ = state.apply(1, &requested); + let _ = state.apply(2, &resolved); + + let projection = project_activities(&state, &snapshot(&thread, &turn)); + assert_eq!(projection.facts.len(), 1, "one card per approval"); + match &projection.facts[0].kind { + SceneFactKind::Approval { decision, .. } => assert_eq!(*decision, Some(true)), + other => panic!("expected an approval fact, got {other:?}"), + } + } + #[test] fn terminal_rows_carry_the_raw_command_as_terminal_detail() { let thread = ThreadId::parse("thread-terminal").expect("thread"); diff --git a/modules/frontend/src/conversation_scene/blocks.rs b/modules/frontend/src/conversation_scene/blocks.rs index 238b5a697..2a03bdf69 100644 --- a/modules/frontend/src/conversation_scene/blocks.rs +++ b/modules/frontend/src/conversation_scene/blocks.rs @@ -68,11 +68,13 @@ pub enum SceneItemKind { FileChange { file: SceneFileChange }, /// Plan/checklist card. Plan { title: String, entries: Vec }, - /// Approval request card: the bounded prompt and the engine's own - /// approval identity, which the answer submits back. + /// Approval request card: the bounded prompt, the engine's own + /// approval identity, which the answer submits back, and the recorded + /// decision once resolved. Approval { prompt: String, approval_id: ObservationId, + decision: Option, }, /// Agent question with its answer once given. Question { @@ -486,6 +488,9 @@ pub struct ApprovalBlock { pub approval_id: ObservationId, /// Bounded prompt. pub prompt: String, + /// The recorded decision once resolved: true allowed, false denied. + /// [`None`] while the approval is still open. + pub decision: Option, /// Explicit disclosure. pub disclosure: Option, } @@ -655,6 +660,12 @@ pub struct TurnScene { pub ordinal: u64, /// Authoritative lifecycle. pub lifecycle: ConversationLifecycle, + /// The turn's active-work clock basis in Unix millis, if any. + /// + /// Kept on the turn as well as on its status block: a streaming reply + /// suppresses the status block, and the section header must keep + /// counting `Working for …` from the same basis until the turn ends. + pub active_started_at_ms: Option, /// Ordered blocks. pub blocks: Vec, } diff --git a/modules/frontend/src/conversation_scene/build.rs b/modules/frontend/src/conversation_scene/build.rs index e4bc4624f..0c7d2aaa0 100644 --- a/modules/frontend/src/conversation_scene/build.rs +++ b/modules/frontend/src/conversation_scene/build.rs @@ -731,6 +731,10 @@ impl ConversationScene { }); } + // A reasoning trace still arriving rewrites itself as it streams + // (Claude's do); it feeds the summary line only once it is whole: + // no longer live, or followed by anything later. + let last_ordinal = turn_items.iter().map(|item| item.ordinal).max(); for item in turn_items { // Session-owned content never reaches the positional arms. // The group emits at the anchor position first; later members @@ -782,7 +786,13 @@ impl ConversationScene { SceneItemKind::ReasoningSummary { body } => { // Reasoning feeds the one live summary line only; // it never becomes a visible row (R2). - if !body.is_empty() { + let arriving = item + .provenance + .as_ref() + .and_then(|provenance| provenance.lifecycle) + .is_some_and(super::validation::is_live_lifecycle) + && last_ordinal == Some(item.ordinal); + if !body.is_empty() && !arriving { let run_matches = match ( &session_run, item.provenance @@ -1023,6 +1033,7 @@ impl ConversationScene { SceneItemKind::Approval { prompt, approval_id, + decision, } => { blocks.push(TurnBlock::Approval(ApprovalBlock { id: item.id, @@ -1032,6 +1043,7 @@ impl ConversationScene { .and_then(|provenance| provenance.run_id.clone()), approval_id, prompt, + decision, disclosure: item.disclosure, })); } @@ -1119,6 +1131,9 @@ impl ConversationScene { !steer_keys.contains_key(label.anchor.as_str()) } TurnBlock::WorkGroup(group) => group.continuation.is_none(), + // An approval or question pauses the session; the work + // it unblocks continues the same section. + TurnBlock::Approval(_) | TurnBlock::Question(_) => false, _ => true, }); let TurnBlock::WorkGroup(group) = &mut blocks[group_index] else { @@ -1207,6 +1222,7 @@ impl ConversationScene { turn_id: turn.turn_id.clone(), ordinal: turn.ordinal, lifecycle: turn.lifecycle, + active_started_at_ms, blocks, }); } diff --git a/modules/frontend/src/conversation_state_machine/impl_dispatch.rs b/modules/frontend/src/conversation_state_machine/impl_dispatch.rs index 627efe5fe..a890fb376 100644 --- a/modules/frontend/src/conversation_state_machine/impl_dispatch.rs +++ b/modules/frontend/src/conversation_state_machine/impl_dispatch.rs @@ -1118,7 +1118,19 @@ fn synchronize_turns( } let mut work_by_turn: BTreeSet<&TurnId> = BTreeSet::new(); let mut thought_by_turn: BTreeSet<&TurnId> = BTreeSet::new(); + // Anything the provider produced for the turn proves it responded: an + // approval or question card, a plan, a native fact. Errors and model + // transitions do not. + let mut responded_by_turn: BTreeSet<&TurnId> = BTreeSet::new(); for fact in facts.values() { + if !matches!( + fact.kind, + SceneFactKind::Error { .. } + | SceneFactKind::UsageInterruption { .. } + | SceneFactKind::ModelTransition { .. } + ) { + responded_by_turn.insert(&fact.turn_id); + } match &fact.kind { SceneFactKind::Activity { .. } | SceneFactKind::ChangedFiles { .. } => { work_by_turn.insert(&fact.turn_id); @@ -1179,6 +1191,7 @@ fn synchronize_turns( items, work, thought_by_turn.contains(&turn.turn_id), + responded_by_turn.contains(&turn.turn_id), controller, ) { // Best-effort: a sealed, stale, or regressed derivation only means @@ -1220,6 +1233,7 @@ fn derive_turn_events( items: &[&ConversationItem], work_evidence: bool, thought_evidence: bool, + responded: bool, controller: &ConversationTurnController, ) -> Vec { // Floor against impossible input only: durable per-turn times never move @@ -1251,8 +1265,11 @@ fn derive_turn_events( } vec![drive_active_like( items, - work_evidence, - thought_evidence, + ActiveEvidence { + work: work_evidence, + thought: thought_evidence, + responded, + }, activate_at, first_revision, )] @@ -1307,8 +1324,11 @@ fn derive_turn_events( | ConversationLifecycle::Waiting => { vec![drive_active_like( items, - work_evidence, - thought_evidence, + ActiveEvidence { + work: work_evidence, + thought: thought_evidence, + responded, + }, activate_at, first_revision, )] @@ -1316,19 +1336,43 @@ fn derive_turn_events( } } +/// What a live turn's facts say the provider has done so far. +#[derive(Clone, Copy)] +struct ActiveEvidence { + /// Tool activity or changed files. + work: bool, + /// A reasoning trace. + thought: bool, + /// Any fact the provider produced (an approval, a question, a plan). + responded: bool, +} + /// Single active-like drive shared by launched lifecycles. /// /// Evidence of provider response outranks the wait, in reference order: a /// streaming non-commentary reply speaks for itself, then work evidence, -/// then thought evidence; otherwise the request is still out and waits on -/// the provider. Callers gate on launch evidence; this helper only ranks it. +/// then thought evidence. Any other sign that the provider responded — +/// prose in any phase, settled or streaming, or any other fact — reads as +/// thinking: the status is derived afresh each delivery, and a turn that +/// has answered must never fall back to waiting on the provider. Only a +/// turn with no response at all waits. Callers gate on launch evidence; +/// this helper only ranks it. fn drive_active_like( items: &[&ConversationItem], - work_evidence: bool, - thought_evidence: bool, + evidence: ActiveEvidence, activate_at: i64, first_revision: u64, ) -> TurnEvent { + let ActiveEvidence { + work: work_evidence, + thought: thought_evidence, + responded, + } = evidence; + let responded = responded + || items.iter().any(|item| { + matches!(item, ConversationItem::AssistantMessage(message) + if !message.body.as_str().is_empty()) + }); let streaming_reply = items.iter().any(|item| { matches!(item, ConversationItem::AssistantMessage(message) if message.lifecycle == ConversationLifecycle::Streaming @@ -1345,7 +1389,7 @@ fn drive_active_like( at: activate_at, revision: first_revision, } - } else if thought_evidence { + } else if thought_evidence || responded { TurnEvent::Thinking { at: activate_at, revision: first_revision, diff --git a/modules/frontend/src/conversation_state_machine/types.rs b/modules/frontend/src/conversation_state_machine/types.rs index d6a1b7bf8..a491a9ec8 100644 --- a/modules/frontend/src/conversation_state_machine/types.rs +++ b/modules/frontend/src/conversation_state_machine/types.rs @@ -239,10 +239,12 @@ pub enum SceneFactKind { /// Bounded checklist entries. entries: Vec, }, - /// Approval request card: the prompt and the engine's approval identity. + /// Approval request card: the prompt, the engine's approval identity, + /// and the recorded decision once resolved (true allowed, false denied). Approval { prompt: String, approval_id: ObservationId, + decision: Option, }, /// Agent question: its prompt and, once given, the answer (empty when /// the user skipped it). @@ -349,9 +351,11 @@ impl SceneFactKind { Self::Approval { prompt, approval_id, + decision, } => SceneItemKind::Approval { prompt: prompt.clone(), approval_id: approval_id.clone(), + decision: *decision, }, Self::Question { prompt, answer } => SceneItemKind::Question { prompt: prompt.clone(), diff --git a/modules/frontend/src/conversation_surface.rs b/modules/frontend/src/conversation_surface.rs index 0afb07467..f45a3a6fb 100644 --- a/modules/frontend/src/conversation_surface.rs +++ b/modules/frontend/src/conversation_surface.rs @@ -48,6 +48,7 @@ use artisan_ui::inline_code_text::inline_runs; use artisan_ui::input_state::TextInputState; use artisan_ui::markdown_cache::MarkdownParseReport; use artisan_ui::markdown_renderer::{MarkdownBodyTone, MarkdownRenderer, RichLinkTitleSource}; +use artisan_ui::markdown_reveal::RevealFade; use artisan_ui::motion::{MotionCurve, MotionDuration, MotionPlan, MotionPolicy, MotionRecipe}; use artisan_ui::scroll_area::ScrollArea; use artisan_ui::selectable_text::SelectableText; @@ -124,6 +125,10 @@ mod render_blocks; mod render_navigator; #[path = "conversation_surface/render_sections.rs"] mod render_sections; +#[path = "conversation_surface/reply_reveal.rs"] +mod reply_reveal; +#[path = "conversation_surface/work_motion.rs"] +mod work_motion; // Answer outcome settlement (see conversation_surface/). @@ -200,6 +205,10 @@ pub struct ConversationSurface { /// Bumped by every scene replacement, so an unchanged scene never /// compares turn contents. scene_generation: u64, + /// When the oldest scene replacement not yet painted arrived. A window + /// that is minimized or hidden stops painting while scenes keep + /// arriving; its next paint catches up instead of animating them all. + unpainted_since: Option, scroll_handle: ScrollHandle, transcript_focus: FocusHandle, disclosure_focus: FocusHandle, @@ -707,7 +716,12 @@ impl Render for ConversationSurface { #[derive(Clone, Copy, Debug)] enum DetailRow<'a> { /// Assistant prose that is not the promoted reply. - Assistant { id: &'a SceneId, body: &'a str }, + Assistant { + id: &'a SceneId, + body: &'a str, + /// Whether the prose is still arriving, so it streams in. + streaming: bool, + }, /// Activity or tool-result summary. Activity { id: &'a SceneId, @@ -748,6 +762,10 @@ impl DetailRow<'_> { /// message with no text yet is not a row: the Forge opens the item ahead of /// its first text, and an empty row would paint a gap and split the tool /// chain around it. +#[expect( + clippy::too_many_lines, + reason = "one mapping covers every legacy and session detail kind in paint order; splitting it would separate the two orderings it keeps identical" +)] fn ordered_detail_rows(block: &WorkGroupBlock) -> Vec<(u64, DetailRow<'_>)> { if block.session_details.is_empty() { block @@ -793,12 +811,17 @@ fn ordered_detail_rows(block: &WorkGroupBlock) -> Vec<(u64, DetailRow<'_>)> { }) .map(|detail| match detail { SessionDetail::Assistant { - id, body, ordinal, .. + id, + body, + ordinal, + provenance, + .. } => ( *ordinal, DetailRow::Assistant { id, body: body.as_str(), + streaming: provenance_is_live(provenance.as_ref()), }, ), SessionDetail::Activity { @@ -843,6 +866,15 @@ fn ordered_detail_rows(block: &WorkGroupBlock) -> Vec<(u64, DetailRow<'_>)> { }) .collect(); rows.sort_by_key(|(ordinal, _)| *ordinal); + // Prose followed by any later row is finished, whatever its message + // lifecycle says: a message can stay live until its turn ends, and a + // body still counted as streaming keeps its last word held back. + let last = rows.len().saturating_sub(1); + for (_, row) in &mut rows[..last] { + if let DetailRow::Assistant { streaming, .. } = row { + *streaming = false; + } + } rows } } @@ -850,3 +882,21 @@ fn ordered_detail_rows(block: &WorkGroupBlock) -> Vec<(u64, DetailRow<'_>)> { #[cfg(test)] #[path = "conversation_surface/tests.rs"] mod tests; + +/// Whether a durable lifecycle means the item is still arriving. +pub(super) fn lifecycle_is_live(lifecycle: ConversationLifecycle) -> bool { + matches!( + lifecycle, + ConversationLifecycle::Pending + | ConversationLifecycle::Streaming + | ConversationLifecycle::Active + | ConversationLifecycle::Waiting + ) +} + +/// Whether an item's durable attribution says it is still arriving. +fn provenance_is_live(provenance: Option<&crate::conversation_scene::ItemProvenance>) -> bool { + provenance + .and_then(|provenance| provenance.lifecycle) + .is_some_and(lifecycle_is_live) +} diff --git a/modules/frontend/src/conversation_surface/answer_state.rs b/modules/frontend/src/conversation_surface/answer_state.rs index 467e658ff..710f8f485 100644 --- a/modules/frontend/src/conversation_surface/answer_state.rs +++ b/modules/frontend/src/conversation_surface/answer_state.rs @@ -112,6 +112,7 @@ pub struct ApprovalAnswerAttempt { pub struct ApprovalAnswerGate { pub(super) flight: AnswerFlight, pub(super) pending_decision: Option, + pub(super) answered: bool, pub(super) failure: Option, pub(super) last_request_id: Option, } @@ -135,6 +136,20 @@ impl ApprovalAnswerGate { self.pending_decision } + /// Returns the decision the engine recorded a receipt for, if any. + /// + /// The row renders this as settled until the resolution reaches it + /// through the subscription, so a recorded answer never reads as still + /// in flight. + #[must_use] + pub const fn answered_decision(&self) -> Option { + if self.answered { + self.pending_decision + } else { + None + } + } + /// Returns the surfaced retry/diagnostic message, if any. #[must_use] pub fn failure_message(&self) -> Option<&str> { @@ -173,6 +188,7 @@ impl ApprovalAnswerGate { }; let command = approval_command(thread_id, &action, request_id.clone()); self.pending_decision = Some(approved); + self.answered = false; self.failure = None; self.last_request_id = Some(request_id.clone()); Some(ApprovalAnswerAttempt { @@ -196,6 +212,7 @@ impl ApprovalAnswerGate { ) -> AnswerPairing { let pairing = pair_approval_answer(state, command, receipt); if pairing.is_settled() { + self.answered = true; self.failure = None; } else { self.flight.settle(); diff --git a/modules/frontend/src/conversation_surface/interactions.rs b/modules/frontend/src/conversation_surface/interactions.rs index 953f33d1a..ba11ead68 100644 --- a/modules/frontend/src/conversation_surface/interactions.rs +++ b/modules/frontend/src/conversation_surface/interactions.rs @@ -47,6 +47,7 @@ impl ConversationSurface { rich_link_generation: 0, turn_rows: HashMap::new(), scene_generation: 0, + unpainted_since: None, scroll_handle: ScrollHandle::new(), transcript_focus: cx.focus_handle().tab_index(0).tab_stop(true), disclosure_focus: cx.focus_handle().tab_index(1).tab_stop(true), diff --git a/modules/frontend/src/conversation_surface/render_blocks.rs b/modules/frontend/src/conversation_surface/render_blocks.rs index e136291ce..785d82373 100644 --- a/modules/frontend/src/conversation_surface/render_blocks.rs +++ b/modules/frontend/src/conversation_surface/render_blocks.rs @@ -6,6 +6,7 @@ //! builders read only the row's synced inputs and reach the surface through //! its weak handle. +use super::work_motion::{phrase_element, tabular_figures, with_entrance}; use super::*; #[path = "render_work_group_header.rs"] @@ -23,14 +24,38 @@ pub(super) struct TurnWorkContext { /// The turn's own state as a section title ([`turn_section_title`]), for /// a section no terminal label or live line reached. title: String, + /// Whether the promoted reply has visibly begun: its first word or + /// block is on screen (see `TurnRowView::advance_reply_reveal`). + reply_shown: bool, +} + +/// Where the owning turn stands, as its work groups render it. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum TurnProgress { + /// Live ([`turn_is_live`]) with no reply on screen yet. + Working, + /// Live, and the promoted reply's first word or block is on screen. + Replying, + /// Settled: completed, failed, cancelled, or interrupted. + Settled, +} + +impl TurnProgress { + fn of(work: &TurnWorkContext) -> Self { + match (work.live, work.reply_shown) { + (false, _) => Self::Settled, + (true, false) => Self::Working, + (true, true) => Self::Replying, + } + } } /// Render facts one work group takes from its turn. pub(super) struct WorkGroupPlacement { /// The live line, present only on the group that owns it. live_header: Option, - /// Whether the owning turn is still live ([`turn_is_live`]). - turn_live: bool, + /// Where the owning turn stands. + progress: TurnProgress, /// The owning turn's state as a section title ([`turn_section_title`]). turn_title: String, /// Whether the promoted reply is the next block, which pulls itself up @@ -40,7 +65,87 @@ pub(super) struct WorkGroupPlacement { mounted_working: bool, } +/// How far a new thinking summary rises into the status line +/// (`--distance-micro`, text swap). +const SUMMARY_SWAP_RISE_PX: f32 = 4.0; + impl TurnRowView { + /// The status line with its motion: it enters like a work row and, once + /// gone, leaves through [`Self::status_exit`]. Its height is + /// measured each frame for that exit. + fn render_live_status( + &self, + turn_id: &TurnId, + block: &crate::conversation_scene::TurnStatusBlock, + selector: String, + theme: &ArtisanTheme, + status_motion: MotionPolicy, + ) -> Option { + let copy = turn_status_block_copy(block, self.active_now_ms); + let status = self.render_status(turn_id, block, selector, theme, status_motion)?; + let height = self.status_height.get(); + let summary = status_summary_copy(block.reasoning_summary.as_deref(), block.engine); + let mut motion = self.motion.borrow_mut(); + motion.status_painted(copy.as_deref().unwrap_or_default(), height); + let entrance = motion.entrance("status"); + let swap = motion.summary_swap(summary.as_deref()); + drop(motion); + // A finished thinking trace replacing the last one swaps in. + let status = match swap { + Some(progress) => div() + .w_full() + .min_w_0() + .relative() + .top(px(SUMMARY_SWAP_RISE_PX * (1.0 - progress))) + .opacity(progress) + .child(status) + .into_any_element(), + None => status, + }; + let measured = Rc::clone(&self.status_height); + // "Thinking for 13s" ticks every second; tabular figures keep it + // from shifting. + let status = div() + .w_full() + .min_w_0() + .font_features(tabular_figures()) + .child(status) + .on_children_prepainted(move |bounds, _, _| { + if let Some(bounds) = bounds.first() { + measured.set(bounds.size.height); + } + }) + .into_any_element(); + Some(with_entrance(status, entrance, theme.spacing.steps(6.0))) + } + + /// A status line that left this frame, fading out while its height and + /// the turn gap above it close to nothing, so the rows around it settle + /// without a jump. It trails every scene child, so scroll identities + /// stay aligned. + fn status_exit(&self, theme: &ArtisanTheme) -> Option { + let (copy, height, progress) = self.motion.borrow_mut().status_exit()?; + Some( + div() + .w_full() + .min_w_0() + .h(height * (1.0 - progress)) + .mt(-theme.spacing.steps(6.0) * progress) + .overflow_hidden() + .opacity(1.0 - progress) + .font_features(tabular_figures()) + .text_size(px(ProseTypography::BODY_SIZE_PX)) + .text_color(theme.colors.muted_foreground.to_paint()) + .truncate() + .child(copy) + .into_any_element(), + ) + } + + #[expect( + clippy::too_many_lines, + reason = "one GPUI builder composes a turn's blocks, their scroll identities, and the leaving status line in paint order" + )] pub(super) fn render_turn( &self, turn: &TurnScene, @@ -80,6 +185,7 @@ impl TurnRowView { live_owner, live: turn_is_live(turn), title: turn_section_title(turn, self.active_now_ms), + reply_shown: self.advance_reply_reveal(turn), }; // Identities mirror the children pushed below in block order. A // suppressed status row and an unsettled footer paint no child, so @@ -165,8 +271,9 @@ impl TurnRowView { previous_was_work_group = matches!(block, TurnBlock::WorkGroup(_)); } } - - turn_element.into_any_element() + turn_element + .children(self.status_exit(theme)) + .into_any_element() } #[expect( @@ -241,7 +348,7 @@ impl TurnRowView { anchors, WorkGroupPlacement { live_header: owned, - turn_live: work.live, + progress: TurnProgress::of(work), turn_title: work.title.clone(), reply_follows, mounted_working, @@ -278,7 +385,7 @@ impl TurnRowView { Some(Self::render_steering(block, selector, theme, anchors)) } TurnBlock::TurnStatus(block) => { - self.render_status(turn_id, block, selector, theme, status_motion) + self.render_live_status(turn_id, block, selector, theme, status_motion) } TurnBlock::TurnFooter(block) => { self.render_footer(turn_id, block, selector, theme, window, status_motion) @@ -299,12 +406,20 @@ impl TurnRowView { // span the column. The reply body reads in the foreground token, // including assistant prose in work history. // Shaping stays inside the per-row render - // budget so one pathological body cannot parse every frame. - let rendered_body = self.render_budgeted_markdown( + // budget so one pathological body cannot parse every frame. A + // streaming reply renders only its revealed prefix, fading fresh + // units in (see `reply_reveal`). + let streaming = block + .provenance + .as_ref() + .and_then(|provenance| provenance.lifecycle) + .is_some_and(lifecycle_is_live); + let rendered_body = self.render_streamed_markdown( + &block.id, &block.body, + streaming, theme, selector.clone(), - MarkdownBodyTone::Foreground, ); anchors .attach( @@ -368,11 +483,12 @@ impl TurnRowView { ) -> AnyElement { let WorkGroupPlacement { live_header, - turn_live, + progress, turn_title, reply_follows, mounted_working, } = placement; + let turn_live = progress != TurnProgress::Settled; // A session continuation paints the work after a mid-run user // message: same section, same disclosure, but no header, divider, or // trigger of its own. The first segment's header titles the section. @@ -398,9 +514,10 @@ impl TurnRowView { // A group is disclosable when it has an identity, a disclosure value, // and actual visible trace content: an empty `Thought for …` group // paints its header with no chevron at all, never a blank disclosure. - // A section cannot be collapsed before its turn settles: while the - // turn is live the panel is forced open whatever value is stored, - // and the header carries no chevron and no toggle. Once settled, + // A section cannot be collapsed by hand before its turn settles: + // while the turn is live the panel is forced open whatever value is + // stored (until its reply shows, below), and the header carries no + // chevron and no toggle. Once settled, // controlled state is never overridden: Closed hides the panel in // every case (continuations included, so one toggle hides the work on // both sides of a steer bubble), and the toggle always flows through @@ -411,6 +528,12 @@ impl TurnRowView { && work_group_has_visible_details(block); let controlled = disclosable && !turn_live; let toggle = controlled && !continuation; + // The work folds away the moment the reply's first word or block is + // on screen, ahead of settlement: the reply is what the reader is + // here for. It folds whatever is stored, because a live section + // carries no toggle to reopen it; once settled the stored state, + // auto-closed on success, takes over without a jump. + let folded_for_reply = disclosable && progress == TurnProgress::Replying; // A section is always titled with its turn's own state, live or // settled: when neither an outcome label nor the live line reached // this group (a provider wait or streaming reply, an earlier group @@ -420,7 +543,8 @@ impl TurnRowView { if disclosable && !continuation && header.is_none() { header = Some(turn_title); } - let open = !controlled || !matches!(block.disclosure, Some(SceneDisclosure::Closed)); + let open = !folded_for_reply + && (!controlled || !matches!(block.disclosure, Some(SceneDisclosure::Closed))); let items_mounted = open; let frame = disclosure_frame(selector, open, status_motion, window, cx); let fraction = frame.fraction; @@ -453,7 +577,7 @@ impl TurnRowView { row_index += 1; } let chain_rows = &rows[start..row_index]; - items = items.child(self.render_trace_chain( + let chain = self.render_trace_chain( selector, chain_index, chain_rows, @@ -463,21 +587,38 @@ impl TurnRowView { items_mounted, window, cx, - )); + ); + // A chain enters once, when its first step arrives; later + // steps count up in its header instead. + let entrance = self + .motion + .borrow_mut() + .entrance(&format!("chain:{}", chain_rows[0].1.scene_id().as_str())); + items = items.child(with_entrance(chain, entrance, theme.spacing.steps(2.0))); // The chain's own prepaint listener resolves its row bounds; // at this level the chain is opaque to row targets so a // collapsed chain cannot resolve one against its header. child_identities.push((None, None)); chain_index += 1; } else { - items = items.child(self.render_detail_row( - rows[row_index].1, + // The reply after the group means its last prose is done. + let mut detail = rows[row_index].1; + if reply_follows && let DetailRow::Assistant { streaming, .. } = &mut detail { + *streaming = false; + } + let row = self.render_detail_row( + detail, rows[row_index].0, selector, theme, anchors, items_mounted, - )); + ); + let entrance = self + .motion + .borrow_mut() + .entrance(rows[row_index].1.scene_id().as_str()); + items = items.child(with_entrance(row, entrance, theme.spacing.steps(2.0))); let id = rows[row_index].1.scene_id(); child_identities.push((Some(id.clone()), item_id_for_scene_id(id))); row_index += 1; @@ -525,8 +666,14 @@ impl TurnRowView { // divider, so plain and disclosable renders share this construction. // Splitting the headers would remount the row the instant the first // detail arrived. + let label_words = header.as_ref().and_then(|label| { + self.motion + .borrow_mut() + .phrase(&format!("{selector}-label"), label) + }); let header_row = ConversationSurface::work_group_header_row( header, + label_words.as_deref(), transition, frame.fraction, toggle, @@ -684,6 +831,17 @@ impl TurnRowView { .unwrap_or_default(); } let icon = activity_chain_icon(kinds.iter().copied()); + // The clause counts up like an odometer as steps arrive, and new + // clauses rise in at its end (see `work_motion`). + let line = theme.spacing.steps(6.0); + let clause: AnyElement = match self + .motion + .borrow_mut() + .phrase(&format!("{selector}-clause"), &clause) + { + Some(words) => phrase_element(&words, line), + None => clause.into_any_element(), + }; let header = div() .id(ElementId::Name(SharedString::from(format!( @@ -732,6 +890,7 @@ impl TurnRowView { .letter_spacing(px(ProseTypography::BODY_TRACKING_PX)) .line_height(theme.spacing.steps(6.0)) .text_color(header_color.to_paint()) + .font_features(tabular_figures()) .child(clause), ) .child( @@ -777,14 +936,11 @@ impl TurnRowView { // identity slot and the rows line up one-to-one behind it. let mut row_identities: Vec<(Option, Option)> = vec![(None, None)]; for (ordinal, row) in rows.iter().filter(|_| frame.content_mounted()) { - rows_column = rows_column.child(self.render_detail_row( - *row, - *ordinal, - group_selector, - theme, - anchors, - mounted, - )); + let step = + self.render_detail_row(*row, *ordinal, group_selector, theme, anchors, mounted); + let entrance = self.motion.borrow_mut().entrance(row.scene_id().as_str()); + rows_column = + rows_column.child(with_entrance(step, entrance, theme.spacing.steps(1.0))); let id = row.scene_id(); row_identities.push((Some(id.clone()), item_id_for_scene_id(id))); } @@ -868,12 +1024,18 @@ impl TurnRowView { ), row => { let content: AnyElement = match &row { - DetailRow::Assistant { body, .. } => { - let rendered = self.render_budgeted_markdown( + // Prose inside the work streams in exactly like the reply. + DetailRow::Assistant { + id, + body, + streaming, + } => { + let rendered = self.render_streamed_markdown( + id, body, + *streaming, theme, format!("{selector}-markdown"), - MarkdownBodyTone::Foreground, ); div().w_full().child(rendered).into_any_element() } diff --git a/modules/frontend/src/conversation_surface/render_navigator.rs b/modules/frontend/src/conversation_surface/render_navigator.rs index 6ce39dfe7..ed81f6c58 100644 --- a/modules/frontend/src/conversation_surface/render_navigator.rs +++ b/modules/frontend/src/conversation_surface/render_navigator.rs @@ -463,6 +463,11 @@ impl ConversationSurface { }); // Shared hover pill behind the rows plus the probe capturing list // bounds for pill-relative coordinates, mirroring the model picker. + let rows: Vec = markers + .iter() + .map(|marker| navigator_target_slug(&marker.target).to_owned()) + .collect(); + self.navigator_hover.borrow_mut().clear_if_missing(&rows); list = list.child(self.render_navigator_hover_pill(theme, reduce_motion)); let hover_surface_bounds = Rc::clone(&self.navigator_hover_surface); list = list.child( diff --git a/modules/frontend/src/conversation_surface/render_sections.rs b/modules/frontend/src/conversation_surface/render_sections.rs index dd57bde44..e1f76c5e7 100644 --- a/modules/frontend/src/conversation_surface/render_sections.rs +++ b/modules/frontend/src/conversation_surface/render_sections.rs @@ -127,14 +127,24 @@ impl TurnRowView { anchors: &mut ScrollAnchorRegistry<'_>, ) -> AnyElement { let style = CardStyle::resolve(*theme); - let key = block.id.as_str().to_owned(); + // Gates are keyed by the engine's approval identity, the one name + // both the gesture and its receipt carry. + let key = block.approval_id.as_str().to_owned(); + let gate = self.approval_state(&key); + // A resolution the subscription delivered wins; until it lands, a + // recorded receipt already settles the card. + if let Some(approved) = block + .decision + .or_else(|| gate.and_then(|gate| gate.answered_decision)) + { + return self.render_decided_approval(block, approved, selector, theme, anchors); + } // The block carries the engine's approval identity and its owning // run from durable provenance; the host supplies the thread. let target = block .run_id .clone() .map(|run_id| (run_id, block.approval_id.clone())); - let gate = self.approval_state(&key); let in_flight = gate.is_some_and(|gate| gate.in_flight); let pending = gate.and_then(|gate| gate.pending_decision); let failure = gate.and_then(|gate| gate.failure.as_deref()); @@ -257,6 +267,46 @@ impl TurnRowView { ) } + /// Renders one settled approval as the transcript's record of it. + fn render_decided_approval( + &self, + block: &crate::conversation_scene::ApprovalBlock, + approved: bool, + selector: String, + theme: &ArtisanTheme, + anchors: &mut ScrollAnchorRegistry<'_>, + ) -> AnyElement { + let style = CardStyle::resolve(*theme); + let (heading, status) = if approved { + ("Approved", "You approved this request.") + } else { + ("Denied", "You denied this request.") + }; + let details = div() + .w_full() + .flex() + .flex_col() + .gap(theme.spacing.steps(2.0)) + .child(body_text(&block.prompt, theme)) + .child( + body_text(status, theme) + .text_color(theme.colors.muted_foreground.to_paint()) + .debug_selector(|| format!("{selector}-status")), + ); + self.render_controlled_card( + ControlledCardOptions { + id: block.id.clone(), + item_id: item_id_for_scene_id(&block.id), + disclosure: block.disclosure, + selector, + style, + }, + compact_card_content(style).child(card_heading(heading, theme)), + compact_card_content(style).child(details), + anchors, + ) + } + /// Renders one agent question as the transcript's record of it. /// /// Questions are answered from the composer, which extends upward with diff --git a/modules/frontend/src/conversation_surface/render_work_group_header.rs b/modules/frontend/src/conversation_surface/render_work_group_header.rs index 9fa138167..815528cb5 100644 --- a/modules/frontend/src/conversation_surface/render_work_group_header.rs +++ b/modules/frontend/src/conversation_surface/render_work_group_header.rs @@ -28,6 +28,7 @@ impl ConversationSurface { )] pub(in crate::conversation_surface) fn work_group_header_row( label: Option, + label_words: Option<&[super::work_motion::PhraseWord]>, transition: Option, fraction: f32, controlled: bool, @@ -41,6 +42,15 @@ impl ConversationSurface { if !controlled && label.is_none() && transition.is_none() { return div().into_any_element(); } + // A changing live label ("Working for 1m 4s") counts like an + // odometer while it moves (see `work_motion`); at rest it is text. + let line = theme.spacing.steps(6.0); + let label_child = |label: String| -> AnyElement { + match label_words { + Some(words) => super::work_motion::phrase_element(words, line), + None => label.into_any_element(), + } + }; let near: AnyElement = match (controlled, label) { (true, Some(label)) => div() .flex() @@ -51,7 +61,7 @@ impl ConversationSurface { .text_color(theme.colors.muted_foreground.to_paint()) // A long thinking chip ellipsizes on its one line; the // chevron never shrinks away. - .child(div().min_w_0().truncate().child(label)) + .child(div().min_w_0().truncate().child(label_child(label))) .child(Self::work_group_header_chevron(fraction, selector, theme)) .into_any_element(), // A live section's words and any uncontrolled label: no chevron @@ -61,7 +71,7 @@ impl ConversationSurface { .truncate() .text_color(theme.colors.muted_foreground.to_paint()) .debug_selector(|| format!("{selector}-header-label")) - .child(label) + .child(label_child(label)) .into_any_element(), (_, None) => div().into_any_element(), }; @@ -81,6 +91,9 @@ impl ConversationSurface { ProseTypography::BODY_SIZE_PX, ))) .text_color(theme.colors.muted_foreground.to_paint()) + // The elapsed label counts every second: tabular figures keep + // its width steady as digits change. + .font_features(super::work_motion::tabular_figures()) .pb(theme.spacing.steps(2.0)) .debug_selector(|| format!("{selector}-header")) .child(near); diff --git a/modules/frontend/src/conversation_surface/reply_reveal.rs b/modules/frontend/src/conversation_surface/reply_reveal.rs new file mode 100644 index 000000000..e3e398ed4 --- /dev/null +++ b/modules/frontend/src/conversation_surface/reply_reveal.rs @@ -0,0 +1,358 @@ +//! Paced, shift-free reveal of a turn's streaming reply. +//! +//! The transport delivers reply text in arbitrary chunks. A row instead +//! shows a revealed prefix that only ever ends where nothing on screen can +//! move ([`stable_reveal_end`]): whole words, whole inline constructs, and +//! whole fences and tables. +//! +//! The prefix advances at a steady, capped pace rather than at the +//! transport's: a word budget accrues at a rate that eases toward clearing +//! the backlog within [`CATCH_UP_WINDOW_SECONDS`], never faster than +//! [`MAX_WORDS_PER_MINUTE`], and segments spend it at most every +//! [`SEGMENT_INTERVAL`]. A segment's short runs start left to right, +//! spread across [`SWEEP_SPAN`] (one segment interval), and each segment's +//! sweep picks up where the previous one's front is, so the fade front +//! glides through the text instead of arriving in bursts. Every run's long +//! soft fade overlaps its neighbours', so it never reads as letters +//! streaming. A reply arriving in one piece, or its remainder when the turn +//! ends, drains at the same capped pace. +//! +//! Opacity is the only animated property: glyph ranges cannot rise or blur +//! without moving layout, and layout must stay still. Reduced motion shows +//! every stable unit at once with no fade. + +use std::time::{Duration, Instant}; + +use artisan_ui::markdown_reveal::{RevealFade, RevealSteps, stable_reveal_end}; +use artisan_ui::motion::{MotionCurve, MotionDuration, MotionPolicy}; + +/// One run's fade-in (`--duration-very-slow`, texts reveal): long against +/// the sweep, so a soft gradient trails the front. +const UNIT_FADE: Duration = Duration::from_millis(400); + +/// The fade's curve: a gentle start, so the front has no hard edge. +const UNIT_FADE_CURVE: MotionCurve = MotionCurve::EaseOut; + +/// Visible characters per run of a segment's sweep: fine enough that the +/// gradient reads as one front, not as stepping blocks. +const SWEEP_RUN: usize = 3; + +/// How long a segment's sweep takes from its first run to its last: the +/// gap until the next segment may start, so consecutive sweeps join into +/// one continuous front. +const SWEEP_SPAN: Duration = SEGMENT_INTERVAL; + +/// The fastest prose ever reveals, in words per minute. The transport's +/// bursts and stalls never show through above this; a long reply may trail +/// the model while it streams and catches up at this pace afterwards. +pub(super) const MAX_WORDS_PER_MINUTE: f64 = 900.0; + +/// The slowest prose reveals while words wait, so the last words of a +/// stream never trickle in. +pub(super) const MIN_WORDS_PER_MINUTE: f64 = 300.0; + +/// The reveal aims to clear its backlog within this window, so it speeds up +/// gently behind a burst and slows as it catches up. +const CATCH_UP_WINDOW_SECONDS: f64 = 1.5; + +/// How quickly the reveal rate follows its target: the time constant of an +/// exponential ease, so speed changes glide instead of stepping. +const RATE_EASE_SECONDS: f64 = 0.5; + +/// The most words the budget may bank, so a pause never ends in a burst. +const MAX_BANKED_WORDS: f64 = 6.0; + +/// What one unit costs at most: a fence or table appears whole, but should +/// not stall the reveal for as long as reading all of its words. +const MAX_UNIT_COST: f64 = 6.0; + +/// The shortest gap between two segments (`--duration-quick`). Text that +/// becomes stable within it joins the next segment. +const SEGMENT_INTERVAL: Duration = MotionDuration::Quick.as_duration(); + +/// The reveal state of one streaming prose body, retained by its turn row. +pub(super) struct ReplyReveal { + /// The revealed source prefix, kept to detect a non-append replacement. + revealed: String, + /// The renderer's visible length of `revealed` at its last render. + visible_len: usize, + /// When set, the next render only re-measures `visible_len`: the prefix + /// jumped without a fade, so there is no segment to mark. + resync: bool, + /// Fade start per revealed run, by visible offset, oldest first. + marks: Vec<(usize, Instant)>, + /// The newest segment's start, until its render reports where it ends + /// and it can be split into runs. + pending: Option<(usize, Instant)>, + /// Earliest instant the next pacing tick may reveal more. + next_tick: Instant, + /// The smoothed reveal rate in words per second. + rate: f64, + /// Words the rate has earned but not yet revealed. + budget: f64, + /// When the rate and budget last advanced. + paced_at: Instant, + /// The fade resolved by the latest [`Self::advance`]. + pub(super) fade: RevealFade, +} + +impl ReplyReveal { + /// Starts revealing prose that arrived while its row was on screen. + pub(super) fn begin(now: Instant) -> Self { + Self { + revealed: String::new(), + visible_len: 0, + resync: false, + marks: Vec::new(), + pending: None, + next_tick: now, + rate: MIN_WORDS_PER_MINUTE / 60.0, + // The first word shows at once; the pace builds from there. + budget: 1.0, + paced_at: now, + fade: RevealFade::default(), + } + } + + /// Resumes revealing prose already mid-stream when its row first + /// painted: what arrived shows at once, only later text streams in. + pub(super) fn resume(body: &str, now: Instant) -> Self { + Self { + revealed: body[..stable_reveal_end(body)].to_owned(), + resync: true, + ..Self::begin(now) + } + } + + /// The revealed source prefix. + pub(super) fn revealed(&self) -> &str { + &self.revealed + } + + /// Reveals at most one segment and resolves this frame's fade. + /// + /// `streaming` is the reply's own liveness: a settled reply drains to + /// its full length. Returns whether the reveal still animates, so the + /// row keeps requesting frames only while it does. + pub(super) fn advance( + &mut self, + body: &str, + streaming: bool, + motion: MotionPolicy, + now: Instant, + ) -> bool { + let stable = if streaming { + stable_reveal_end(body) + } else { + body.len() + }; + if !body.starts_with(self.revealed.as_str()) { + // A replacement rather than an append: the shown prefix is gone, + // so show the new stable prefix outright instead of fading it. + body[..stable].clone_into(&mut self.revealed); + self.marks.clear(); + self.pending = None; + self.resync = true; + } + let start = self.revealed.len(); + if start < stable { + match motion { + MotionPolicy::Reduced => body[..stable].clone_into(&mut self.revealed), + MotionPolicy::Full => { + let steps = RevealSteps::new(&body[..stable]); + self.pace(steps.pending_words(start), now); + if now >= self.next_tick && self.budget >= 1.0 { + // Spend the budget on whole units; one unit may run + // the budget negative, which the pace then repays. + let mut end = start; + while end < stable && self.budget >= 1.0 { + let next = steps.next(end); + self.budget -= unit_cost(&body[end..next]); + end = next; + } + self.revealed.push_str(&body[start..end]); + self.next_tick = now + SEGMENT_INTERVAL; + } + } + } + } + if self.revealed.len() > start && !self.resync && motion == MotionPolicy::Full { + self.pending = Some((self.visible_len, now)); + } + self.fade = self.resolve_fade(now); + self.revealed.len() < stable || !self.marks.is_empty() || self.pending.is_some() + } + + /// Eases the rate toward clearing `backlog` words within the catch-up + /// window, between [`MIN_WORDS_PER_MINUTE`] and [`MAX_WORDS_PER_MINUTE`], + /// and earns budget at it. + fn pace(&mut self, backlog: usize, now: Instant) { + let elapsed = now.saturating_duration_since(self.paced_at).as_secs_f64(); + self.paced_at = now; + let backlog = f64::from(u32::try_from(backlog).unwrap_or(u32::MAX)); + let target = (backlog / CATCH_UP_WINDOW_SECONDS) + .clamp(MIN_WORDS_PER_MINUTE / 60.0, MAX_WORDS_PER_MINUTE / 60.0); + let blend = 1.0 - (-elapsed / RATE_EASE_SECONDS).exp(); + self.rate += (target - self.rate) * blend; + self.budget = (self.budget + self.rate * elapsed).min(MAX_BANKED_WORDS); + } + + /// Records the visible length the renderer reported for `revealed`, and + /// splits a new segment into runs whose fades start left to right. + pub(super) fn rendered(&mut self, visible_len: usize) { + if let Some((start, began)) = self.pending.take() + && visible_len > start + { + let runs = (visible_len - start).div_ceil(SWEEP_RUN); + let stagger = SWEEP_SPAN / u32::try_from(runs).unwrap_or(u32::MAX); + // The front carries on from the previous segment's last run, so + // a segment arriving mid-sweep queues behind it; starts stay in + // order, which the fade's prefix drain relies on. + let front = self + .marks + .last() + .map_or(began, |(_, last)| began.max(*last + stagger)); + for run in 0..runs { + let delay = stagger * u32::try_from(run).unwrap_or(u32::MAX); + self.marks.push((start + run * SWEEP_RUN, front + delay)); + } + } + self.visible_len = visible_len; + self.resync = false; + } + + /// Whether the reveal has nothing left to show or fade for `body`. + pub(super) fn is_complete(&self, body: &str) -> bool { + self.revealed.len() == body.len() && self.marks.is_empty() && self.pending.is_none() + } + + /// Resolves each segment's opacity and drops the fully opaque oldest + /// marks; fades end oldest first, so those form a prefix. + fn resolve_fade(&mut self, now: Instant) -> RevealFade { + let alpha = |started: Instant| { + let progress = + now.saturating_duration_since(started).as_secs_f64() / UNIT_FADE.as_secs_f64(); + #[expect( + clippy::cast_possible_truncation, + reason = "the shared easing curve samples in f64 and feeds an f32 opacity; the narrowing is the intended precision" + )] + let eased = UNIT_FADE_CURVE.sample(progress) as f32; + eased + }; + let opaque = self + .marks + .iter() + .take_while(|(_, started)| alpha(*started) >= 1.0) + .count(); + self.marks.drain(..opaque); + // A segment awaiting its runs paints transparent: it was revealed + // this frame, so its fade has not begun. + RevealFade::new( + self.marks + .iter() + .map(|(offset, started)| (*offset, alpha(*started))) + .chain(self.pending.map(|(offset, _)| (offset, 0.0))) + .collect(), + ) + } +} + +/// What revealing one unit costs the budget: its words, capped so a whole +/// fence or table does not stall the prose after it. +fn unit_cost(unit: &str) -> f64 { + let words = f64::from(u32::try_from(unit.split_whitespace().count()).unwrap_or(u32::MAX)); + words.clamp(1.0, MAX_UNIT_COST) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Advances one reveal at 60 fps for `frames`, rendering each frame; + /// returns the largest segment it revealed, in words. + fn run( + reveal: &mut ReplyReveal, + body: &str, + streaming: bool, + at: &mut Instant, + frames: u32, + ) -> usize { + let mut largest = 0; + for _ in 0..frames { + *at += Duration::from_millis(16); + let before = reveal.revealed().split_whitespace().count(); + reveal.advance(body, streaming, MotionPolicy::Full, *at); + let after = reveal.revealed().split_whitespace().count(); + largest = largest.max(after - before); + reveal.rendered(reveal.revealed().len()); + } + largest + } + + #[test] + fn a_segment_sweeps_in_left_to_right() { + let start = Instant::now(); + let mut reveal = ReplyReveal::begin(start); + // A 20-character segment revealed this frame splits into runs. + reveal.pending = Some((0, start)); + reveal.rendered(20); + let mid = start + UNIT_FADE / 3; + reveal.fade = reveal.resolve_fade(mid); + let (left, right) = (reveal.fade.alpha_at(0), reveal.fade.alpha_at(19)); + assert!(right < left, "the left run leads the right one"); + assert!(left < 1.0 && right > 0.0, "one segment, fading together"); + } + + #[test] + fn prose_reveals_at_a_steady_capped_pace() { + // A reply that lands whole, as at the end of a turn: no snap, no + // burst, never faster than the cap, and it still drains. + let body = "word ".repeat(120); + let start = Instant::now(); + let mut at = start; + let mut reveal = ReplyReveal::begin(start); + let largest = run(&mut reveal, &body, false, &mut at, 120); + let shown = reveal.revealed().split_whitespace().count(); + let cap = (MAX_WORDS_PER_MINUTE / 60.0 * 2.0) as usize + MAX_BANKED_WORDS as usize + 1; + assert!(shown > 10, "it moves: {shown} words in two seconds"); + assert!(shown <= cap, "{shown} words in two seconds exceeds the cap"); + assert!( + largest <= MAX_BANKED_WORDS as usize + 1, + "no burst: {largest} words at once" + ); + run(&mut reveal, &body, false, &mut at, 60 * 10); + assert_eq!(reveal.revealed(), body, "the whole reply drains"); + } + + #[test] + fn complete_words_reveal_and_a_growing_word_waits() { + let start = Instant::now(); + let mut at = start; + let mut reveal = ReplyReveal::begin(start); + run(&mut reveal, "Hello brave new", true, &mut at, 60); + // Every complete word shows; the trailing `new` may still grow. + assert_eq!(reveal.revealed(), "Hello brave "); + run(&mut reveal, "Hello brave new", false, &mut at, 60); + assert_eq!(reveal.revealed(), "Hello brave new"); + } + + #[test] + fn reduced_motion_shows_the_stable_prefix_without_fading() { + let start = Instant::now(); + let mut reveal = ReplyReveal::begin(start); + let animating = reveal.advance("One two three four", true, MotionPolicy::Reduced, start); + assert_eq!(reveal.revealed(), "One two three "); + assert!(!animating); + assert!(reveal.fade.is_opaque()); + } + + #[test] + fn a_replaced_body_snaps_without_replaying() { + let start = Instant::now(); + let mut reveal = ReplyReveal::begin(start); + reveal.advance("Alpha beta ", true, MotionPolicy::Full, start); + reveal.rendered(5); + reveal.advance("Gamma delta ", true, MotionPolicy::Full, start); + assert_eq!(reveal.revealed(), "Gamma delta "); + } +} diff --git a/modules/frontend/src/conversation_surface/status_copy.rs b/modules/frontend/src/conversation_surface/status_copy.rs index c83c9901d..f90e0f7a0 100644 --- a/modules/frontend/src/conversation_surface/status_copy.rs +++ b/modules/frontend/src/conversation_surface/status_copy.rs @@ -191,7 +191,9 @@ pub fn turn_is_live(turn: &TurnScene) -> bool { /// never a generic title. A live turn counts on the turn's one elapsed basis /// whatever its narration (a provider wait, a streaming reply, compaction, a /// background wait), so the header keeps counting while the status row says -/// what is happening; without a basis it reads the bare verb. A settled turn +/// what is happening. The basis lives on the turn, not its status block, +/// because a streaming reply suppresses that block; without a basis it reads +/// the bare verb. A settled turn /// reads its lifecycle outcome: the duration belongs to the terminal label, /// so a completed turn that lost its narration reads plain `Worked`. #[must_use] @@ -204,14 +206,12 @@ pub fn turn_section_title(turn: &TurnScene, frame_now_ms: Option) -> String ConversationLifecycle::Pending | ConversationLifecycle::Streaming | ConversationLifecycle::Active - | ConversationLifecycle::Waiting => { - let basis = turn.blocks().iter().find_map(|block| match block { - TurnBlock::TurnStatus(status) => status.active_started_at_ms, - _ => None, - }); - live_status_copy(TurnNarration::Working, basis, frame_now_ms) - .unwrap_or_else(|| "Working".to_owned()) - } + | ConversationLifecycle::Waiting => live_status_copy( + TurnNarration::Working, + turn.active_started_at_ms, + frame_now_ms, + ) + .unwrap_or_else(|| "Working".to_owned()), } } diff --git a/modules/frontend/src/conversation_surface/tests/answer_receipts.rs b/modules/frontend/src/conversation_surface/tests/answer_receipts.rs index 4aa751ea2..9fb6ef338 100644 --- a/modules/frontend/src/conversation_surface/tests/answer_receipts.rs +++ b/modules/frontend/src/conversation_surface/tests/answer_receipts.rs @@ -1,5 +1,6 @@ //! Surface-level answer settlement: correlation by command identity, the -//! mismatched-receipt diagnostic, and unmatched outcomes settling nothing. +//! mismatched-receipt diagnostic, unmatched outcomes settling nothing, and a +//! recorded receipt settling a projected card. use super::super::{ AnswerSettlement, ApprovalAnswerGate, ConversationSurface, RespondApprovalAction, @@ -37,6 +38,22 @@ fn approval_scene() -> crate::conversation_scene::ConversationScene { SceneItemKind::Approval { prompt: String::from("Run the test suite?"), approval_id: observation_id("approval-1"), + decision: None, + }, + None, + )]) +} + +/// A scene whose card id is run-scoped like a projected fact, so it differs +/// from the engine's approval identity the receipt carries. +fn projected_approval_scene() -> crate::conversation_scene::ConversationScene { + scene(vec![item( + "approval-run-answer-provider-7", + 1, + SceneItemKind::Approval { + prompt: String::from("May I access the npm registry?"), + approval_id: observation_id("provider-7"), + decision: None, }, None, )]) @@ -179,3 +196,40 @@ fn unmatched_outcome_settles_nothing(cx: &mut TestAppContext) { }); }); } + +#[gpui::test] +fn recorded_receipt_settles_a_projected_card(cx: &mut TestAppContext) { + const STATUS: &str = "artisan-conversation-surface-turn-turn_a-block-approval-approval-run-answer-provider-7-status"; + const APPROVE: &str = "artisan-conversation-surface-turn-turn_a-block-approval-approval-run-answer-provider-7-approve-submit"; + let (surface, cx) = cx.add_window_view(|_, surface_cx| { + ConversationSurface::new(projected_approval_scene(), ThemeMode::Dark, surface_cx) + }); + cx.update(|_, app| { + surface.update(app, |surface, cx| { + surface.set_answer_thread(thread_id(), cx); + // The renderer keys the gesture by the engine's approval id. + assert!(surface.submit_approval_gesture( + "provider-7", + &run_id(), + &observation_id("provider-7"), + true, + cx, + )); + let command = dispatched_approval(surface); + let state = EngineObservationState::new(thread_id()); + let pairing = surface + .settle_approval_answered(&state, &command, &receipt_for(&command, true), cx) + .expect("the receipt finds the gesture's gate"); + assert!(pairing.is_settled()); + }); + }); + cx.run_until_parked(); + assert!( + cx.debug_bounds(STATUS).is_some(), + "the settled card records the decision" + ); + assert!( + cx.debug_bounds(APPROVE).is_none(), + "a settled card offers no further gesture" + ); +} diff --git a/modules/frontend/src/conversation_surface/tests/work_group_header.rs b/modules/frontend/src/conversation_surface/tests/work_group_header.rs index 75c50be69..c9451d338 100644 --- a/modules/frontend/src/conversation_surface/tests/work_group_header.rs +++ b/modules/frontend/src/conversation_surface/tests/work_group_header.rs @@ -82,6 +82,7 @@ impl Render for HeaderProbe { .child(ConversationSurface::work_group_header_row( Some(self.label.clone()), None, + None, 0.0, self.controlled, "probe", @@ -526,3 +527,187 @@ fn a_live_continuation_chain_toggles_without_disturbing_the_header(cx: &mut Test "the section header stays put" ); } + +#[test] +fn an_approval_pauses_the_section_without_splitting_it() { + // The approval card waits for the reader mid-run; the work it unblocks + // continues the same section, whose one header keeps the live line + // instead of a second `Working for` row painting below the card. + let scene = ConversationScene::build( + vec![SceneTurn::new( + turn_id("turn_a"), + 0, + ConversationLifecycle::Active, + )], + vec![ + item( + "prompt", + 1, + SceneItemKind::UserMessage { + body: "migrate the app".to_owned(), + }, + None, + ), + run_prose("plan", 2, "Checking the workspace"), + run_command("cmd-a", 3), + run_item( + "approval-run_a-provider-7", + 4, + SceneItemKind::Approval { + prompt: "May I access the npm registry?".to_owned(), + approval_id: artisan_domain::ObservationId::parse("provider-7") + .expect("observation id is valid"), + decision: Some(true), + }, + ), + run_command("cmd-b", 5), + ], + vec![TurnNarrationEntry::new( + turn_id("turn_a"), + TurnNarration::Working, + )], + Vec::new(), + ) + .expect("approval section scene is valid"); + let turn = scene.turn_scene(&turn_id("turn_a")).expect("turn present"); + let groups: Vec<_> = turn + .blocks() + .iter() + .enumerate() + .filter_map(|(index, block)| match block { + TurnBlock::WorkGroup(group) => Some((index, group)), + _ => None, + }) + .collect(); + assert_eq!(groups.len(), 1, "one section for the whole run"); + let (index, group) = groups[0]; + assert!(!group.superseded, "the approval does not supersede the run"); + assert_eq!(owning_group_index(turn), Some(index)); +} + +#[test] +fn a_streaming_reply_keeps_the_section_counting_until_the_turn_ends() { + // A live reply suppresses the turn's status block, which used to carry + // the only clock basis, so the header fell back to a bare `Working` + // while the summary streamed. The basis lives on the turn now. + let streaming_reply = item( + "reply-a", + 2, + SceneItemKind::AssistantMessage { + body: "All tests pass.".to_owned(), + phase: AssistantPhase::Unspecified, + }, + None, + ) + .with_provenance(ItemProvenance { + run_id: Some(artisan_domain::RunId::parse("run_a").expect("run id is valid")), + lifecycle: Some(ConversationLifecycle::Streaming), + }); + let scene = ConversationScene::build( + vec![SceneTurn::new( + turn_id("turn_a"), + 0, + ConversationLifecycle::Active, + )], + vec![run_command("work-a", 1), streaming_reply], + vec![ + TurnNarrationEntry::new(turn_id("turn_a"), TurnNarration::StreamingSuppression) + .with_active_started_at_ms(1_000), + ], + Vec::new(), + ) + .expect("scene is valid"); + let turn = scene.turn_scene(&turn_id("turn_a")).expect("turn present"); + assert!( + !turn + .blocks() + .iter() + .any(|block| matches!(block, TurnBlock::TurnStatus(_))), + "the streaming reply suppresses the status block" + ); + assert_eq!(turn_section_title(turn, Some(89_000)), "Working for 1m 28s"); +} + +/// The steered live section plus a streaming reply with `body`. +fn replying_section_scene(body: &str) -> ConversationScene { + let reply = item( + "reply", + 8, + SceneItemKind::AssistantMessage { + body: body.to_owned(), + phase: AssistantPhase::Unspecified, + }, + None, + ) + .with_provenance(ItemProvenance { + run_id: Some(artisan_domain::RunId::parse("run_a").expect("run id is valid")), + lifecycle: Some(ConversationLifecycle::Streaming), + }); + ConversationScene::build( + vec![SceneTurn::new( + turn_id("turn_a"), + 0, + ConversationLifecycle::Active, + )], + vec![ + item( + "prompt", + 1, + SceneItemKind::UserMessage { + body: "why is the footer wrong".to_owned(), + }, + None, + ), + run_prose("likely", 2, "Likely cause found"), + run_command("cmd-a", 3), + reply, + ], + vec![ + TurnNarrationEntry::new(turn_id("turn_a"), TurnNarration::StreamingSuppression) + .with_active_started_at_ms(1_000) + .with_session_disclosure(SceneDisclosure::Open), + ], + Vec::new(), + ) + .expect("replying section scene is valid") +} + +#[gpui::test] +fn a_live_section_folds_once_its_streaming_reply_is_clearly_the_answer(cx: &mut TestAppContext) { + // Reduced motion shows every stable unit at once and folds without an + // accordion flight, so one settled frame reads the end state. + cx.update(|app| app.set_reduce_motion(true)); + // A sentence or two may be narration before the next tool call, which + // moves back into the work: folding on it would fold and reopen the + // work around every such sentence. + let (_narrating, cx_narrating) = cx.add_window_view(|_, surface_cx| { + ConversationSurface::new( + replying_section_scene("All tests pass. "), + ThemeMode::Dark, + surface_cx, + ) + }); + cx_narrating.simulate_resize(size(px(720.0), px(720.0))); + settle(cx_narrating); + assert!( + cx_narrating.debug_bounds(STEER_LEAD_CHAIN).is_some(), + "the work stays open while the reply could still be narration" + ); + + // A long streaming reply is the answer: the work folds while the turn + // is still live, and the header keeps its words. + let answer = "word ".repeat(40); + let (_replying, cx) = cx.add_window_view(|_, surface_cx| { + ConversationSurface::new(replying_section_scene(&answer), ThemeMode::Dark, surface_cx) + }); + cx.simulate_resize(size(px(720.0), px(720.0))); + settle(cx); + assert!( + cx.debug_bounds(STEER_LEAD_HEADER).is_some(), + "the folded section keeps its header" + ); + assert!( + cx.debug_bounds(STEER_LEAD_CHAIN).is_none(), + "the work folds once the reply is clearly the answer" + ); +} diff --git a/modules/frontend/src/conversation_surface/tests/work_groups.rs b/modules/frontend/src/conversation_surface/tests/work_groups.rs index effe1b1ab..cb6828737 100644 --- a/modules/frontend/src/conversation_surface/tests/work_groups.rs +++ b/modules/frontend/src/conversation_surface/tests/work_groups.rs @@ -1332,3 +1332,62 @@ fn copy_confirmation_enters_holds_and_returns_with_reduced_motion_endpoints() { assert_eq!(progress(0, MotionPolicy::Reduced), 1.0); assert_eq!(progress(1500, MotionPolicy::Reduced), 0.0); } + +#[test] +fn prose_followed_by_a_tool_call_is_finished_even_while_its_message_is_live() { + // A message can stay live until its turn ends; counted as streaming, its + // reveal held the last word back and the sentence read cut off. + use crate::conversation_scene::{ProgressPhase, SessionDetail}; + let live = || { + Some(ItemProvenance { + run_id: None, + lifecycle: Some(ConversationLifecycle::Streaming), + }) + }; + let prose = |id: &str, ordinal| SessionDetail::Assistant { + id: scene_id(id), + body: "Checking the renderer now".to_owned(), + phase: AssistantPhase::Unspecified, + ordinal, + provenance: live(), + disclosure: None, + }; + let group = WorkGroupBlock { + items: Vec::new(), + label: None, + disclosure: None, + session: Some(scene_id("session-turn_a")), + session_run: None, + superseded: false, + continuation: None, + reasoning_summary: None, + progress: ProgressPhase::Work, + transition: None, + session_details: vec![ + prose("asst-1", 1), + SessionDetail::Activity { + id: scene_id("act-2"), + body: "cargo test".to_owned(), + kind: None, + detail: None, + lifecycle: Some(ConversationLifecycle::Completed), + ordinal: 2, + disclosure: None, + }, + prose("asst-3", 3), + ], + }; + let rows = ordered_detail_rows(&group); + let streaming: Vec = rows + .iter() + .filter_map(|(_, row)| match row { + DetailRow::Assistant { streaming, .. } => Some(*streaming), + _ => None, + }) + .collect(); + assert_eq!( + streaming, + vec![false, true], + "only the newest prose streams" + ); +} diff --git a/modules/frontend/src/conversation_surface/transcript_window.rs b/modules/frontend/src/conversation_surface/transcript_window.rs index e4d346728..04a622995 100644 --- a/modules/frontend/src/conversation_surface/transcript_window.rs +++ b/modules/frontend/src/conversation_surface/transcript_window.rs @@ -34,6 +34,7 @@ use std::collections::HashSet; use std::ops::Range; +use std::time::{Duration, Instant}; use super::render_budget::{ TRANSCRIPT_MAX_ROW_HEIGHT_PX, TRANSCRIPT_MIN_ROW_HEIGHT_PX, TRANSCRIPT_TURN_GAP_PX, @@ -41,6 +42,10 @@ use super::render_budget::{ }; use super::*; +/// How long after a scene arrives its paint stops counting as watched: a +/// visible window paints within a frame or two. +const UNWATCHED_AFTER: Duration = Duration::from_millis(500); + /// Stable debug-selector suffix for a placeholder standing in for one turn. /// /// Tests use this to prove an off-window turn was not built as a real row. @@ -219,28 +224,8 @@ impl TranscriptShaper { tone: MarkdownBodyTone, row_ledger: Option<&Cell>, ) -> AnyElement { - let within_budget = transcript_markdown_within_budget(body.len()); - let mut frame = self.frame_ledger.get(); - frame.record(body.len(), within_budget); - self.frame_ledger.set(frame); - if let Some(row_ledger) = row_ledger { - let mut row = row_ledger.get(); - row.record(body.len(), within_budget); - row_ledger.set(row); - } - if !within_budget { - let plain_id = SharedString::from(format!("{selector}-markdown-plain")); - return div() - .w_full() - .max_w(px(TRANSCRIPT_PROSE_BODY_WIDTH_PX)) - .debug_selector(|| format!("{selector}-markdown")) - .child(SelectableText::retained( - plain_id, - body.to_owned(), - *theme, - Vec::new(), - )) - .into_any_element(); + if !self.record(body.len(), row_ledger) { + return plain_markdown(body, theme, &selector); } let titles = self.rich_link_titles.borrow(); let probe = SurfaceRichLinkTitles { @@ -251,6 +236,66 @@ impl TranscriptShaper { self.renderer .render_source_with_tone_and_titles(body, *theme, selector, tone, &probe) } + + /// Shapes one revealing prefix of a streamed reply, fading freshly + /// revealed units by `fade` (see + /// [`MarkdownRenderer::render_revealing_source`]). + /// + /// Returns the element and the prefix's visible length. An over-budget + /// body renders plain and whole, exactly like [`Self::render`]. + pub(super) fn render_revealing( + &self, + body: &str, + theme: &ArtisanTheme, + selector: String, + tone: MarkdownBodyTone, + row_ledger: Option<&Cell>, + fade: &RevealFade, + ) -> (AnyElement, usize) { + if !self.record(body.len(), row_ledger) { + return (plain_markdown(body, theme, &selector), body.len()); + } + let titles = self.rich_link_titles.borrow(); + let probe = SurfaceRichLinkTitles { + titles: &titles, + missing: &self.rich_link_missing, + now_ms: self.now_ms(), + }; + self.renderer + .render_revealing_source(body, *theme, selector, tone, &probe, fade) + } + + /// Records one shaped body in the frame and row ledgers and reports + /// whether it is within the per-row budget. + fn record(&self, len: usize, row_ledger: Option<&Cell>) -> bool { + let within_budget = transcript_markdown_within_budget(len); + let mut frame = self.frame_ledger.get(); + frame.record(len, within_budget); + self.frame_ledger.set(frame); + if let Some(row_ledger) = row_ledger { + let mut row = row_ledger.get(); + row.record(len, within_budget); + row_ledger.set(row); + } + within_budget + } +} + +/// The exact full text of an over-budget body as one plain selectable leaf. +fn plain_markdown(body: &str, theme: &ArtisanTheme, selector: &str) -> AnyElement { + let plain_id = SharedString::from(format!("{selector}-markdown-plain")); + let selector = selector.to_owned(); + div() + .w_full() + .max_w(px(TRANSCRIPT_PROSE_BODY_WIDTH_PX)) + .debug_selector(move || format!("{selector}-markdown")) + .child(SelectableText::retained( + plain_id, + body.to_owned(), + *theme, + Vec::new(), + )) + .into_any_element() } /// Measured turn heights plus cached prefix offsets for window planning. @@ -628,6 +673,12 @@ impl ConversationSurface { let force_render = !self.pending_scroll_targets.is_empty() || self.anchor_scrolls_in_flight > 0; let reduce_motion = cx.reduce_motion(); + // A visible window paints a replaced scene within a frame or two; + // a longer wait means nobody watched it arrive. + let caught_up = self + .unpainted_since + .take() + .is_some_and(|since| since.elapsed() >= UNWATCHED_AFTER); for index in 0..turn_count { if !built.contains(index) { // One placeholder per off-window turn keeps the child count @@ -644,6 +695,7 @@ impl ConversationSurface { let source = TurnRowSource { turn, scene_generation: self.scene_generation, + caught_up, theme_mode: self.theme_mode, status_motion, reduce_motion, @@ -709,6 +761,7 @@ impl ConversationSurface { self.turn_rows .retain(|id, _| live_turns.contains(id.as_str())); self.scene_generation = self.scene_generation.wrapping_add(1); + self.unpainted_since.get_or_insert_with(Instant::now); self.transcript_window .borrow_mut() .scene_replaced(&self.scene); diff --git a/modules/frontend/src/conversation_surface/turn_row.rs b/modules/frontend/src/conversation_surface/turn_row.rs index a7ace5e28..3802cd1e4 100644 --- a/modules/frontend/src/conversation_surface/turn_row.rs +++ b/modules/frontend/src/conversation_surface/turn_row.rs @@ -26,18 +26,25 @@ //! one-child-per-turn prepaint indexing (end space, navigator geometry, //! scroll-target handoff, height measurement) is unchanged. -use gpui::{AppContext as _, StyleRefinement, Subscription, WeakEntity}; +use gpui::{AppContext as _, Pixels, StyleRefinement, Subscription, WeakEntity}; use super::transcript_window::{TranscriptShapeLedger, TranscriptShaper}; +use super::work_motion::WorkMotion; use super::*; +use crate::conversation_scene::AssistantPhase; + +/// Words a still-streaming reply must show before the work above it folds: +/// interim narration before a tool call is usually a sentence or two. +const FOLD_AFTER_WORDS: usize = 40; /// One approval row's gate snapshot, the only answer state a row paints. #[derive(Clone, Debug, PartialEq)] pub(super) struct ApprovalRowState { - /// Block identity text; the gate key. + /// The engine's approval identity; the gate key. pub(super) key: String, pub(super) in_flight: bool, pub(super) pending_decision: Option, + pub(super) answered_decision: Option, pub(super) failure: Option, } @@ -83,6 +90,9 @@ pub(super) struct TurnRowSource<'a> { /// Bumped by every scene replacement, so an unchanged scene skips the /// turn comparison entirely. pub(super) scene_generation: u64, + /// Set when this paint comes long after the scene arrived, so a row + /// shows what landed meanwhile at rest instead of replaying it. + pub(super) caught_up: bool, pub(super) theme_mode: ThemeMode, pub(super) status_motion: MotionPolicy, pub(super) reduce_motion: bool, @@ -151,6 +161,7 @@ impl TurnRowHandle { answer_focus: services.answer_focus.clone(), turn: turn.clone(), scene_generation: None, + caught_up: false, theme_mode: ThemeMode::Dark, status_motion: MotionPolicy::Full, reduce_motion: false, @@ -164,6 +175,8 @@ impl TurnRowHandle { send_entrance: None, message_images: None, rich_link_generation: 0, + motion: RefCell::new(WorkMotion::default()), + status_height: Rc::new(Cell::new(px(0.0))), anchors: anchors.clone(), stats: stats.clone(), _self_observation: dirty, @@ -233,6 +246,8 @@ pub(super) struct TurnRowView { pub(super) answer_focus: FocusHandle, pub(super) turn: TurnScene, scene_generation: Option, + /// The turn changed while nobody watched; the next render catches up. + caught_up: bool, pub(super) theme_mode: ThemeMode, pub(super) status_motion: MotionPolicy, pub(super) reduce_motion: bool, @@ -247,6 +262,11 @@ pub(super) struct TurnRowView { pub(super) send_entrance: Option<(Instant, SceneId)>, pub(super) message_images: Option>, rich_link_generation: u64, + /// The live turn's work motion: streamed prose, entering rows, + /// counting phrases, and the status line's exit. + pub(super) motion: RefCell, + /// The status line's last measured height, for its exit. + pub(super) status_height: Rc>, anchors: Rc>>, pub(super) stats: Rc, _self_observation: Subscription, @@ -265,6 +285,7 @@ impl TurnRowView { self.scene_generation = Some(source.scene_generation); if self.turn != *source.turn { self.turn = source.turn.clone(); + self.caught_up |= source.caught_up; self.clock = ClockDigest::of(&self.turn, source.active_now_ms); self.active_now_ms = source.active_now_ms; changed = true; @@ -320,15 +341,17 @@ impl TurnRowView { let TurnBlock::Approval(approval) = block else { continue; }; - let key = approval.id.as_str(); + let key = approval.approval_id.as_str(); let gate = gates.get(key); let in_flight = gate.is_some_and(ApprovalAnswerGate::is_in_flight); let pending_decision = gate.and_then(ApprovalAnswerGate::pending_decision); + let answered_decision = gate.and_then(ApprovalAnswerGate::answered_decision); let failure = gate.and_then(ApprovalAnswerGate::failure_message); let same = self.approvals.get(index).is_some_and(|state| { state.key == key && state.in_flight == in_flight && state.pending_decision == pending_decision + && state.answered_decision == answered_decision && state.failure.as_deref() == failure }); if !same { @@ -336,6 +359,7 @@ impl TurnRowView { key: key.to_owned(), in_flight, pending_decision, + answered_decision, failure: failure.map(str::to_owned), }; if index < self.approvals.len() { @@ -391,6 +415,72 @@ impl TurnRowView { .render(body, theme, selector, tone, Some(&self.stats.ledger)) } + /// Shapes one prose body in the reply tone, streaming it in while it + /// arrives: only its revealed prefix paints, fresh segments fading in + /// (see [`WorkMotion::reveal`]). Settled prose paints whole, under the + /// same budget and ledgers as [`Self::render_budgeted_markdown`]. + pub(super) fn render_streamed_markdown( + &self, + id: &SceneId, + body: &str, + streaming: bool, + theme: &ArtisanTheme, + selector: String, + ) -> AnyElement { + let reveal = self.motion.borrow_mut().reveal(id, body, streaming); + let Some((revealed, fade)) = reveal else { + return self.render_budgeted_markdown( + body, + theme, + selector, + MarkdownBodyTone::Foreground, + ); + }; + let (element, visible_len) = self.shaper.render_revealing( + &revealed, + theme, + selector, + MarkdownBodyTone::Foreground, + Some(&self.stats.ledger), + &fade, + ); + self.motion.borrow_mut().reveal_rendered(id, visible_len); + element + } + + /// Advances this turn's reply reveal for the frame and reports whether + /// the reply is clearly the answer, so the work above it may fold. + /// + /// Claude's prose before a tool call counts as the reply while it + /// streams and moves back into the work when the call starts; folding on + /// its first word folded and reopened the work around every such + /// sentence. The work therefore folds only once the reply is marked + /// final, has stopped streaming, or has shown [`FOLD_AFTER_WORDS`]. + /// A reveal starts only for prose that arrives while the row is on + /// screen; the reply's own render reads the same reveal for its prefix + /// (see [`WorkMotion::reveal`]). + pub(super) fn advance_reply_reveal(&self, turn: &TurnScene) -> bool { + let Some(reply) = turn.blocks().iter().find_map(|block| match block { + TurnBlock::AssistantMessage(message) => Some(message), + _ => None, + }) else { + return false; + }; + let streaming = reply + .provenance + .as_ref() + .and_then(|provenance| provenance.lifecycle) + .is_some_and(lifecycle_is_live); + let mut motion = self.motion.borrow_mut(); + let _ = motion.reveal(&reply.id, &reply.body, streaming); + motion.mark_present(reply.id.as_str()); + let shown = motion + .revealed_words(&reply.id) + .unwrap_or_else(|| reply.body.split_whitespace().count()); + let decided = !streaming || reply.phase == AssistantPhase::Final; + (decided && shown > 0) || shown >= FOLD_AFTER_WORDS + } + /// Hands every unresolved rich-link destination to the surface. /// /// The row renders after the surface's render tail has flushed, so the @@ -414,6 +504,11 @@ impl Render for TurnRowView { .set(self.stats.renders.get().saturating_add(1)); self.stats.ledger.set(TranscriptShapeLedger::default()); let theme = ArtisanTheme::for_mode(self.theme_mode); + self.motion.borrow_mut().begin_frame( + turn_is_live(&self.turn), + self.status_motion, + std::mem::take(&mut self.caught_up), + ); // Anchors are retained per identity across this row's renders, so a // painted anchor stays painted while its block stays in the turn. let previous = std::mem::take(&mut *self.anchors.borrow_mut()); @@ -435,6 +530,10 @@ impl Render for TurnRowView { ) }; *self.anchors.borrow_mut() = rendered; + if self.motion.borrow_mut().end_frame() { + // Only this row repaints next frame; its siblings stay cached. + window.request_animation_frame(); + } self.schedule_rich_link_flush(window, cx); element } diff --git a/modules/frontend/src/conversation_surface/work_motion.rs b/modules/frontend/src/conversation_surface/work_motion.rs new file mode 100644 index 000000000..189ca7cf8 --- /dev/null +++ b/modules/frontend/src/conversation_surface/work_motion.rs @@ -0,0 +1,746 @@ +//! Motion of a live turn's work: streamed prose, entering rows, counting +//! phrases, and the status line's exit. +//! +//! Every animation here belongs to one turn row and runs only while that +//! turn is live: settled history and a row's first render paint at rest, so +//! scrolling back or opening a thread never replays anything. Motion follows +//! the transitions-dev tokens: +//! +//! - Prose inside the work streams exactly like the reply ([`ReplyReveal`]). +//! - A work row (a tool chain, prose, a chain step, the status line) enters +//! with the texts-reveal recipe: 500 ms smooth-out, rising 12 px and +//! un-blurring from 3 px while it fades in. +//! - A phrase that keeps changing (a chain's "Ran 2 commands, read a file", +//! the "Working for 1m 4s" header) animates per word. Its first paint is +//! the row's entrance; after that a changed number rolls like an odometer, +//! digit columns sliding up as it counts up and down as it counts down; a +//! word that grows ("command" → "commands") fades its new letters in; a +//! replaced word and appended words rise in with the text-swap recipe +//! (150 ms ease-in-out, 4 px, 2 px blur). +//! - The status line leaves by fading out while its height closes, so +//! nothing below it jumps. + +use std::cell::Cell; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use gpui::{FontFeatures, Pixels}; + +use super::reply_reveal::ReplyReveal; +use super::*; + +/// Row entrance: `--duration-very-slow` (texts reveal). +const ENTRANCE: Duration = Duration::from_millis(400); +/// The row's height opening (`--duration-fast`, accordion/card resize). +const ENTRANCE_GROW: Duration = MotionDuration::Fast.as_duration(); +/// The content's fade waits this long, so it fades into opened space. +const ENTRANCE_FADE_DELAY: Duration = Duration::from_millis(80); +/// The content's fade (`--duration-fast`). +const ENTRANCE_FADE: Duration = MotionDuration::Fast.as_duration(); +/// The content's rise into place (`--distance-micro`). +const ENTRANCE_RISE_PX: f32 = 4.0; +/// Odometer roll of one digit column (`--duration-very-slow`). +const ROLL: Duration = Duration::from_millis(500); +/// Offset between neighbouring digit columns (`--duration-stagger`). +const ROLL_STAGGER: Duration = Duration::from_millis(40); +/// In-place word swap: `--duration-quick` (text swap). +const SWAP: Duration = Duration::from_millis(150); +/// In-place word swap rise: `--distance-micro` (text swap). +const SWAP_RISE_PX: f32 = 4.0; +/// In-place word swap blur: `--blur-small` (text swap). +const SWAP_BLUR_PX: f32 = 2.0; +/// A new thinking summary swapping into the status line (`--duration-fast`). +const SUMMARY_SWAP: Duration = MotionDuration::Fast.as_duration(); +/// Status line exit: the texts-reveal quiet fade out. +const STATUS_EXIT: Duration = Duration::from_millis(200); + +/// One turn row's work motion, retained across its renders. +#[derive(Default)] +pub(super) struct WorkMotion { + /// Streaming reveals of live prose, by scene id. + reveals: HashMap, + /// Reveals touched by the current frame; the rest are dropped. + touched: HashSet, + /// Every prose body this row has painted, revealed or not. + seen_prose: HashSet, + /// When each work row first painted while the turn was live. + entrances: HashMap>, + /// Natural heights of rows mid-entrance, measured each frame. + entrance_heights: HashMap>>, + /// Set after the row's first render: rows already on screen then never + /// enter. + primed: bool, + /// Whether the turn is live this frame, and how much it may move. + pace: Pace, + /// Changing phrases, by key. + phrases: HashMap, + /// The status line painted last frame and its measured height. + status: Option<(String, Pixels)>, + /// Whether this frame painted a status line. + status_painted: bool, + /// A status line on its way out. + status_exit: Option, + /// The status line's thinking summary and when it last changed. + summary: Option<(String, Option)>, + /// Whether anything still moves; the row then asks for the next frame. + animating: bool, +} + +/// How much a frame of the row may move. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum Pace { + /// The turn has settled: everything paints at rest. + #[default] + Settled, + /// The turn is live under reduced motion: prose shows whole segments + /// at once and nothing animates. + Reduced, + /// The turn is live with full motion. + Full, +} + +/// A status line fading out after it left the scene. +struct StatusExit { + copy: String, + height: Pixels, + started: Instant, +} + +/// One changing phrase's words now and before its latest change. +struct PhraseState { + current: Vec, + previous: Vec, + changed: Option, +} + +/// How one word of a phrase moves this frame. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum WordMotion { + /// At rest. + Still, + /// A number rolling from `from`; `up` when the value grew. + Roll { from: String, up: bool }, + /// The word grew: its first `kept` bytes stay, the rest fade in. + Extend { kept: usize }, + /// A new or replaced word rising in. + Enter, +} + +/// One word of a phrase with its motion and elapsed time. +#[derive(Clone, Debug)] +pub(super) struct PhraseWord { + pub(super) text: String, + pub(super) motion: WordMotion, + pub(super) elapsed: Duration, +} + +impl WorkMotion { + /// Starts one render of the row. + /// + /// A `caught_up` frame paints what arrived while nobody watched (a + /// minimized or hidden window) at rest, like a first render: running + /// reveals end, and prose still arriving shows what landed and streams + /// only the rest. + pub(super) fn begin_frame(&mut self, live: bool, motion: MotionPolicy, caught_up: bool) { + if caught_up { + self.primed = false; + for id in self.reveals.keys() { + self.seen_prose.remove(id); + } + self.reveals.clear(); + for phrase in self.phrases.values_mut() { + phrase.changed = None; + } + } + self.pace = match (live, motion) { + (false, _) => Pace::Settled, + (true, MotionPolicy::Reduced) => Pace::Reduced, + (true, MotionPolicy::Full) => Pace::Full, + }; + self.animating = false; + self.touched.clear(); + self.status_painted = false; + } + + /// Ends one render; returns whether the row must render the next frame. + pub(super) fn end_frame(&mut self) -> bool { + self.primed = true; + let touched = std::mem::take(&mut self.touched); + self.reveals.retain(|id, _| touched.contains(id)); + self.touched = touched; + if !self.status_painted { + self.status = None; + } + self.animating + } + + fn full_motion(&self) -> bool { + self.primed && self.pace == Pace::Full + } + + /// Advances the streaming reveal of one prose body. + /// + /// Returns the prefix to paint and its fade while a reveal runs, or + /// `None` to paint `body` whole. Prose that appears after the row first + /// painted streams in whether or not it is still arriving: a reply that + /// lands whole as the turn ends drains in segments instead of appearing + /// at once. Prose already mid-stream at the row's first paint shows what + /// arrived and streams the rest; settled history never animates. A + /// reveal ends once its last segment has faded in, and the same id keeps + /// its reveal when the scene moves the prose between the reply slot and + /// the work. + pub(super) fn reveal( + &mut self, + id: &SceneId, + body: &str, + streaming: bool, + ) -> Option<(String, RevealFade)> { + let motion = if self.pace == Pace::Reduced { + MotionPolicy::Reduced + } else { + MotionPolicy::Full + }; + let now = Instant::now(); + if self.seen_prose.insert(id.clone()) { + if self.primed { + self.reveals.insert(id.clone(), ReplyReveal::begin(now)); + } else if streaming { + self.reveals + .insert(id.clone(), ReplyReveal::resume(body, now)); + } + } + let reveal = self.reveals.get_mut(id)?; + self.touched.insert(id.clone()); + if reveal.advance(body, streaming, motion, now) { + self.animating = true; + } else if !streaming && reveal.is_complete(body) { + self.reveals.remove(id); + return None; + } + Some((reveal.revealed().to_owned(), reveal.fade.clone())) + } + + /// How many words the reveal of `id` shows, if one runs. + pub(super) fn revealed_words(&self, id: &SceneId) -> Option { + self.reveals + .get(id) + .map(|reveal| reveal.revealed().split_whitespace().count()) + } + + /// Records that the row keyed `key` is already on screen, so it never + /// enters later: prose shown as the reply keeps its place when the scene + /// moves it into the work. + pub(super) fn mark_present(&mut self, key: &str) { + self.entrances.entry(key.to_owned()).or_insert(None); + } + + /// How far the status line's thinking summary has swapped in, while a + /// new one does: it fades in and rises into place, like a text swap. + /// The first summary arrives with the status line's own entrance, and + /// the counting verb never swaps. + pub(super) fn summary_swap(&mut self, summary: Option<&str>) -> Option { + let Some(summary) = summary else { + self.summary = None; + return None; + }; + let animate = self.full_motion(); + match &mut self.summary { + Some((shown, changed)) if shown != summary => { + summary.clone_into(shown); + *changed = animate.then(Instant::now); + } + Some(_) => {} + None => self.summary = Some((summary.to_owned(), None)), + } + let changed = self.summary.as_ref().and_then(|(_, changed)| *changed)?; + let progress = progress_of( + Instant::now().saturating_duration_since(changed), + SUMMARY_SWAP, + MotionCurve::EaseOut, + ); + if progress >= 1.0 { + return None; + } + self.animating = true; + Some(progress) + } + + /// Records the visible length the renderer reported for `id`. + pub(super) fn reveal_rendered(&mut self, id: &SceneId, visible_len: usize) { + if let Some(reveal) = self.reveals.get_mut(id) { + reveal.rendered(visible_len); + } + } + + /// The entrance of one work row this frame, `None` at rest. + pub(super) fn entrance(&mut self, key: &str) -> Option { + let animate = self.full_motion(); + let started = *self + .entrances + .entry(key.to_owned()) + .or_insert_with(|| animate.then(Instant::now)); + let elapsed = started.map(|started| Instant::now().saturating_duration_since(started)); + let Some(elapsed) = elapsed.filter(|elapsed| *elapsed < ENTRANCE) else { + self.entrance_heights.remove(key); + return None; + }; + if self.pace == Pace::Settled { + self.entrance_heights.remove(key); + return None; + } + self.animating = true; + Some(Entrance { + grow: progress_of(elapsed, ENTRANCE_GROW, MotionCurve::SmoothOut), + reveal: progress_of( + elapsed.saturating_sub(ENTRANCE_FADE_DELAY), + ENTRANCE_FADE, + MotionCurve::EaseOut, + ), + measured: Rc::clone( + self.entrance_heights + .entry(key.to_owned()) + .or_insert_with(|| Rc::new(Cell::new(px(0.0)))), + ), + }) + } + + /// The words of a changing phrase with their motion this frame. + /// + /// While the turn is live with full motion the phrase always paints word + /// by word, at rest between changes too: switching to plain text between + /// ticks would swap two layouts that do not measure alike, and a ticking + /// timer would visibly resize every second. Returns `None` (plain text) + /// outside a live turn and under reduced motion. + pub(super) fn phrase(&mut self, key: &str, text: &str) -> Option> { + let animate = self.full_motion(); + let words: Vec = text.split(' ').map(str::to_owned).collect(); + let now = Instant::now(); + let state = self + .phrases + .entry(key.to_owned()) + .or_insert_with(|| PhraseState { + current: words.clone(), + previous: words.clone(), + changed: None, + }); + if state.current != words { + state.previous = std::mem::replace(&mut state.current, words); + state.changed = animate.then_some(now); + } + if !animate { + state.changed = None; + return None; + } + let longest = ROLL + ROLL_STAGGER * 4; + let elapsed = state + .changed + .map(|changed| now.saturating_duration_since(changed)) + .filter(|elapsed| *elapsed < longest); + if elapsed.is_none() { + state.changed = None; + } + self.animating |= elapsed.is_some(); + let previous = if elapsed.is_some() { + &state.previous + } else { + &state.current + }; + let elapsed = elapsed.unwrap_or(longest); + Some( + state + .current + .iter() + .enumerate() + .map(|(index, word)| PhraseWord { + text: word.clone(), + motion: word_motion(previous.get(index).map(String::as_str), word), + elapsed, + }) + .collect(), + ) + } + + /// Records the status line painted this frame and its measured height. + pub(super) fn status_painted(&mut self, copy: &str, height: Pixels) { + self.status_painted = true; + self.status_exit = None; + self.status = Some((copy.to_owned(), height)); + } + + /// The status line on its way out: its copy, height, and progress. + /// + /// Called after the turn's blocks painted, so a status line that left + /// this frame starts its exit in the same frame, with no blank frame + /// between them. A status line that returns later enters again. + pub(super) fn status_exit(&mut self) -> Option<(String, Pixels, f32)> { + if !self.status_painted + && let Some((copy, height)) = self.status.take() + { + self.entrances.remove("status"); + if self.full_motion() { + self.status_exit = Some(StatusExit { + copy, + height, + started: Instant::now(), + }); + } + } + let exit = self.status_exit.as_ref()?; + let elapsed = Instant::now().saturating_duration_since(exit.started); + if elapsed >= STATUS_EXIT { + self.status_exit = None; + return None; + } + self.animating = true; + Some(( + exit.copy.clone(), + exit.height, + eased(MotionCurve::EaseInOut, elapsed, STATUS_EXIT), + )) + } +} + +/// How `word` moves given the word it replaced at the same position. +fn word_motion(previous: Option<&str>, word: &str) -> WordMotion { + let Some(previous) = previous else { + return WordMotion::Enter; + }; + if previous == word { + return WordMotion::Still; + } + if let (Some((_, from_suffix, from_value)), Some((_, to_suffix, to_value))) = + (number_word(previous), number_word(word)) + && from_suffix == to_suffix + { + return WordMotion::Roll { + from: previous.to_owned(), + up: to_value >= from_value, + }; + } + if word.starts_with(previous) { + return WordMotion::Extend { + kept: previous.len(), + }; + } + WordMotion::Enter +} + +/// Splits a counting word into its digits, unit suffix, and value. The +/// article that stands for one ("a command", "an edit") counts as `1`. +fn number_word(word: &str) -> Option<(&str, &str, u64)> { + if matches!(word, "a" | "an") { + return Some((word, "", 1)); + } + let digits_end = word + .char_indices() + .find(|(_, character)| !character.is_ascii_digit()) + .map_or(word.len(), |(index, _)| index); + if digits_end == 0 { + return None; + } + let (digits, suffix) = word.split_at(digits_end); + Some((digits, suffix, digits.parse().ok()?)) +} + +fn eased(curve: MotionCurve, elapsed: Duration, duration: Duration) -> f32 { + #[expect( + clippy::cast_possible_truncation, + reason = "the shared easing curve samples in f64 and feeds f32 opacity and offsets; the narrowing is the intended precision" + )] + let value = curve.sample(elapsed.as_secs_f64() / duration.as_secs_f64()) as f32; + value +} + +/// One row's entrance this frame. +pub(super) struct Entrance { + /// How far the row's height has opened, `0.0..=1.0`. + grow: f32, + /// How far its content has faded in, `0.0..=1.0`. + reveal: f32, + /// The content's natural height, measured each frame. + measured: Rc>, +} + +/// Wraps a work row in its entrance: the row's height opens from nothing +/// (clipped, so the rows below glide down instead of jumping), and the +/// content fades in a beat later while it settles 4 px into place. `gap` is +/// the container's gap before the row, which closes with the height so no +/// empty band appears first. No blur: a filtered layer softened the text. +pub(super) fn with_entrance( + element: AnyElement, + entrance: Option, + gap: Pixels, +) -> AnyElement { + let Some(Entrance { + grow, + reveal, + measured, + }) = entrance + else { + return element; + }; + let height = measured.get() * grow; + let content = div() + .w_full() + .min_w_0() + .flex_shrink_0() + .relative() + .top(px(ENTRANCE_RISE_PX * (1.0 - reveal))) + .opacity(reveal) + .child(element); + div() + .w_full() + .min_w_0() + .h(height) + .mt(-(gap * (1.0 - grow))) + .overflow_hidden() + .flex() + .flex_col() + .child(content) + .on_children_prepainted(move |bounds, _, _| { + if let Some(bounds) = bounds.first() { + measured.set(bounds.size.height); + } + }) + .into_any_element() +} + +/// Tabular figures (`tnum`): every digit takes the same advance, so a +/// counting number never changes width as its digits change. Spline Sans +/// digits are proportional by default ("1" is a third narrower than "8"). +pub(super) fn tabular_figures() -> FontFeatures { + FontFeatures(Arc::new(vec![("tnum".into(), 1)])) +} + +/// Paints a changing phrase word by word on one line of `line_height`, in +/// tabular figures. +pub(super) fn phrase_element(words: &[PhraseWord], line_height: Pixels) -> AnyElement { + let mut row = div() + .font_features(tabular_figures()) + .flex() + .flex_row() + .flex_nowrap() + .items_center() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap(); + for (index, word) in words.iter().enumerate() { + if index > 0 { + row = row.child(" "); + } + row = row.child(word_element(word, line_height)); + } + row.into_any_element() +} + +fn word_element(word: &PhraseWord, line_height: Pixels) -> AnyElement { + match &word.motion { + // A number rests in the same per-character columns it rolls in: + // one text run spaces its characters by the font's tracking, columns + // do not, so swapping between the two moved it a few pixels on + // every tick. + WordMotion::Still if word.text.chars().any(|c| c.is_ascii_digit()) => { + character_columns(&word.text) + } + WordMotion::Still => div() + .flex_none() + .child(word.text.clone()) + .into_any_element(), + WordMotion::Enter => swap_in(word.text.clone(), word.elapsed), + WordMotion::Extend { kept } => { + let (kept, added) = word.text.split_at((*kept).min(word.text.len())); + let progress = progress_of(word.elapsed, SWAP, MotionCurve::EaseInOut); + div() + .flex() + .flex_none() + .child(kept.to_owned()) + .child(div().opacity(progress).child(added.to_owned())) + .into_any_element() + } + WordMotion::Roll { from, up } => odometer(from, &word.text, *up, word.elapsed, line_height), + } +} + +/// A word as one column per character, the layout the odometer rolls in. +fn character_columns(text: &str) -> AnyElement { + let mut row = div().flex().flex_row().flex_none(); + for character in text.chars() { + row = row.child(div().child(character.to_string())); + } + row.into_any_element() +} + +/// A word rising 4 px into place while it fades in (text swap enter). +fn swap_in(text: String, elapsed: Duration) -> AnyElement { + let progress = progress_of(elapsed, SWAP, MotionCurve::EaseInOut); + div() + .flex_none() + .relative() + .top(px(SWAP_RISE_PX * (1.0 - progress))) + .opacity(progress) + .filter(vec![Filter::Blur(px(SWAP_BLUR_PX * (1.0 - progress)))]) + .child(text) + .into_any_element() +} + +fn progress_of(elapsed: Duration, duration: Duration, curve: MotionCurve) -> f32 { + if elapsed >= duration { + 1.0 + } else { + eased(curve, elapsed, duration) + } +} + +/// A number rolling from `from` to `to` like an odometer: right-aligned +/// digit columns, each a clipped reel passing through every digit between +/// its old and new value, rightmost column first. +fn odometer(from: &str, to: &str, up: bool, elapsed: Duration, line_height: Pixels) -> AnyElement { + let from_chars: Vec = from.chars().collect(); + let to_chars: Vec = to.chars().collect(); + let mut row = div().flex().flex_row().flex_none(); + for column in 0..to_chars.len() { + let from_right = to_chars.len() - 1 - column; + let target = to_chars[column]; + let source = from_chars + .len() + .checked_sub(1 + from_right) + .map(|index| from_chars[index]); + let delay = ROLL_STAGGER * u32::try_from(from_right).unwrap_or(0); + let local = elapsed.saturating_sub(delay); + row = row.child(match source { + Some(source) if source == target => div().child(target.to_string()).into_any_element(), + Some(source) => reel(source, target, up, local, line_height), + // A column the number grew into rises in like a new word. + None => swap_in(target.to_string(), local), + }); + } + row.into_any_element() +} + +/// One clipped digit column scrolling from `source` to `target`. +fn reel( + source: char, + target: char, + up: bool, + elapsed: Duration, + line_height: Pixels, +) -> AnyElement { + let mut cells = vec![source]; + if let (Some(from), Some(to)) = (source.to_digit(10), target.to_digit(10)) { + let step = if up { 1 } else { 9 }; + let mut digit = from; + while digit != to { + digit = (digit + step) % 10; + cells.push(char::from_digit(digit, 10).unwrap_or(target)); + } + } else { + cells.push(target); + } + // Counting up the reel moves up through ascending cells; counting down + // it moves down, so the strip is laid out in reverse. + if !up { + cells.reverse(); + } + let steps = f32::from(u16::try_from(cells.len() - 1).unwrap_or(u16::MAX)); + let travel = f32::from(line_height) * steps; + let progress = progress_of(elapsed, ROLL, MotionCurve::SmoothOut); + let offset = if up { + -travel * progress + } else { + -travel * (1.0 - progress) + }; + let mut strip = div() + .absolute() + .left_0() + .right_0() + .top(px(offset)) + .flex() + .flex_col(); + for cell in cells { + strip = strip.child( + div() + .h(line_height) + .flex() + .items_center() + .justify_center() + .child(cell.to_string()), + ); + } + // The invisible target sizes the column; the reel scrolls inside it. + div() + .relative() + .flex_none() + .h(line_height) + .overflow_hidden() + .child(div().opacity(0.0).child(target.to_string())) + .child(strip) + .into_any_element() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn words_move_by_what_changed() { + assert_eq!(word_motion(Some("ran"), "ran"), WordMotion::Still); + assert_eq!( + word_motion(Some("a"), "2"), + WordMotion::Roll { + from: "a".to_owned(), + up: true + } + ); + assert_eq!( + word_motion(Some("12s"), "11s"), + WordMotion::Roll { + from: "12s".to_owned(), + up: false + } + ); + assert_eq!( + word_motion(Some("command"), "commands"), + WordMotion::Extend { kept: 7 } + ); + // A unit change is a different word, not a count. + assert_eq!(word_motion(Some("59s"), "1m"), WordMotion::Enter); + assert_eq!(word_motion(None, "read"), WordMotion::Enter); + } + + #[test] + fn a_live_phrase_keeps_one_layout_and_moves_on_change() { + let mut motion = WorkMotion::default(); + motion.begin_frame(true, MotionPolicy::Full, false); + // The first frame of a row paints at rest as plain text. + assert!(motion.phrase("chain", "Ran a command").is_none()); + motion.end_frame(); + motion.begin_frame(true, MotionPolicy::Full, false); + // Once live, a phrase at rest still paints word by word. + let still = motion + .phrase("chain", "Ran a command") + .expect("live phrase"); + assert!(still.iter().all(|word| word.motion == WordMotion::Still)); + assert!(!motion.end_frame(), "a phrase at rest requests no frames"); + motion.begin_frame(true, MotionPolicy::Full, false); + let words = motion.phrase("chain", "Ran 2 commands").expect("changed"); + assert_eq!(words[0].motion, WordMotion::Still); + assert!(matches!(words[1].motion, WordMotion::Roll { up: true, .. })); + assert_eq!(words[2].motion, WordMotion::Extend { kept: 7 }); + } + + #[test] + fn rows_on_screen_at_first_render_never_enter() { + let mut motion = WorkMotion::default(); + motion.begin_frame(true, MotionPolicy::Full, false); + assert!(motion.entrance("old-row").is_none()); + motion.end_frame(); + motion.begin_frame(true, MotionPolicy::Full, false); + assert!(motion.entrance("old-row").is_none()); + assert!(motion.entrance("new-row").is_some()); + // Settled history paints at rest. + motion.begin_frame(false, MotionPolicy::Full, false); + assert!(motion.entrance("later-row").is_none()); + } +} diff --git a/modules/frontend/src/desktop_shell.rs b/modules/frontend/src/desktop_shell.rs index 8335c8f26..37695e658 100644 --- a/modules/frontend/src/desktop_shell.rs +++ b/modules/frontend/src/desktop_shell.rs @@ -15,6 +15,7 @@ use gpui::prelude::{ }; use gpui::{AnyElement, Div, FontWeight, Pixels, SharedString, WindowControlArea, div, px}; +use crate::native_context_usage::{CONTEXT_METER_HEIGHT_PX, CONTEXT_METER_WIDTH_PX}; use crate::shell::title_bar_caption_button; /// Root selector for the mounted native workspace. @@ -46,6 +47,13 @@ pub const DESKTOP_COLLAPSE_SELECTOR: &str = "artisan-desktop-collapse"; /// Junction crosshair where the right inspector column's left rule meets the /// titlebar's bottom rule. pub const DESKTOP_INSPECTOR_JUNCTION_SELECTOR: &str = "artisan-desktop-inspector-junction"; +/// Stable selector for the inspector rule's titlebar continuation. +pub const DESKTOP_INSPECTOR_TITLEBAR_DIVIDER_SELECTOR: &str = + "artisan-desktop-inspector-titlebar-divider"; +/// Stable selector for the titlebar slot holding the context meter. +pub const DESKTOP_CONTEXT_METER_SLOT_SELECTOR: &str = "artisan-desktop-context-meter-slot"; +/// Gap between the context meter and the rule or controls to its right. +pub const DESKTOP_CONTEXT_METER_GAP_PX: f32 = 16.0; /// Native workspace titlebar height. pub const DESKTOP_TITLEBAR_HEIGHT_PX: f32 = 48.0; @@ -194,9 +202,12 @@ pub fn junction_crosshair(theme: DesktopTheme, stroke: Pixels) -> Div { /// column while it is on screen (`None` when the route shows none). The /// shell owns every junction of its rules, so it paints a second /// [`junction_crosshair`] where that column's one-device-pixel left rule -/// meets the titlebar's bottom rule, mirroring the sidebar junction. Unlike -/// the sidebar rule, the inspector rule does not continue up through the -/// titlebar: the header and drag surface run unbroken above the column. +/// meets the titlebar's bottom rule, mirroring the sidebar junction. Like +/// the sidebar rule, the inspector rule continues up through the titlebar. +/// +/// `context_meter` is the thread's context-window meter. It sits at the +/// titlebar's right end of the conversation side: just left of the +/// inspector rule, or of the caption controls when no inspector shows. /// /// `brand` owns the leading sidebar section: the `Artisan Editor` wordmark, /// seated above the sidebar at exactly its width. `header` owns the titlebar's @@ -225,6 +236,7 @@ pub fn desktop_shell( sidebar: AnyElement, body: AnyElement, inspector_width: Option, + context_meter: Option, maximized: bool, ) -> Div { let legacy_theme = ArtisanTheme::for_mode(ThemeMode::Dark); @@ -277,6 +289,18 @@ pub fn desktop_shell( // the elastic thread name from truncating flush against the controls, and // the header itself is not a drag area, so its repository link keeps its // click. + // The meter is anchored from the window's right edge: past the inspector + // column when it shows, else past the caption controls. The content + // section ends at the controls, so its right inset grows by however far + // the meter reaches past them, keeping a long title clear of it. + let controls_width = px(DESKTOP_TITLEBAR_CONTROL_WIDTH_PX * 3.0); + let meter_right = + inspector_width.unwrap_or(px(0.0)).max(controls_width) + px(DESKTOP_CONTEXT_METER_GAP_PX); + let content_inset = if context_meter.is_some() { + (meter_right + px(CONTEXT_METER_WIDTH_PX + 24.0) - controls_width).max(px(24.0)) + } else { + px(24.0) + }; let content = div() .flex_1() .min_w(px(0.0)) @@ -292,7 +316,7 @@ pub fn desktop_shell( .flex() .items_center() .pl(px(DESKTOP_TITLEBAR_CONTENT_INSET_PX)) - .pr(px(24.0)) + .pr(content_inset) .overflow_hidden() .child(header) .child( @@ -338,6 +362,31 @@ pub fn desktop_shell( .h(style.titlebar_height) .bg(theme.line) .debug_selector(|| "artisan-desktop-titlebar-divider".to_owned()) + })) + .children(inspector_width.map(|inspector_width| { + // One-physical-pixel continuation of the inspector's left rule + // above its junction. That rule paints [w-iw, w-iw+1dp] from the + // window's right edge, so anchoring from the right at iw-1dp + // lines the two up. Plain and absolute, like the sidebar divider. + div() + .absolute() + .right(inspector_width - style.one_device_pixel) + .top(px(0.0)) + .w(style.one_device_pixel) + .h(style.titlebar_height) + .bg(theme.line) + .debug_selector(|| DESKTOP_INSPECTOR_TITLEBAR_DIVIDER_SELECTOR.to_owned()) + })) + .children(context_meter.map(|meter| { + div() + .absolute() + .right(meter_right) + .top(px(0.0)) + .h(style.titlebar_height) + .flex() + .items_center() + .debug_selector(|| DESKTOP_CONTEXT_METER_SLOT_SELECTOR.to_owned()) + .child(meter) })); let main = div() @@ -557,6 +606,7 @@ mod tests { struct ShellJunctionProbe { inspector_width: Option, collapsed: bool, + meter: bool, } impl gpui::Render for ShellJunctionProbe { @@ -575,6 +625,13 @@ mod tests { empty(), empty(), self.inspector_width, + self.meter.then(|| { + div() + .w(px(CONTEXT_METER_WIDTH_PX)) + .h(px(CONTEXT_METER_HEIGHT_PX)) + .debug_selector(|| "probe-meter".to_owned()) + .into_any_element() + }), false, ) } @@ -590,6 +647,7 @@ mod tests { let (_view, cx) = cx.add_window_view(|_, _| ShellJunctionProbe { inspector_width: Some(px(DESKTOP_SIDEBAR_WIDTH_PX)), collapsed, + meter: false, }); cx.simulate_resize(gpui::size(px(1800.0), px(900.0))); cx.run_until_parked(); @@ -623,6 +681,7 @@ mod tests { let (_view, cx) = cx.add_window_view(|_, _| ShellJunctionProbe { inspector_width: None, collapsed: false, + meter: false, }); cx.run_until_parked(); assert!(cx.debug_bounds(DESKTOP_ROOT_SELECTOR).is_some()); @@ -633,6 +692,61 @@ mod tests { ); } + /// The inspector's left rule continues up through the titlebar, and the + /// context meter sits just left of it, vertically centred in the bar. + #[gpui::test] + fn inspector_rule_runs_through_the_titlebar_with_the_meter_beside_it( + cx: &mut gpui::TestAppContext, + ) { + let (_view, cx) = cx.add_window_view(|_, _| ShellJunctionProbe { + inspector_width: Some(px(DESKTOP_SIDEBAR_WIDTH_PX)), + collapsed: false, + meter: true, + }); + cx.simulate_resize(gpui::size(px(1800.0), px(900.0))); + cx.run_until_parked(); + let root = cx.debug_bounds(DESKTOP_ROOT_SELECTOR).expect("root"); + let rule_x = root.right() - px(DESKTOP_SIDEBAR_WIDTH_PX); + let divider = cx + .debug_bounds(DESKTOP_INSPECTOR_TITLEBAR_DIVIDER_SELECTOR) + .expect("the inspector rule continues through the titlebar"); + assert!((divider.left() - rule_x).abs() < px(0.01), "{divider:?}"); + assert_eq!(divider.top(), root.top()); + assert_eq!(divider.size.height, px(DESKTOP_TITLEBAR_HEIGHT_PX)); + + let meter = cx.debug_bounds("probe-meter").expect("meter mounts"); + assert!( + (meter.right() - (rule_x - px(DESKTOP_CONTEXT_METER_GAP_PX))).abs() < px(0.01), + "meter {meter:?} must end one gap left of the rule at {rule_x:?}" + ); + let centre = root.top() + px(DESKTOP_TITLEBAR_HEIGHT_PX / 2.0); + assert!((meter.center().y - centre).abs() < px(0.01), "{meter:?}"); + } + + /// Without an inspector the meter clears the caption controls, and no + /// titlebar divider is left behind. + #[gpui::test] + fn meter_clears_the_controls_without_an_inspector(cx: &mut gpui::TestAppContext) { + let (_view, cx) = cx.add_window_view(|_, _| ShellJunctionProbe { + inspector_width: None, + collapsed: false, + meter: true, + }); + cx.simulate_resize(gpui::size(px(1800.0), px(900.0))); + cx.run_until_parked(); + let root = cx.debug_bounds(DESKTOP_ROOT_SELECTOR).expect("root"); + assert!( + cx.debug_bounds(DESKTOP_INSPECTOR_TITLEBAR_DIVIDER_SELECTOR) + .is_none() + ); + let meter = cx.debug_bounds("probe-meter").expect("meter mounts"); + let controls_left = root.right() - px(DESKTOP_TITLEBAR_CONTROL_WIDTH_PX * 3.0); + assert!( + (meter.right() - (controls_left - px(DESKTOP_CONTEXT_METER_GAP_PX))).abs() < px(0.01), + "meter {meter:?} must end one gap left of the controls at {controls_left:?}" + ); + } + #[test] fn desktop_shell_resolves_one_physical_pixel_on_scaled_displays() { assert_eq!( diff --git a/modules/frontend/src/home_project_picker.rs b/modules/frontend/src/home_project_picker.rs index 3a73ec9d1..6cca0b8dd 100644 --- a/modules/frontend/src/home_project_picker.rs +++ b/modules/frontend/src/home_project_picker.rs @@ -570,9 +570,12 @@ impl HomeProjectPickerView { .track_scroll(&self.menu_scroll) .max_h(menu_max_height_for_viewport(viewport)) .p(px(8.0)); + let mut rows = vec![self.menu_hover_id(PickerRow::NewProject)]; for catalog_index in self.state.visible_indexes() { + rows.push(self.menu_hover_id(PickerRow::Project(catalog_index))); list = list.child(self.render_project_row(catalog_index, cx)); } + self.menu_hover.borrow_mut().clear_if_missing(&rows); list = list.child( div() .flex() diff --git a/modules/frontend/src/native_application.rs b/modules/frontend/src/native_application.rs index a01d31414..7f9e6b764 100644 --- a/modules/frontend/src/native_application.rs +++ b/modules/frontend/src/native_application.rs @@ -70,9 +70,9 @@ use crate::native_composer_controls::{ use crate::native_message_images::{NativeMessageImages, NativeMessageImagesEvent}; use crate::native_model_catalog::NativeModelCatalog; use crate::native_model_selector::{ - HoverRect, NativeModelSelector, NativeModelSelectorStatus, PICKER_MENU_MOTION_DURATION_MS, - PickerMenuMotion, PickerMenuPhase, PickerScrollState, SlidingHoverState, animate_picker_menu, - engine_accent, engine_asset, render_picker_hover_pill, + HoverRect, NativeModelSelector, NativeModelSelectorStatus, PickerMenuEntrance, + PickerMenuMotion, PickerMenuPhase, PickerScrollState, SlidingHoverState, + animate_picker_menu_timed, engine_accent, engine_asset, render_picker_hover_pill, }; use crate::native_profile_usage::{ NativeProfileUsageState, NativeUsageEntry, NativeUsageWindow, ProfileUsageGeneration, @@ -219,7 +219,10 @@ mod impl_engine_installs; #[path = "native_application/app_entry.rs"] mod app_entry; +#[cfg(feature = "flight-recorder")] +mod flight_recorder; mod frame_capture; +mod project_icon; #[path = "native_application/impl_route_surface.rs"] mod impl_route_surface; @@ -234,23 +237,23 @@ mod impl_failure; use app_entry::bind_native_actions; pub use app_entry::run; -#[cfg(test)] -use selectors::{SURFACE_HEIGHT, SURFACE_WIDTH}; use selectors::{ - MAX_RETAINED_SWITCH_LISTINGS, MAX_RETAINED_SWITCH_PATCH_IDS, MAX_RETAINED_SWITCH_REQUEST_IDS, - LAUNCH_WINDOW_HEIGHT, MIN_WINDOW_HEIGHT, NATIVE_KEY_CONTEXT, NATIVE_ROOT_SELECTOR, POLL_INTERVAL, PROFILE_MENU_ANCHOR_GAP_PX, + LAUNCH_WINDOW_HEIGHT, MAX_RETAINED_SWITCH_LISTINGS, MAX_RETAINED_SWITCH_PATCH_IDS, + MAX_RETAINED_SWITCH_REQUEST_IDS, MIN_WINDOW_HEIGHT, NATIVE_KEY_CONTEXT, NATIVE_ROOT_SELECTOR, + POLL_INTERVAL, PROFILE_MENU_ANCHOR_GAP_PX, PROFILE_MENU_CLOSE, PROFILE_MENU_OPEN, PROFILE_MENU_VIEWPORT_MARGIN_PX, PROFILE_SETTINGS_HOVER_ID, PROFILE_USAGE_HOVER_ID, PROFILE_USAGE_TITLE_GAP_PX, SIDEBAR_MARKETPLACE_HOVER_ID, SIDEBAR_NEW_THREAD_HOVER_ID, - SIDEBAR_PROFILE_HOVER_ID, TITLEBAR_HEADER_SELECTOR, - TITLEBAR_PROJECT_FOLDER_SELECTOR, TITLEBAR_REPOSITORY_LABEL_SELECTOR, - TITLEBAR_REPOSITORY_MARK_SELECTOR, TITLEBAR_ROUTE_TITLE_SELECTOR, - TITLEBAR_THREAD_SEPARATOR_SELECTOR, + SIDEBAR_PROFILE_HOVER_ID, TITLEBAR_HEADER_SELECTOR, TITLEBAR_PROJECT_FOLDER_SELECTOR, + TITLEBAR_REPOSITORY_LABEL_SELECTOR, TITLEBAR_REPOSITORY_MARK_SELECTOR, + TITLEBAR_ROUTE_TITLE_SELECTOR, TITLEBAR_THREAD_SEPARATOR_SELECTOR, }; #[cfg(test)] use selectors::{ NATIVE_RAIL_ADD_PROJECT_LABEL, NATIVE_RAIL_ADD_PROJECT_SELECTOR, NATIVE_STATUS_SELECTOR, WINDOW_TITLE, }; +#[cfg(test)] +use selectors::{SURFACE_HEIGHT, SURFACE_WIDTH}; use profile_motion::{ PROFILE_MENU_FIXED_CHROME_PX, PROFILE_METER_TICK_PX, ProfileTipTween, RefreshSwap, @@ -360,6 +363,7 @@ pub struct NativeApplication { /// The Forge's default engine configuration, shown on threads without /// their own until they save one. default_engine_config: Option, + pending_model_preference: Option, /// Legacy file preferences handed to the Forge, removed once it answers. legacy_import: Option, #[cfg(test)] diff --git a/modules/frontend/src/native_application/app_entry.rs b/modules/frontend/src/native_application/app_entry.rs index f776c341d..164d31ecd 100644 --- a/modules/frontend/src/native_application/app_entry.rs +++ b/modules/frontend/src/native_application/app_entry.rs @@ -7,7 +7,17 @@ use super::workspace::{NativeWorkspace, QUIT_DRAIN_LIMIT, close_connection}; use super::*; +#[cfg(feature = "flight-recorder")] +gpui::actions!(flight_recorder, [SaveRecentTrace]); + pub(super) fn bind_native_actions(cx: &mut App) { + #[cfg(feature = "flight-recorder")] + { + cx.bind_keys([KeyBinding::new("ctrl-shift-f11", SaveRecentTrace, None)]); + cx.on_action(|_: &SaveRecentTrace, _| { + let _ = artisan_tracing::save(); + }); + } NativeComposer::bind_actions(cx); NativeCommandMenu::bind_actions(cx); cx.bind_keys([ @@ -81,6 +91,8 @@ pub fn run() -> ExitCode { if let Some(code) = crate::native_hosts::headless() { return code; } + #[cfg(feature = "flight-recorder")] + let _trace_session = artisan_tracing::start("editor"); // One selection for the whole launch: the connection, the title, and the // window all name the same host (or none, until one is added). let home = crate::native_hosts::selected_home(); @@ -95,6 +107,8 @@ pub fn run() -> ExitCode { gpui_platform::application() .with_assets(artisan_ui::asset_seam::CatalogAssetSource) .run(move |cx: &mut App| { + #[cfg(feature = "flight-recorder")] + super::flight_recorder::start(cx); // Register the vendored legacy typefaces before any window opens; // on failure keep running on system faces (typed, not swallowed). if let Err(error) = artisan_ui::fonts::register_bundled_fonts(cx) { diff --git a/modules/frontend/src/native_application/flight_recorder.rs b/modules/frontend/src/native_application/flight_recorder.rs new file mode 100644 index 000000000..c83d62a64 --- /dev/null +++ b/modules/frontend/src/native_application/flight_recorder.rs @@ -0,0 +1,118 @@ +//! UI heartbeat, GPUI frame/task import, and content-free navigation state. + +use std::time::{Duration, Instant}; + +use artisan_tracing::{Heartbeat, completed}; +use gpui::{ + App, + profiler::{self, FrameEvent, FrameTimingCollector}, +}; + +use super::*; + +thread_local! { + /// The navigation gates last written to the recorder on this thread. + static LAST_GATES: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +pub(super) fn start(cx: &mut App) { + if !artisan_tracing::is_recording() { + return; + } + artisan_tracing::name_thread("editor.ui"); + profiler::set_trace_enabled(true); + let heartbeat = Heartbeat::new("editor.ui"); + let mut frames = FrameTimingCollector::new(); + let mut last_task = Instant::now(); + let mut last_task_collection = last_task; + cx.spawn(async move |cx| { + loop { + cx.background_executor().timer(Duration::from_millis(100)).await; + cx.update(|_| { + heartbeat.tick(); + for frame in frames.collect_unseen() { + match frame { + FrameEvent::Draw(frame) => { + completed("frame", "draw", frame.draw_start, frame.draw_end, serde_json::json!({ + "invalidations":frame.invalidations, + "dirty_to_draw_ms":frame.dirty_to_draw_duration().map(|d| d.as_secs_f64() * 1000.0), + })); + } + FrameEvent::Present(frame) => { + completed("frame.present", "present", frame.present_start, frame.present_end, serde_json::json!({ + "interval_ms":frame.animation_interval.map(|d| d.as_secs_f64() * 1000.0), + })); + } + } + } + if last_task_collection.elapsed() < Duration::from_secs(1) { + return; + } + last_task_collection = Instant::now(); + let mut newest = last_task; + for task in profiler::get_current_thread_timings(gpui::TasksIncluded::OnlyCompleted).timings { + if task.end.0 > last_task { + newest = newest.max(task.end.0); + if task.poll_duration() >= Duration::from_millis(10) { + completed("ui.task", "foreground.poll", task.start, task.end.0, serde_json::json!({"location":task.location.to_string()})); + } + } + } + last_task = newest; + }); + } + }).detach(); +} + +pub(super) const fn route_name(route: &NativeRoute) -> &'static str { + match route { + NativeRoute::NewThread { .. } => "new_thread", + NativeRoute::Thread { .. } => "thread", + NativeRoute::Editor { .. } => "editor", + NativeRoute::Settings { .. } => "settings", + NativeRoute::Onboarding => "onboarding", + } +} + +impl NativeApplication { + pub(super) fn trace_state(&self) { + artisan_tracing::counter!("state", "connection.holds", + "count" => self.service.as_ref().map_or(0, |s| s.holds().status().count), + "sealed" => self.service.as_ref().is_some_and(|s| s.holds().status().sealed) as u8 + ); + if !artisan_tracing::is_recording() { + return; + } + // Service events arrive many times a second; the gates change rarely. + // Emitting only changes keeps them from crowding real history out of + // the bounded recorder window. + let gates = artisan_tracing::json!({ + "route": route_name(self.route()), + "shutdown": self.shutdown_prepared, + "service_stopped": self.service_stopped, + "intake": self.intake_stage.map(|s| format!("{s:?}")), + "switch_phase": self.thread_switch_flight.as_ref().map(|f| format!("{:?}", f.phase)), + "switch_span": self.thread_switch_flight.as_ref().map(|f| f.trace.id()), + "switch_generation": self.thread_switch_flight.as_ref().map(|f| f.generation), + "unsubscribe_pending": self.ordinary_unsubscribe_thread.as_ref().map(ThreadId::as_str), + "pending_thread": self.pending_thread.as_ref().map(ThreadId::as_str), + "selected_thread": self.selected_thread.as_ref().map(ThreadId::as_str), + "awaiting_threads": self.project_navigation.awaiting_threads, + "window_error_present": self.window_error.is_some(), + "navigation_admissible": self.project_picker_action_is_admissible() + }); + let changed = LAST_GATES.with(|last| { + let mut last = last.borrow_mut(); + if last.as_ref() == Some(&gates) { + false + } else { + *last = Some(gates.clone()); + true + } + }); + if changed { + artisan_tracing::instant("state", "navigation.gates", gates); + } + } +} diff --git a/modules/frontend/src/native_application/history_paging.rs b/modules/frontend/src/native_application/history_paging.rs index 3f7ffb3f2..62c3b82a5 100644 --- a/modules/frontend/src/native_application/history_paging.rs +++ b/modules/frontend/src/native_application/history_paging.rs @@ -199,6 +199,8 @@ impl NativeApplication { /// Reads the page of turns before the oldest loaded one, when the reader /// is near the start of what is loaded and more can be held. pub(super) fn request_earlier_turns(&mut self, cx: &mut Context) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "request_earlier_turns"); // A pending jump reads its own, larger page once the one in flight // has landed. if !self.history_paging.near_start @@ -279,6 +281,8 @@ impl NativeApplication { } fn request_turn_work(&mut self, thread_id: ThreadId, turn_id: TurnId, after_sequence: u64) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "request_turn_work"); let command = NativeTransportCommand::ReadConversationHistory { thread_id, part: ConversationHistoryPart::TurnWork { diff --git a/modules/frontend/src/native_application/impl_catalog.rs b/modules/frontend/src/native_application/impl_catalog.rs index 84b62a47b..641ccb153 100644 --- a/modules/frontend/src/native_application/impl_catalog.rs +++ b/modules/frontend/src/native_application/impl_catalog.rs @@ -124,6 +124,28 @@ impl NativeApplication { self.model_selector.read(cx).state().snapshot().clone() } + /// Hands the picker the engines whose account the Forge reports as + /// needing sign-in, so their tabs are greyed out. Unchanged sets do not + /// re-render the picker. + pub(super) fn sync_model_selector_blocked_engines(&mut self, cx: &mut Context) { + let blocked = self + .model_selector + .read(cx) + .state() + .snapshot() + .manifest + .harnesses + .iter() + .filter(|harness| { + crate::native_profile_usage::engine_readiness(&self.profile_usage, &harness.id) + == crate::native_profile_usage::EngineReadinessVerdict::NeedsSignIn + }) + .map(|harness| harness.id.clone()) + .collect::>(); + self.model_selector + .update(cx, |selector, cx| selector.set_blocked_engines(blocked, cx)); + } + /// Returns why a displayed policy's engine cannot run: the Forge's /// readiness reason from the engine's latest usage report, or, when the /// Forge judged the account ready, the catalog's own unavailability. diff --git a/modules/frontend/src/native_application/impl_failure.rs b/modules/frontend/src/native_application/impl_failure.rs index e4bc21628..12b5d8c52 100644 --- a/modules/frontend/src/native_application/impl_failure.rs +++ b/modules/frontend/src/native_application/impl_failure.rs @@ -7,6 +7,12 @@ use super::*; impl NativeApplication { pub(super) fn set_failure(&mut self, failure: ServiceFailure, cx: &mut Context) { + #[cfg(feature = "flight-recorder")] + artisan_tracing::instant!("ui", "application.failure", "stage" => failure.stage.to_string(), "category" => failure.category.to_string()); + #[cfg(feature = "flight-recorder")] + self.trace_state(); + #[cfg(feature = "flight-recorder")] + artisan_tracing::incident!("application.failure"); self.state = NativeViewState::Failure(failure); self.sync_composer_availability(cx); cx.notify(); diff --git a/modules/frontend/src/native_application/impl_host_mounting.rs b/modules/frontend/src/native_application/impl_host_mounting.rs index 5eb42fa26..a84542eff 100644 --- a/modules/frontend/src/native_application/impl_host_mounting.rs +++ b/modules/frontend/src/native_application/impl_host_mounting.rs @@ -11,6 +11,8 @@ impl NativeApplication { snapshot: ConversationSnapshot, cx: &mut Context, ) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "handle_snapshot"); let thread_id = snapshot.thread_id().clone(); // A standalone snapshot cannot identify the subscription generation. // During a switch only the matching fresh-start payload may advance @@ -49,6 +51,8 @@ impl NativeApplication { snapshot: ConversationSnapshot, cx: &mut Context, ) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "dispatch_snapshot"); // Collect echo candidates before the snapshot moves into the host: // matching needs only watched source ids, so a small owned // (message, turn) list suffices and no transcript data is cloned. @@ -117,6 +121,8 @@ impl NativeApplication { reason = "one mount transaction keeps the pending-thread checks and state handoff together" )] pub(super) fn try_mount_pending_thread(&mut self, cx: &mut Context) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "try_mount_pending_thread"); if self.shutdown_prepared { self.pending_thread = None; return; @@ -284,6 +290,8 @@ impl NativeApplication { } pub(super) fn retire_host(&mut self, cx: &mut Context) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "retire_host"); self.remember_switch_listing(); if let Some(thread_id) = self.selected_thread.clone() { self.remember_switch_snapshot_thread(thread_id.clone()); @@ -372,6 +380,8 @@ impl NativeApplication { host: &Entity, cx: &mut Context, ) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "pump_host_boundary"); let mut retried_surface = false; for _ in 0..=CONVERSATION_HOST_MAX_EFFECTS { self.collect_host_effects(host, cx); diff --git a/modules/frontend/src/native_application/impl_lifecycle.rs b/modules/frontend/src/native_application/impl_lifecycle.rs index 44c0dd15e..670598859 100644 --- a/modules/frontend/src/native_application/impl_lifecycle.rs +++ b/modules/frontend/src/native_application/impl_lifecycle.rs @@ -146,6 +146,7 @@ impl NativeApplication { composer_model_scope: None, deferred_composer_policy: None, default_engine_config: None, + pending_model_preference: None, legacy_import: None, #[cfg(test)] test_legacy_preferences: None, @@ -313,6 +314,8 @@ impl NativeApplication { /// Navigates to `route`, retaining history, and rerenders. Readiness /// verdicts the engine pages show arrive pushed by the Forge. pub(super) fn navigate(&mut self, route: NativeRoute, cx: &mut Context) { + #[cfg(feature = "flight-recorder")] + artisan_tracing::instant!("navigation", "route.changed", "route" => super::flight_recorder::route_name(&route)); self.route_history.navigate(route); self.sync_composer_availability(cx); cx.notify(); diff --git a/modules/frontend/src/native_application/impl_pickers.rs b/modules/frontend/src/native_application/impl_pickers.rs index 1fd9aa84b..174cade52 100644 --- a/modules/frontend/src/native_application/impl_pickers.rs +++ b/modules/frontend/src/native_application/impl_pickers.rs @@ -7,6 +7,8 @@ use super::*; impl NativeApplication { pub(super) fn handle_projects(&mut self, listing: &ProjectListing, cx: &mut Context) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "handle_projects"); if self.thread_switch_flight.is_some() { return; } @@ -61,6 +63,8 @@ impl NativeApplication { current: Option, cx: &mut Context, ) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "install_picker"); let picker = cx .new(|picker_cx| ProjectPickerView::new(options, current, ThemeMode::Dark, picker_cx)); let subscription = cx.observe(&picker, |application, picker, cx| { @@ -78,6 +82,8 @@ impl NativeApplication { selected_thread: Option, cx: &mut Context, ) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "install_thread_picker"); let picker = cx.new(|picker_cx| { NativeThreadPicker::new(listing, selected_thread, ThemeMode::Dark, picker_cx) }); @@ -174,6 +180,8 @@ impl NativeApplication { listing: &ThreadListing, cx: &mut Context, ) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "handle_threads"); if self.selected_project.as_ref() != Some(project_id) { return; } diff --git a/modules/frontend/src/native_application/impl_profile_menu.rs b/modules/frontend/src/native_application/impl_profile_menu.rs index f5053db68..e8cd67ab3 100644 --- a/modules/frontend/src/native_application/impl_profile_menu.rs +++ b/modules/frontend/src/native_application/impl_profile_menu.rs @@ -208,10 +208,13 @@ impl NativeApplication { opening: bool, cx: &mut Context, ) { + let duration = if opening { + PROFILE_MENU_OPEN + } else { + PROFILE_MENU_CLOSE + }; self.profile_menu_motion_task = Some(cx.spawn(async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(PICKER_MENU_MOTION_DURATION_MS)) - .await; + cx.background_executor().timer(duration).await; let _ = this.update(cx, |application, cx| { let settled = if opening { application @@ -1262,8 +1265,7 @@ impl NativeApplication { }), ); // The retained exit presentation stays mounted through Closing so - // the shared 100ms fade/slide-out can complete, exactly like the - // model picker popover. + // the fade/slide-out can complete, like the model picker popover. let menu_phase = self.profile_menu_motion.borrow().phase(); if self.profile_menu.is_open() || menu_phase == PickerMenuPhase::Closing { // The dropdown paints from the shared Artisan text tokens rather @@ -1324,11 +1326,13 @@ impl NativeApplication { .left_0() .size_full() }; + // The menu takes exactly its trigger's width, so the two read as + // one column; the minimum only covers a trigger not yet measured. + let menu_width = self.profile_origin.get().size.width.max(px(256.0)); let mut panel = div() .id("artisan-desktop-profile-menu") .debug_selector(|| "artisan-desktop-profile-menu".to_string()) - .min_w(px(256.0)) - .max_w(px(352.0)) + .w(menu_width) .rounded(RadiusTokens::value(RadiusStep::X2l)) .backdrop_blur(glass_blur_radius(GlassStrength::Quiet)) .bg(glass_foreground_base(&self.theme)) @@ -1571,11 +1575,18 @@ impl NativeApplication { .anchor(gpui::Anchor::BottomLeft) .position(self.profile_origin.get().origin) .offset(gpui::point(px(0.0), px(-4.0))) - .child(animate_picker_menu( + .child(animate_picker_menu_timed( panel, Rc::clone(&self.profile_menu_motion), motion, "profile", + // The panel sits above the selector, aligned to its + // left edge: it grows from that bottom-left corner. + PickerMenuEntrance::Scale { + origin: gpui::point(0.0, 1.0), + }, + PROFILE_MENU_OPEN, + PROFILE_MENU_CLOSE, )), )); } diff --git a/modules/frontend/src/native_application/impl_recent_threads.rs b/modules/frontend/src/native_application/impl_recent_threads.rs index d4bcaf734..ab0d36cb1 100644 --- a/modules/frontend/src/native_application/impl_recent_threads.rs +++ b/modules/frontend/src/native_application/impl_recent_threads.rs @@ -21,6 +21,8 @@ pub(super) const RECENT_OPEN_WAIT: Duration = Duration::from_secs(10); /// A chosen recent thread that has not opened yet. pub(super) struct AwaitedOpen { + #[cfg(feature = "flight-recorder")] + _trace: artisan_tracing::Span, pub(super) project: ProjectId, pub(super) thread: ThreadId, /// Whether the projects were listed again for it. @@ -54,6 +56,8 @@ impl NativeApplication { listing: RecentThreadListing, cx: &mut Context, ) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "apply_recent_threads"); let threads = &mut self.sidebar_threads; if threads.recent.as_ref() != Some(&listing) { threads.recent = Some(listing); @@ -159,7 +163,11 @@ impl NativeApplication { thread: ThreadId, cx: &mut Context, ) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "open_recent_thread"); self.sidebar_threads.awaited_open = Some(AwaitedOpen { + #[cfg(feature = "flight-recorder")] + _trace: artisan_tracing::span!("navigation", "recent_thread.wait", "project_id" => project.as_str(), "thread_id" => thread.as_str()), project, thread, catalog_requested: false, @@ -199,6 +207,12 @@ impl NativeApplication { } fn fail_recent_open(&mut self, awaited: &AwaitedOpen, reason: &str, cx: &mut Context) { + #[cfg(feature = "flight-recorder")] + artisan_tracing::instant!("navigation", "recent_thread.failed", "thread_id" => awaited.thread.as_str(), "reason" => reason); + #[cfg(feature = "flight-recorder")] + self.trace_state(); + #[cfg(feature = "flight-recorder")] + artisan_tracing::incident!("recent_thread.failed"); let title = self .sidebar_threads .recent @@ -226,6 +240,8 @@ impl NativeApplication { thread: ThreadId, cx: &mut Context, ) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "open_known_recent_thread"); if self.selected_project.as_ref() != Some(&project) { self.enter_project(project, true, Some(thread), cx); } else if self.thread_is_listed(&thread) { @@ -377,6 +393,7 @@ mod tests { RecentThread { thread, subtitle: DisplayName::parse("owner/repo").unwrap(), + project_icon: Default::default(), } } diff --git a/modules/frontend/src/native_application/impl_render.rs b/modules/frontend/src/native_application/impl_render.rs index 847873b40..0d003a0d8 100644 --- a/modules/frontend/src/native_application/impl_render.rs +++ b/modules/frontend/src/native_application/impl_render.rs @@ -7,7 +7,10 @@ use super::*; impl Render for NativeApplication { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "application.render"); self.sync_composer_controls(cx); + self.sync_model_selector_blocked_engines(cx); self.sync_profile_actions(); // The sidebar fills its shell slot and renders only when notified, // so a transcript or composer frame reuses its previous paint. @@ -27,6 +30,25 @@ impl Render for NativeApplication { .and_then(|screen| screen.read(cx).visible_inspector_width()), _ => None, }; + // The meter shows the same validated reading as the composer's ring: + // the controls snapshot was synced at the top of this render. + let context_meter = match self.route() { + NativeRoute::Thread { .. } => { + let snapshot = self.composer_controls.read(cx).snapshot(); + snapshot + .context_usage + .as_ref() + .and_then(|usage| usage.presentation(snapshot.run_id.as_deref())) + .map(|presentation| { + crate::native_context_usage::render_context_meter( + &presentation, + ArtisanTheme::for_mode(ThemeMode::Dark), + ) + .into_any_element() + }) + } + _ => None, + }; let brand = self.desktop_brand(cx).into_any_element(); let header = self .desktop_header_cluster() @@ -42,6 +64,7 @@ impl Render for NativeApplication { sidebar, body, inspector_width, + context_meter, window.is_maximized(), ); div() diff --git a/modules/frontend/src/native_application/impl_route_surface.rs b/modules/frontend/src/native_application/impl_route_surface.rs index bb2a2053e..f44154a34 100644 --- a/modules/frontend/src/native_application/impl_route_surface.rs +++ b/modules/frontend/src/native_application/impl_route_surface.rs @@ -18,6 +18,8 @@ impl NativeApplication { window: &mut Window, cx: &mut Context, ) -> AnyElement { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "route_surface"); match self.route().clone() { NativeRoute::Onboarding => { if self.onboarding_screen.is_none() { @@ -250,8 +252,9 @@ impl NativeApplication { /// project's `QueryProjectRepository` reply. /// - Checklist is the thread's latest plan update from its engine /// observations. - /// - Agents are the subagents the thread's current run has started, read - /// from the same observations ([`thread_agent_rows`]). + /// - Agents are the subagents the thread's live run has started, read + /// from the same observations ([`thread_agent_rows`]); a settled run + /// lists none. /// /// Every fact is fenced to the route's own `project` and `thread`: /// repository facts retained (or requested) for another project, and @@ -303,7 +306,12 @@ impl NativeApplication { let plan = observations .and_then(EngineObservationState::latest_plan) .map_or(&[][..], |plan| plan.entries().as_slice()); - let agents = observations.map(thread_agent_rows).unwrap_or_default(); + // Agents belong to a live run: once it settles they are done and the + // section clears instead of listing finished work indefinitely. + let agents = observations + .filter(|_| self.run_controls.is_live_for(thread)) + .map(thread_agent_rows) + .unwrap_or_default(); screen.update(cx, |screen, screen_cx| { let mut changed = screen.set_project_label(project_label); changed |= screen.set_environment(environment); diff --git a/modules/frontend/src/native_application/impl_service_events.rs b/modules/frontend/src/native_application/impl_service_events.rs index c1afa7aaf..7f62ace19 100644 --- a/modules/frontend/src/native_application/impl_service_events.rs +++ b/modules/frontend/src/native_application/impl_service_events.rs @@ -7,6 +7,8 @@ use super::*; impl NativeApplication { pub(super) fn poll_service(&mut self, cx: &mut Context) -> bool { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "service.poll"); self.drain_answer_dispatches(cx); let Some(service) = self.service.clone() else { return false; @@ -24,6 +26,8 @@ impl NativeApplication { } } self.observation_replay_deferred = true; + #[cfg(feature = "flight-recorder")] + artisan_tracing::counter!("ui", "service.event_batch", "count" => events.len()); for event in events { self.handle_service_event(event, cx); } @@ -50,6 +54,8 @@ impl NativeApplication { self.try_mount_pending_thread(cx); self.sync_composer_availability(cx); self.sync_composer_questionnaires(cx); + #[cfg(feature = "flight-recorder")] + self.trace_state(); !self.service_stopped } @@ -65,6 +71,8 @@ impl NativeApplication { if self.shutdown_prepared || self.service_stopped { return; } + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "service.event", "event" => event.trace_name()); match event { NativeTransportEvent::ComposerState(event) => { self.handle_composer_state_event(event, cx); @@ -728,6 +736,8 @@ impl NativeApplication { } pub(super) fn handle_patch_batch(&mut self, batch: &PatchBatch, cx: &mut Context) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "handle_patch_batch"); if self.thread_switch_flight.is_some() { self.remember_patch_ids(batch); return; @@ -862,6 +872,8 @@ impl NativeApplication { /// projects until the thread's history is current, so the replayed /// history lands as one projection rather than one per page. pub(super) fn replay_observation_activity(&mut self, cx: &mut Context) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "replay_observation_activity"); self.observation_replay_pending = false; if !self.selected_history_current() { return; @@ -1101,6 +1113,8 @@ impl NativeApplication { } pub(super) fn begin_thread_switch(&mut self, target_thread: ThreadId, cx: &mut Context) { + #[cfg(feature = "flight-recorder")] + artisan_tracing::instant!("navigation", "thread_switch.requested", "thread_id" => target_thread.as_str()); if self.shutdown_prepared || self.service_stopped || self.intake_stage.is_some() @@ -1167,6 +1181,9 @@ impl NativeApplication { self.remember_active_subscription_request(); self.pending_thread = None; self.thread_switch_flight = Some(ThreadSwitchFlight { + #[cfg(feature = "flight-recorder")] + trace: artisan_tracing::span!("navigation", "thread_switch", "generation" => generation, + "source_thread" => source_thread.as_str(), "target_thread" => target_thread.as_ref().map(ThreadId::as_str)), source_thread, target_thread, generation, @@ -1404,6 +1421,8 @@ impl NativeApplication { } pub(super) fn retire_host_after_switch_stop(&mut self, cx: &mut Context) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "retire_host_after_switch_stop"); self.retain_message_flight(cx); self.clear_message_presentation(); self.pending_snapshot = None; @@ -1581,6 +1600,8 @@ impl NativeApplication { thread_id: ThreadId, cx: &mut Context, ) { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("ui", "handle_intake_ready"); if self.thread_switch_flight.is_some() { return; } diff --git a/modules/frontend/src/native_application/impl_sidebar_threads.rs b/modules/frontend/src/native_application/impl_sidebar_threads.rs index 1e62a5158..6c628c82e 100644 --- a/modules/frontend/src/native_application/impl_sidebar_threads.rs +++ b/modules/frontend/src/native_application/impl_sidebar_threads.rs @@ -1,14 +1,18 @@ -//! The sidebar's recent threads across every project, grouped by age. +//! The sidebar's recent threads across every project, as an inbox. //! //! Rows are the Forge's recent-threads listing (`impl_recent_threads.rs`): //! the title, and beneath it the repository or project the thread works -//! in. The Editor only sorts them into age groups -//! ([`crate::recent_thread_groups`]) and regroups them when a row crosses an -//! age boundary. Choosing a row opens the thread in its own project. +//! in. Only threads with something unseen show outright; running threads +//! collapse into `Working` and read idle ones into `History`, which keeps +//! its age groups inside ([`crate::recent_thread_groups`]). Rows regroup +//! when one crosses an age boundary. Choosing a row opens the thread in its +//! own project. use super::*; -use crate::desktop_shell::desktop_section_label; -use crate::recent_thread_groups::{RecentThreadGroup, group_recent_threads, next_regrouping}; +use crate::desktop_shell::{DESKTOP_COLUMN_ROW_INSET_PX, desktop_section_label}; +use crate::recent_thread_groups::{ + RecentThreadGroup, SidebarSections, next_regrouping, section_recent_threads, +}; use artisan_domain::{ RecentThread, RecentThreadListing, ThreadAttention, ThreadSummary, UnixMillis, }; @@ -28,6 +32,61 @@ const SIDEBAR_WORKING_PULSE: Duration = Duration::from_millis(1_600); /// The dimmest the working dot gets mid-pulse. const SIDEBAR_WORKING_PULSE_FLOOR: f32 = 0.4; +/// A collapsible sidebar group. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(super) enum SidebarGroup { + /// Threads the Forge is running with nothing waiting on the reader. + Working, + /// Read, idle threads, in age groups. + History, +} + +impl SidebarGroup { + const fn label(self) -> &'static str { + match self { + Self::Working => "Working", + Self::History => "History", + } + } + + const fn id(self) -> &'static str { + match self { + Self::Working => "working", + Self::History => "history", + } + } +} + +/// One collapsible group's disclosure: collapsed until the reader opens it. +#[derive(Default)] +struct SidebarGroupDisclosure { + open: bool, + /// When the reader last toggled it, for the chevron flip and reveal. + toggled: Option, + focus: Option, +} + +impl SidebarGroupDisclosure { + /// Eased progress of the latest toggle, `None` once settled. + fn progress(&self, now: Instant, reduced: bool) -> Option { + if reduced { + return None; + } + let elapsed = now.saturating_duration_since(self.toggled?); + let duration = MotionDuration::Fast.as_duration(); + (elapsed < duration).then(|| { + #[expect( + clippy::cast_possible_truncation, + reason = "the shared easing curve samples in f64 and feeds f32 rotation and opacity; the narrowing is the intended precision" + )] + let eased = MotionCurve::SmoothOut + .sample(elapsed.as_secs_f64() / duration.as_secs_f64()) + as f32; + eased + }) + } +} + /// What a row's trailing dot says about its thread: whether it needs the /// reader, never decoration. At most one state shows. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -45,16 +104,16 @@ pub(super) enum SidebarThreadIndicator { impl SidebarThreadIndicator { /// The state a thread's row shows, if any. /// - /// Waiting on the reader outranks the work it blocks. An unread outcome - /// never shows on the thread the reader has open: they are looking at - /// it, and leaving it marks it read. - pub(super) fn of(thread: &ThreadSummary, open: bool) -> Option { + /// Waiting on the reader outranks the work it blocks. The open + /// thread's unread outcome keeps showing until the reader leaves it + /// (see [`SidebarThreadsState::held_unread`]). + pub(super) fn of(thread: &ThreadSummary) -> Option { match thread.attention { ThreadAttention::AwaitingAnswer => Some(Self::Awaiting), _ if thread.has_active_work => Some(Self::Working), - ThreadAttention::Failed if !open => Some(Self::Failed), - ThreadAttention::Finished if !open => Some(Self::Finished), - ThreadAttention::None | ThreadAttention::Failed | ThreadAttention::Finished => None, + ThreadAttention::Failed => Some(Self::Failed), + ThreadAttention::Finished => Some(Self::Finished), + ThreadAttention::None => None, } } @@ -82,10 +141,16 @@ impl SidebarThreadIndicator { #[derive(Default)] pub(super) struct SidebarThreadsState { focus: HashMap, + project_icons: super::project_icon::ProjectIconCache, hover: Rc>, bounds: Rc>>>, selection: SidebarSelectionFade, selection_frame_pending: bool, + /// The open thread and the unread outcome it had when opened or that + /// arrived while open. The Forge marks a thread read the moment it + /// opens; the row keeps its dot and its place among unread rows until + /// the reader leaves the thread, so opening never pulls it away. + held_unread: Option<(ThreadId, ThreadAttention)>, /// The recent threads the Forge served or last pushed. pub(super) recent: Option, /// Advances with every new recent-threads list. @@ -103,6 +168,8 @@ pub(super) struct SidebarThreadsState { pub(super) open_after_refresh: Option, /// A chosen recent thread waiting for its project or a settled view. pub(super) awaited_open: Option, + /// The `Working` and `History` disclosures. + groups: HashMap, } /// Two sequential halves of the transitions-dev icon-swap duration. @@ -253,6 +320,7 @@ impl NativeApplication { cx: &mut Context, ) -> Stateful
{ let listing = self.sidebar_threads.recent.clone().unwrap_or_default(); + self.sidebar_threads.project_icons.retain(&listing); let now = wall_clock(); self.schedule_regroup(&listing, now, cx); let ids = listing @@ -261,15 +329,16 @@ impl NativeApplication { .map(|row| row.thread.thread_id.clone()) .collect::>(); self.animate_sidebar_selection(&ids, window, cx); + let listing = self.hold_open_unread(listing); self.sidebar_threads.focus.retain(|id, _| ids.contains(id)); - let hover_ids = ids - .iter() - .map(|id| id.as_str().to_owned()) - .collect::>(); + // The pill reads its target while this render builds it, so stale + // targets clear before the pill, against the rows that will paint. + let sections = section_recent_threads(&listing, now); + let rendered = self.sidebar_rendered_rows(§ions); self.sidebar_threads .hover .borrow_mut() - .clear_if_missing(&hover_ids); + .clear_if_missing(&rendered); let bounds_state = Rc::clone(&self.sidebar_threads.bounds); let probe = canvas( |_, _, _| {}, @@ -284,7 +353,7 @@ impl NativeApplication { .top_0() .left_0() .size_full(); - let mut container = div() + let container = div() .id("artisan-sidebar-threads") .relative() .flex_1() @@ -311,12 +380,246 @@ impl NativeApplication { px(6.0), cx.reduce_motion(), )); - for group in &group_recent_threads(&listing, now) { - container = container.child(self.desktop_sidebar_thread_group(group, cx)); + self.desktop_sidebar_sections(container, §ions, window, cx) + } + + /// Presents the open thread with the unread outcome it held when the + /// reader opened it, until they leave it. Starting a new run there acts + /// on the outcome and releases it. + fn hold_open_unread(&mut self, listing: RecentThreadListing) -> RecentThreadListing { + let open = self.sidebar_threads.selection.target.clone(); + let held = &mut self.sidebar_threads.held_unread; + if held.as_ref().map(|(id, _)| id) != open.as_ref() { + *held = open.map(|id| (id, ThreadAttention::None)); + } + let Some((id, attention)) = held.as_mut() else { + return listing; + }; + let Some(row) = listing + .threads() + .iter() + .find(|row| &row.thread.thread_id == id) + else { + return listing; + }; + let current = &row.thread; + if current.has_active_work || current.attention == ThreadAttention::AwaitingAnswer { + *attention = ThreadAttention::None; + return listing; + } + if current.attention != ThreadAttention::None { + *attention = current.attention; + return listing; + } + if *attention == ThreadAttention::None { + return listing; + } + let held = *attention; + let rows = listing + .threads() + .iter() + .cloned() + .map(|mut row| { + if &row.thread.thread_id == id { + row.thread.attention = held; + } + row + }) + .collect(); + RecentThreadListing::new(rows).unwrap_or(listing) + } + + /// The thread rows this render builds: unread rows, plus the rows of + /// each open group. A row hidden in a collapsed group, or moved into + /// one, still has its thread in the listing but no row to hover. + fn sidebar_rendered_rows(&self, sections: &SidebarSections<'_>) -> Vec { + let mut rows = sections.unread.clone(); + if self.sidebar_group_open(SidebarGroup::Working) { + rows.extend(§ions.working); + } + if self.sidebar_group_open(SidebarGroup::History) { + rows.extend(sections.history.iter().flat_map(|group| &group.threads)); + } + rows.iter() + .map(|row| row.thread.thread_id.as_str().to_owned()) + .collect() + } + + /// Appends the unread rows, then the `Working` and `History` groups, + /// each only when it holds a row. + fn desktop_sidebar_sections( + &mut self, + mut container: Stateful
, + sections: &SidebarSections<'_>, + window: &mut Window, + cx: &mut Context, + ) -> Stateful
{ + if !sections.unread.is_empty() { + let mut unread = div() + .w_full() + .flex_none() + .flex() + .flex_col() + .gap(px(2.0)) + .debug_selector(|| "artisan-sidebar-threads-unread".to_owned()); + for thread in §ions.unread { + unread = unread.child(self.desktop_sidebar_thread(thread, cx)); + } + container = container.child(unread); + } + if !sections.working.is_empty() { + let rows = if self.sidebar_group_open(SidebarGroup::Working) { + sections + .working + .iter() + .map(|thread| self.desktop_sidebar_thread(thread, cx).into_any_element()) + .collect() + } else { + Vec::new() + }; + container = container.child(self.desktop_sidebar_collapsible( + SidebarGroup::Working, + sections.working.len(), + rows, + window, + cx, + )); + } + if !sections.history.is_empty() { + let groups = if self.sidebar_group_open(SidebarGroup::History) { + sections + .history + .iter() + .map(|group| { + self.desktop_sidebar_thread_group(group, cx) + .into_any_element() + }) + .collect() + } else { + Vec::new() + }; + container = container.child(self.desktop_sidebar_collapsible( + SidebarGroup::History, + sections.history_len(), + groups, + window, + cx, + )); } container } + fn sidebar_group_open(&self, group: SidebarGroup) -> bool { + self.sidebar_threads + .groups + .get(&group) + .is_some_and(|disclosure| disclosure.open) + } + + fn toggle_sidebar_group(&mut self, group: SidebarGroup, cx: &mut Context) { + let disclosure = self.sidebar_threads.groups.entry(group).or_default(); + disclosure.open = !disclosure.open; + disclosure.toggled = Some(Instant::now()); + cx.notify(); + } + + /// One collapsible group: a header in the age-group label style with a + /// count and a chevron that turns down when open, then its content. + /// + /// Opening flips the chevron and fades the content in over the fast + /// motion token; closing flips the chevron and removes the content. + fn desktop_sidebar_collapsible( + &mut self, + group: SidebarGroup, + count: usize, + content: Vec, + window: &mut Window, + cx: &mut Context, + ) -> Div { + let reduced = cx.reduce_motion(); + let disclosure = self.sidebar_threads.groups.entry(group).or_default(); + let open = disclosure.open; + let progress = disclosure.progress(Instant::now(), reduced); + let focus = disclosure + .focus + .get_or_insert_with(|| cx.focus_handle()) + .clone(); + if progress.is_some() { + window.request_animation_frame(); + } + let settled = if open { 1.0 } else { 0.0 }; + let turn = progress.map_or(settled, |eased| if open { eased } else { 1.0 - eased }); + let selector = format!("artisan-sidebar-threads-{}", group.id()); + let header_selector = format!("{selector}-header"); + let chevron_selector = format!("{selector}-chevron"); + let muted = self.theme.colors.muted_foreground.to_paint(); + let focus_fill = self.theme.colors.muted.to_paint(); + let label = SharedString::from(format!("{}, {count} threads", group.label())); + let header = div() + .id(SharedString::from(header_selector.clone())) + .track_focus(&focus) + .tab_index(0) + .role(gpui::Role::Button) + .aria_label(label) + .aria_expanded(open) + .w_full() + .flex() + .items_center() + .gap(px(4.0)) + .px(px(DESKTOP_COLUMN_ROW_INSET_PX)) + .pb(self.theme.spacing.steps(1.0)) + .rounded(px(6.0)) + .text_size(self.theme.typography.label_text) + .text_color(muted) + .cursor_pointer() + .focus_visible(move |style| style.bg(focus_fill)) + .debug_selector(move || header_selector.clone()) + .child(div().flex_none().child(group.label())) + .child(div().flex_none().opacity(0.7).child(count.to_string())) + .child( + gpui::svg() + .path(AssetId::TABLER_CHEVRON_RIGHT.as_str()) + .size(px(12.0)) + .flex_shrink_0() + .text_color(muted) + .with_transformation(gpui::Transformation::rotate(gpui::radians( + turn * std::f32::consts::FRAC_PI_2, + ))) + .debug_selector(move || chevron_selector.clone()), + ) + .on_click(cx.listener(move |app, _, window, cx| { + window.focus(&focus, cx); + app.toggle_sidebar_group(group, cx); + })) + .on_key_down(cx.listener(move |app, event: &gpui::KeyDownEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + app.toggle_sidebar_group(group, cx); + } + })); + let mut section = div() + .w_full() + .flex_none() + .flex() + .flex_col() + .gap(px(2.0)) + .debug_selector(move || selector.clone()) + .child(header); + if open && !content.is_empty() { + let body = div() + .w_full() + .flex() + .flex_col() + .gap(self.theme.spacing.steps(3.0)) + .children(content); + section = section.child(match progress { + Some(eased) => body.opacity(eased), + None => body, + }); + } + section + } + fn desktop_sidebar_thread_group( &mut self, group: &RecentThreadGroup<'_>, @@ -331,11 +634,13 @@ impl NativeApplication { .flex() .flex_col() .gap(px(2.0)) - .debug_selector(move || selector.clone()) - .child( + .debug_selector(move || selector.clone()); + if group.age != crate::recent_thread_groups::RecentThreadAge::LastDay { + rows = rows.child( desktop_section_label(&self.theme, group.age.label()) .debug_selector(move || header_selector.clone()), ); + } for thread in &group.threads { rows = rows.child(self.desktop_sidebar_thread(thread, cx)); } @@ -367,8 +672,12 @@ impl NativeApplication { ); let title = SharedString::from(thread.title.as_str().to_owned()); let subtitle = SharedString::from(row.subtitle.as_str().to_owned()); - let open = self.sidebar_threads.selection.target.as_ref() == Some(&thread.thread_id); - let indicator = SidebarThreadIndicator::of(thread, open); + let project_icon = self.sidebar_threads.project_icons.render( + &thread.project_id, + &row.project_icon, + &self.theme, + ); + let indicator = SidebarThreadIndicator::of(thread); let description = match indicator { Some(indicator) => SharedString::from(format!("{subtitle}, {}", indicator.label())), None => subtitle.clone(), @@ -435,11 +744,15 @@ impl NativeApplication { .child( div() .w_full() - .truncate() + .min_w(px(0.0)) + .flex() + .items_center() + .gap(px(5.0)) .text_size(self.theme.typography.label_text) .text_color(self.theme.colors.muted_foreground.to_paint()) .debug_selector(move || subtitle_selector.clone()) - .child(subtitle), + .child(project_icon) + .child(div().flex_1().min_w(px(0.0)).truncate().child(subtitle)), ), ) // A thread that needs the reader keeps its prominence without @@ -513,32 +826,27 @@ mod tests { updated_at: UnixMillis::from_millis(1), }; let of = SidebarThreadIndicator::of; - assert_eq!(of(&thread(false, ThreadAttention::None), false), None); + assert_eq!(of(&thread(false, ThreadAttention::None)), None); assert_eq!( - of(&thread(true, ThreadAttention::None), false), + of(&thread(true, ThreadAttention::None)), Some(SidebarThreadIndicator::Working) ); - // The reader's turn outranks the work it blocks, open or not. - for open in [false, true] { - assert_eq!( - of(&thread(true, ThreadAttention::AwaitingAnswer), open), - Some(SidebarThreadIndicator::Awaiting) - ); - } + // The reader's turn outranks the work it blocks. + assert_eq!( + of(&thread(true, ThreadAttention::AwaitingAnswer)), + Some(SidebarThreadIndicator::Awaiting) + ); assert_eq!( - of(&thread(false, ThreadAttention::Finished), false), + of(&thread(false, ThreadAttention::Finished)), Some(SidebarThreadIndicator::Finished) ); assert_eq!( - of(&thread(false, ThreadAttention::Failed), false), + of(&thread(false, ThreadAttention::Failed)), Some(SidebarThreadIndicator::Failed) ); - // The thread on screen is being read: no unread outcome on it. - assert_eq!(of(&thread(false, ThreadAttention::Finished), true), None); - assert_eq!(of(&thread(false, ThreadAttention::Failed), true), None); // A new run after an unread outcome reads as working. assert_eq!( - of(&thread(true, ThreadAttention::Finished), false), + of(&thread(true, ThreadAttention::Finished)), Some(SidebarThreadIndicator::Working) ); } diff --git a/modules/frontend/src/native_application/impl_surfaces.rs b/modules/frontend/src/native_application/impl_surfaces.rs index f46e53d71..26e9ab709 100644 --- a/modules/frontend/src/native_application/impl_surfaces.rs +++ b/modules/frontend/src/native_application/impl_surfaces.rs @@ -242,8 +242,8 @@ impl NativeApplication { pub(super) fn desktop_brand(&self, cx: &Context) -> Div { let brand = div() .flex() - .items_center() - .gap(px(8.0)) + .flex_col() + .items_start() .min_w(px(0.0)) .overflow_hidden() .child( @@ -256,10 +256,10 @@ impl NativeApplication { .debug_selector(|| "artisan-brand-home".to_owned()) .flex_shrink_0() .text_size(px(20.0)) - .font_family("Artisan Neo") - .font_weight(FontWeight::SEMIBOLD) - // -0.05em tracking at 20px: 20 * -0.05 = -1.0px. - .letter_spacing(px(-1.0)) + .font_family("Test Founders Grotesk") + .font_weight(FontWeight::BOLD) + // -0.025em tracking at 20px matches the Founders specimen. + .letter_spacing(px(-0.5)) .text_color(self.desktop_theme.foreground) .child("Artisan Editor"), ); @@ -271,6 +271,7 @@ impl NativeApplication { .truncate() .text_size(px(11.0)) .line_height(px(16.0)) + .font_family(self.theme.typography.mono.family) .text_color(self.theme.colors.muted_foreground.to_paint()) .child(badge), ), diff --git a/modules/frontend/src/native_application/preferences.rs b/modules/frontend/src/native_application/preferences.rs index 4c3261c3b..19da22669 100644 --- a/modules/frontend/src/native_application/preferences.rs +++ b/modules/frontend/src/native_application/preferences.rs @@ -74,6 +74,31 @@ impl NativeApplication { // Without the Forge's record the catalog order stands; later // navigation is still reported. PreferencesEvent::Loaded(Err(_)) => {} + PreferencesEvent::ModelSaved { request_id, result } => { + if self + .pending_model_preference + .as_ref() + .map(|save| &save.request_id) + != Some(&request_id) + { + return; + } + match result { + Ok(preferences) => { + self.pending_model_preference = None; + self.apply_forge_preferences(&preferences, cx); + } + Err(_) => { + self.composer_model_run_error = Some( + "Your model preference could not be saved. Retry the model selection." + .to_owned(), + ); + self.sync_composer_model_policy(cx); + self.sync_composer_controls(cx); + cx.notify(); + } + } + } PreferencesEvent::LegacyImported(Ok(imported)) => { if let Some(legacy) = self.legacy_import.take() { legacy.retire(); diff --git a/modules/frontend/src/native_application/project_icon.rs b/modules/frontend/src/native_application/project_icon.rs new file mode 100644 index 000000000..bb7d051b6 --- /dev/null +++ b/modules/frontend/src/native_application/project_icon.rs @@ -0,0 +1,83 @@ +//! Compact project identity mark beside a recent thread's project label. + +use artisan_domain::{ProjectId, RecentProjectIcon, RecentThreadListing}; +use gpui::{Image, ImageFormat, ObjectFit, StyledImage as _, img}; +use std::sync::Arc; + +use super::*; +use crate::repository_mark::RepositoryHost; + +#[derive(Default)] +pub(super) struct ProjectIconCache { + images: HashMap, Arc)>, +} + +impl ProjectIconCache { + pub(super) fn retain(&mut self, listing: &RecentThreadListing) { + self.images.retain(|id, _| { + listing + .threads() + .iter() + .any(|row| &row.thread.project_id == id) + }); + } + + pub(super) fn render( + &mut self, + id: &ProjectId, + project: &RecentProjectIcon, + theme: &ArtisanTheme, + ) -> AnyElement { + let Some(host) = &project.host else { + return div() + .size(px(14.0)) + .flex_shrink_0() + .child(icon(IconStyle::resolve( + *theme, + AssetId::TABLER_FOLDER, + IconSize::Compact, + IconTint::Muted, + ))) + .into_any_element(); + }; + if !project.png.is_empty() { + let cached = self.images.entry(id.clone()).or_insert_with(|| { + ( + project.png.clone(), + Arc::new(Image::from_bytes(ImageFormat::Png, project.png.to_vec())), + ) + }); + if cached.0 != project.png { + *cached = ( + project.png.clone(), + Arc::new(Image::from_bytes(ImageFormat::Png, project.png.to_vec())), + ); + } + let image = Arc::clone(&cached.1); + return div() + .size(px(14.0)) + .flex_shrink_0() + .child( + img(image) + .size(px(14.0)) + .rounded(px(3.0)) + .object_fit(ObjectFit::Contain), + ) + .into_any_element(); + } + let host = host + .as_str() + .parse::() + .unwrap_or(RepositoryHost::Unknown); + let mark = repository_mark_for(Some(host)); + let mut glyph = asset_glyph(repository_logo_asset(mark.logo)).size(px(14.0)); + if mark.monochrome { + glyph = glyph.text_color(theme.colors.muted_foreground.to_paint()); + } + div() + .size(px(14.0)) + .flex_shrink_0() + .child(glyph) + .into_any_element() + } +} diff --git a/modules/frontend/src/native_application/selectors.rs b/modules/frontend/src/native_application/selectors.rs index aa6c381b1..42f4b6549 100644 --- a/modules/frontend/src/native_application/selectors.rs +++ b/modules/frontend/src/native_application/selectors.rs @@ -7,6 +7,8 @@ use std::time::Duration; +use artisan_ui::motion::MotionDuration; + /// The one shipping application title. pub(crate) const WINDOW_TITLE: &str = "Artisan Editor"; @@ -83,6 +85,12 @@ pub(super) const PROFILE_MENU_VIEWPORT_MARGIN_PX: f32 = 8.0; /// matching the source content `sideOffset` and the anchored offset applied /// when placing the panel. pub(super) const PROFILE_MENU_ANCHOR_GAP_PX: f32 = 4.0; +/// The profile menu's pop-in, the transitions-dev dropdown open +/// (`--dropdown-open-dur`, `--duration-fast`). +pub(super) const PROFILE_MENU_OPEN: Duration = MotionDuration::Fast.as_duration(); +/// The profile menu's exit, the dropdown close (`--dropdown-close-dur`, +/// `--duration-quick`): out faster than in. +pub(super) const PROFILE_MENU_CLOSE: Duration = MotionDuration::Quick.as_duration(); pub(super) const MAX_RETAINED_SWITCH_REQUEST_IDS: usize = 8; pub(super) const MAX_RETAINED_SWITCH_PATCH_IDS: usize = 256; diff --git a/modules/frontend/src/native_application/state.rs b/modules/frontend/src/native_application/state.rs index eaf1ad944..1e27d44f7 100644 --- a/modules/frontend/src/native_application/state.rs +++ b/modules/frontend/src/native_application/state.rs @@ -86,6 +86,8 @@ impl NativeMessageFailure { /// corresponding admission receipt. The service owns request-ID minting; /// this flight only records the receipt that advanced its current phase. pub(super) struct ThreadSwitchFlight { + #[cfg(feature = "flight-recorder")] + pub(super) trace: artisan_tracing::Span, pub(super) source_thread: ThreadId, pub(super) target_thread: Option, pub(super) generation: u64, diff --git a/modules/frontend/src/native_application/tests/navigation.rs b/modules/frontend/src/native_application/tests/navigation.rs index 07eedd34c..2490890bc 100644 --- a/modules/frontend/src/native_application/tests/navigation.rs +++ b/modules/frontend/src/native_application/tests/navigation.rs @@ -1086,6 +1086,8 @@ fn every_project_action_fence_disables_the_native_rail_action(cx: &mut TestAppCo application.intake_stage = None; application.thread_switch_flight = Some(ThreadSwitchFlight { + #[cfg(feature = "flight-recorder")] + trace: artisan_tracing::span!("navigation", "test.thread_switch"), source_thread: ThreadId::parse("rail-source").expect("thread"), target_thread: Some(ThreadId::parse("rail-target").expect("thread")), generation: 1, diff --git a/modules/frontend/src/native_application/tests/new_thread_send.rs b/modules/frontend/src/native_application/tests/new_thread_send.rs index bc2712002..d3c73ba11 100644 --- a/modules/frontend/src/native_application/tests/new_thread_send.rs +++ b/modules/frontend/src/native_application/tests/new_thread_send.rs @@ -256,6 +256,12 @@ fn a_model_chosen_in_the_new_task_draft_stays_with_it_and_is_what_send_carries( let chosen = catalog.selection_policy_for_model("codex-luna").unwrap(); let later = catalog.selection_policy_for_model("codex-sol").unwrap(); application.handle_composer_policy_selection(&chosen, cx); + let saved = commands.borrow().iter().find_map(|command| match command { + NativeTransportCommand::Preferences(crate::native_transport_service::PreferencesCommand::SaveModel(save)) => Some(save.clone()), + _ => None, + }).expect("a picker choice saves preferences before a new task is sent"); + assert_eq!(saved.source, artisan_domain::ModelPreferenceSource::User); + assert_eq!(saved.selection, crate::picker_selection::selection_for_policy(&chosen).unwrap()); assert!( !commands.borrow().iter().any(|command| matches!( command, @@ -298,3 +304,91 @@ fn a_model_chosen_in_the_new_task_draft_stays_with_it_and_is_what_send_carries( "the send carries the model shown when Send was pressed" ); } + +#[gpui::test] +fn preference_failures_match_the_latest_choice_and_retry_without_a_thread(cx: &mut TestAppContext) { + use crate::native_transport_service::{ + PreferencesCommand, PreferencesEvent, ServiceFailure, ServiceFailureCategory, + ServiceFailureStage, + }; + let (view, cx) = cx.add_window_view(signed_in_test_application); + let (sink, commands) = command_sink([]); + cx.update(|_, app| { + view.update(app, |application, cx| { + new_thread_screen(application, cx, "keep this draft", sink); + let catalog = application.served_catalog(cx); + let first = catalog.selection_policy_for_model("codex-luna").unwrap(); + let latest = catalog.selection_policy_for_model("codex-sol").unwrap(); + application.handle_composer_policy_selection(&first, cx); + let old_id = application + .pending_model_preference + .as_ref() + .unwrap() + .request_id + .clone(); + application.handle_composer_policy_selection(&latest, cx); + let pending = application.pending_model_preference.clone().unwrap(); + let failure = ServiceFailure { + stage: ServiceFailureStage::Request, + category: ServiceFailureCategory::Unavailable, + }; + application.handle_preferences_event( + PreferencesEvent::ModelSaved { + request_id: old_id, + result: Err(failure), + }, + cx, + ); + assert!( + application.composer_model_run_error.is_none(), + "an old choice's failure must not affect the latest choice" + ); + application.handle_preferences_event( + PreferencesEvent::ModelSaved { + request_id: pending.request_id.clone(), + result: Err(failure), + }, + cx, + ); + assert!(application.composer_model_run_error.is_some()); + assert!( + application + .composer_controls + .read(cx) + .snapshot() + .failure + .is_some() + ); + application.handle_composer_model_event( + &crate::native_model_selector::NativeModelSelectorEvent::Retry, + cx, + ); + assert_eq!(application.composer_model_choice(cx), Some(&latest)); + assert!( + application + .composer_controls + .read(cx) + .snapshot() + .failure + .is_none() + ); + assert_eq!(application.composer.read(cx).draft(), "keep this draft"); + let saves = commands + .borrow() + .iter() + .filter_map(|command| match command { + NativeTransportCommand::Preferences(PreferencesCommand::SaveModel(save)) => { + Some(save.clone()) + } + _ => None, + }) + .collect::>(); + assert_eq!(saves.len(), 3); + assert_eq!(saves[1], pending); + assert_eq!( + saves[2], pending, + "retry uses the same choice, source and request identity" + ); + }); + }); +} diff --git a/modules/frontend/src/native_application/tests/render_boundaries.rs b/modules/frontend/src/native_application/tests/render_boundaries.rs index f8e1452ce..f13f812e9 100644 --- a/modules/frontend/src/native_application/tests/render_boundaries.rs +++ b/modules/frontend/src/native_application/tests/render_boundaries.rs @@ -121,12 +121,15 @@ fn a_recent_threads_push_renders_the_sidebar(cx: &mut TestAppContext) { cx.update(|_, app| { view.update(app, |application, cx| { let mut summary = thread("thread-b", "message-project", "Pulse the dot"); + // Unread rows show outright; read idle ones fold into History. + summary.attention = artisan_domain::ThreadAttention::Finished; summary.last_message_at = Some(UnixMillis::from_millis( super::super::impl_sidebar_threads::wall_clock().as_millis() - 60_000, )); let listing = RecentThreadListing::new(vec![RecentThread { thread: summary, subtitle: DisplayName::parse("Varde").expect("subtitle"), + project_icon: Default::default(), }]) .expect("recent threads"); application.handle_service_event( diff --git a/modules/frontend/src/native_application/tests/sidebar_recents.rs b/modules/frontend/src/native_application/tests/sidebar_recents.rs index b8f3cc748..dd0061187 100644 --- a/modules/frontend/src/native_application/tests/sidebar_recents.rs +++ b/modules/frontend/src/native_application/tests/sidebar_recents.rs @@ -15,6 +15,7 @@ fn recent_row(id: &str, project: &str, title: &str, subtitle: &str, age_ms: i64) RecentThread { thread: summary, subtitle: DisplayName::parse(subtitle).expect("subtitle"), + project_icon: Default::default(), } } @@ -105,13 +106,15 @@ fn sidebar_lists_recent_threads_by_age_without_a_project_switcher(cx: &mut TestA ); assert_eq!(days.top() - day.bottom(), px(12.0)); assert_eq!(older.top() - days.bottom(), px(12.0)); - let header = cx - .debug_bounds("artisan-sidebar-threads-last-24-hours-header") - .expect("group header"); + assert!( + cx.debug_bounds("artisan-sidebar-threads-last-24-hours-header") + .is_none(), + "the last day group keeps its rows without a second header" + ); let fresh = cx .debug_bounds("artisan-sidebar-thread-fresh") .expect("fresh row"); - assert!(header.bottom() <= fresh.top()); + assert_eq!(day.top(), fresh.top()); assert_eq!( fresh.size.height, px(super::super::super::impl_sidebar_threads::SIDEBAR_THREAD_ROW_HEIGHT_PX) diff --git a/modules/frontend/src/native_application/tests/thread_inspector.rs b/modules/frontend/src/native_application/tests/thread_inspector.rs index 2059c89d2..a38b2e5d3 100644 --- a/modules/frontend/src/native_application/tests/thread_inspector.rs +++ b/modules/frontend/src/native_application/tests/thread_inspector.rs @@ -411,7 +411,7 @@ fn agent(id: &str, name: &str, state: ThreadAgentState) -> ThreadAgentEntry { /// The Agents section lists the subagents the thread's current run started, /// named by their task and settled as their tool reports settle. Other tools -/// never appear, and a new run starts the list over. +/// never appear, a settled run lists none, and a new run starts over. #[gpui::test] fn inspector_agents_follow_the_current_runs_subagents(cx: &mut TestAppContext) { let (view, cx) = mount_open_thread(cx); @@ -429,6 +429,22 @@ fn inspector_agents_follow_the_current_runs_subagents(cx: &mut TestAppContext) { }); cx.run_until_parked(); }; + let live = |cx: &mut gpui::VisualTestContext, run: Option<&str>| { + cx.update(|_, app| { + view.update(app, |application, cx| { + match run { + Some(run) => application.run_controls.set_live_for_test( + thread_a.clone(), + artisan_domain::RunId::parse(run).expect("run"), + ), + None => application.run_controls.clear_transient_observation(), + } + cx.notify(); + }); + }); + cx.run_until_parked(); + }; + live(cx, Some("run-1")); send( cx, vec![ @@ -508,7 +524,13 @@ fn inspector_agents_follow_the_current_runs_subagents(cx: &mut TestAppContext) { vec![ThreadAgentState::Completed, ThreadAgentState::Failed] ); - // A new run without subagents clears the list. + // Once the run settles its agents are done: the section clears. + live(cx, None); + assert!(inspector_agents(&view, cx).is_empty()); + assert!(cx.debug_bounds(THREAD_SCREEN_AGENTS_SELECTOR).is_none()); + + // A new run without subagents lists none. + live(cx, Some("run-2")); send( cx, vec![tool_event( diff --git a/modules/frontend/src/native_application/workspace/tests/crash.rs b/modules/frontend/src/native_application/workspace/tests/crash.rs index 5d32d0a02..ccf255aac 100644 --- a/modules/frontend/src/native_application/workspace/tests/crash.rs +++ b/modules/frontend/src/native_application/workspace/tests/crash.rs @@ -78,6 +78,8 @@ fn connection_loss_releases_navigation_waits_and_ignores_old_receipts(cx: &mut T let source = ThreadId::parse("source-thread").unwrap(); let receipt = RequestId::parse("lost-switch-receipt").unwrap(); app.thread_switch_flight = Some(ThreadSwitchFlight { + #[cfg(feature = "flight-recorder")] + trace: artisan_tracing::span!("navigation", "test.thread_switch"), source_thread: source.clone(), target_thread: Some(ThreadId::parse("target-thread").unwrap()), generation: 1, diff --git a/modules/frontend/src/native_composer_models.rs b/modules/frontend/src/native_composer_models.rs index 9d6d388e3..4d3102a4a 100644 --- a/modules/frontend/src/native_composer_models.rs +++ b/modules/frontend/src/native_composer_models.rs @@ -25,6 +25,9 @@ impl NativeApplication { if !pending.admitted { self.submit_pending_model_favorite(&pending, cx); } + } else if self.pending_model_preference.is_some() { + self.submit_model_preference(); + self.sync_composer_controls(cx); } else if self.engine_settings.can_save() { self.save_engine_settings(cx); } else if !self.retry_policy_save(cx) { @@ -48,16 +51,18 @@ impl NativeApplication { policy: &crate::native_model_selector::SelectPolicy, cx: &mut Context, ) { - // The Forge makes the configuration a choice saves the default new - // threads start from, and pushes it. + self.composer_model_run_error = None; + self.save_model_preference(policy); + // The draft choice and the thread's runtime configuration are separate + // from the user preference saved above. let scope = self.composer.read(cx).draft_scope(); self.composer_model_choices.insert(scope, policy.clone()); - self.composer_model_run_error = None; self.sync_composer_controls(cx); // Only a thread's own draft saves the choice onto it: a choice made // in a new task's draft (or while its thread is being left) is sent // with that draft instead. let Some(thread) = self.model_choice_thread(cx) else { + self.sync_composer_model_policy(cx); return; }; if self.engine_settings.pending_save_request_id().is_some() { @@ -69,6 +74,36 @@ impl NativeApplication { self.sync_composer_model_policy(cx); } + /// Saves every picker change, including a new task before its first send. + fn save_model_preference(&mut self, policy: &crate::native_model_selector::SelectPolicy) { + let Some(selection) = crate::picker_selection::selection_for_policy(policy) else { + return; + }; + let Ok(request_id) = artisan_domain::RequestId::mint("model-preference") else { + self.composer_model_run_error = Some(SELECTION_SAVE_FAILED.to_owned()); + return; + }; + self.pending_model_preference = Some(artisan_domain::SaveModelPreference { + request_id, + selection, + source: artisan_domain::ModelPreferenceSource::User, + }); + self.submit_model_preference(); + } + + fn submit_model_preference(&mut self) { + let Some(save) = self.pending_model_preference.clone() else { + return; + }; + self.composer_model_run_error = None; + let command = NativeTransportCommand::Preferences( + crate::native_transport_service::PreferencesCommand::SaveModel(save), + ); + if self.submit_command(command).is_err() { + self.composer_model_run_error = Some(SELECTION_SAVE_FAILED.to_owned()); + } + } + /// The model chosen in the draft the composer shows. pub(super) fn composer_model_choice( &self, diff --git a/modules/frontend/src/native_composer_queue_application.rs b/modules/frontend/src/native_composer_queue_application.rs index a8eb42c30..98761eab1 100644 --- a/modules/frontend/src/native_composer_queue_application.rs +++ b/modules/frontend/src/native_composer_queue_application.rs @@ -103,19 +103,27 @@ impl NativeApplication { let Some(config) = self.engine_settings.authoritative_config() else { return; }; - // Usage scope is OpenCode2-shaped; another engine starts no usage - // scope instead of attributing usage as OpenCode2. - let artisan_domain::EngineSelection::OpenCode2(selection) = config.selection() else { - return; - }; - self.composer_queue.state.begin_usage_scope( - thread, - self.composer_queue.generation, - run, - selection.model_id().clone(), - selection.route_id().clone(), - selection.variant_id().cloned(), - ); + // OpenCode2 reports under the selected model, route and variant; a + // native engine reports under its own ids, attributed by run. + match config.selection() { + artisan_domain::EngineSelection::OpenCode2(selection) => { + self.composer_queue.state.begin_usage_scope( + thread, + self.composer_queue.generation, + run, + selection.model_id().clone(), + selection.route_id().clone(), + selection.variant_id().cloned(), + ); + } + _ => { + self.composer_queue.state.begin_native_usage_scope( + thread, + self.composer_queue.generation, + run, + ); + } + } self.composer_queue.usage = None; self.request_composer_usage(cx); } diff --git a/modules/frontend/src/native_composer_run_controls.rs b/modules/frontend/src/native_composer_run_controls.rs index a5acf769e..89a0cfdae 100644 --- a/modules/frontend/src/native_composer_run_controls.rs +++ b/modules/frontend/src/native_composer_run_controls.rs @@ -18,6 +18,18 @@ pub(super) struct RunControlsState { } impl RunControlsState { + /// Marks `run` live on `thread`, as an active-run answer would. + #[cfg(test)] + pub(super) fn set_live_for_test(&mut self, thread: ThreadId, run: RunId) { + self.thread = Some(thread); + self.active = Some(run); + } + + /// Whether `thread` has a run the Forge reports as live. + pub(super) fn is_live_for(&self, thread: &ThreadId) -> bool { + self.thread.as_ref() == Some(thread) && self.active.is_some() + } + pub(super) fn optimistically_stopped(&self) -> Option<&RunId> { self.optimistic_stopped.as_ref() } diff --git a/modules/frontend/src/native_context_usage.rs b/modules/frontend/src/native_context_usage.rs index e5f89cc1b..26d0647ac 100644 --- a/modules/frontend/src/native_context_usage.rs +++ b/modules/frontend/src/native_context_usage.rs @@ -18,14 +18,20 @@ #![allow(clippy::module_name_repetitions)] use artisan_ui::{ - popover::{Popover, PopoverAlign, PopoverChangeReason, PopoverSide, PopoverVariant}, + flame::{MaterialFace, material_face}, + popover::{ + Popover, PopoverAlign, PopoverChangeReason, PopoverSide, PopoverStyle, PopoverVariant, + popover_content, + }, progress::{ProgressFraction, progress}, theme::{ArtisanTheme, RadiusStep, RadiusTokens}, }; -use gpui::prelude::{InteractiveElement as _, ParentElement as _, Styled as _}; +use gpui::prelude::{ + InteractiveElement as _, ParentElement as _, StatefulInteractiveElement as _, Styled as _, +}; use gpui::{ - App, Bounds, ElementId, FocusHandle, Hsla, Path, PathBuilder, Pixels, Stateful, Window, canvas, - div, point, px, + App, AppContext as _, Bounds, Context, ElementId, FocusHandle, Hsla, IntoElement, Path, + PathBuilder, Pixels, Render, Stateful, Window, canvas, div, point, px, }; use crate::context_usage_description::{ @@ -42,6 +48,12 @@ pub const CONTEXT_USAGE_SELECTOR: &str = "artisan-native-context-usage"; pub const CONTEXT_USAGE_RING_SELECTOR: &str = "artisan-native-context-usage-ring"; /// Stable selector for the details card. pub const CONTEXT_USAGE_DETAILS_SELECTOR: &str = "artisan-native-context-usage-details"; +/// Stable selector for the titlebar context meter. +pub const CONTEXT_METER_SELECTOR: &str = "artisan-native-context-meter"; +/// Titlebar context meter width. +pub const CONTEXT_METER_WIDTH_PX: f32 = 92.0; +/// Titlebar context meter height. +pub const CONTEXT_METER_HEIGHT_PX: f32 = 20.0; const RING_EDGE_PX: f32 = 16.0; const RING_STROKE_PX: f32 = 2.5; const RING_RADIUS_PX: f32 = 5.75; @@ -336,6 +348,84 @@ pub fn render_details( ) } +/// Renders the titlebar context meter: a borderless rounded track whose fill +/// grows from the right edge leftward as the context window fills. The fill +/// wears Varde's button material, running green → flame → red as the thread +/// nears the point where the engine compacts. Hovering it shows the same +/// details card as the composer's ring. +#[must_use] +pub fn render_context_meter( + presentation: &NativeContextUsagePresentation, + theme: ArtisanTheme, +) -> Stateful { + let radius = RadiusTokens::value(RadiusStep::Xs); + let inner_width = CONTEXT_METER_WIDTH_PX; + let fraction = finite_percent(presentation.percent); + let face = MaterialFace::green_to_red(compaction_level(presentation)); + // A sliver keeps both rounded ends visible instead of a smeared dot. + let min_fill = f32::from(radius) * 2.0; + let fill_width = if fraction > 0.0 { + (inner_width * fraction).clamp(min_fill.min(inner_width), inner_width) + } else { + 0.0 + }; + let card = presentation.clone(); + div() + .id(ElementId::Name(CONTEXT_METER_SELECTOR.into())) + .debug_selector(|| CONTEXT_METER_SELECTOR.to_owned()) + .relative() + .flex_shrink_0() + .w(px(CONTEXT_METER_WIDTH_PX)) + .h(px(CONTEXT_METER_HEIGHT_PX)) + .rounded(radius) + .bg(theme.colors.foreground.with_alpha(0.08).to_paint()) + .children((fill_width > 0.0).then(|| { + material_face(face, radius) + .absolute() + .right(px(0.0)) + .top(px(0.0)) + .bottom(px(0.0)) + .w(px(fill_width)) + })) + .tooltip(move |_window, cx| { + let card = card.clone(); + cx.new(|_| ContextUsageHoverCard { + presentation: card, + theme, + }) + .into() + }) +} + +/// How close the context is to compaction, `0.0` empty to `1.0` at the +/// Forge-reported compaction point (the full window when none is reported). +#[expect( + clippy::cast_possible_truncation, + reason = "a clamped 0..=1 ratio narrowed to the f32 the colour blend uses" +)] +fn compaction_level(presentation: &NativeContextUsagePresentation) -> f32 { + let limit = presentation.compaction_percent.unwrap_or(100.0); + if !(limit.is_finite() && limit > 0.0 && presentation.percent.is_finite()) { + return 0.0; + } + (presentation.percent / limit).clamp(0.0, 1.0) as f32 +} + +/// The hover card behind the titlebar context meter. +struct ContextUsageHoverCard { + presentation: NativeContextUsagePresentation, + theme: ArtisanTheme, +} + +impl Render for ContextUsageHoverCard { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + popover_content( + PopoverStyle::default_card(self.theme), + render_details(&self.presentation, &self.theme), + ) + } +} + fn render_ring_trigger( presentation: &NativeContextUsagePresentation, theme: &ArtisanTheme, @@ -524,6 +614,17 @@ mod tests { assert_eq!(cloned.reporting_model_id, "gpt-5.6-luna"); } + #[test] + fn meter_colour_tracks_the_distance_to_compaction() { + // 90 000 of 200 000 tokens with compaction at 180 000: halfway there. + let presentation = usage().presentation(Some("run-1")).expect("valid report"); + assert!((super::compaction_level(&presentation) - 0.5).abs() < 1e-6); + let mut at_limit = usage(); + at_limit.context_tokens = Some(190_000); + let at_limit = at_limit.presentation(Some("run-1")).expect("valid report"); + assert!((super::compaction_level(&at_limit) - 1.0).abs() < f32::EPSILON); + } + #[test] fn invalid_details_are_not_recovered_with_fabricated_text() { let error = super::ContextUsageDetails::new(Some("Luna"), f64::NAN, 200_000.0) diff --git a/modules/frontend/src/native_model_selector.rs b/modules/frontend/src/native_model_selector.rs index b79ac821a..de33f0c67 100644 --- a/modules/frontend/src/native_model_selector.rs +++ b/modules/frontend/src/native_model_selector.rs @@ -65,7 +65,8 @@ mod tests; // Re-exports keep the public surface identical to the pre-split module. pub(crate) use render::{ - animate_picker_menu, engine_accent, engine_asset, render_picker_hover_pill, + PickerMenuEntrance, animate_picker_menu_timed, engine_accent, engine_asset, + render_picker_hover_pill, }; pub use state::model_display_label; @@ -224,6 +225,8 @@ pub struct NativeModelSelectorState { local_error: Option, model_groups_cache: RefCell>, collapsed_groups: std::collections::HashSet<(String, String)>, + /// Engines the Forge reports as needing sign-in; their tabs are greyed. + blocked_engines: Vec, } /// Catalog projections survive animation frames; their inputs change only on interaction. diff --git a/modules/frontend/src/native_model_selector/interaction.rs b/modules/frontend/src/native_model_selector/interaction.rs index 28c936799..fc170c392 100644 --- a/modules/frontend/src/native_model_selector/interaction.rs +++ b/modules/frontend/src/native_model_selector/interaction.rs @@ -92,6 +92,13 @@ impl NativeModelSelector { } /// Supplies owner-controlled save/error/authority state. + /// Greys out the tabs of engines the Forge reports as needing sign-in. + pub fn set_blocked_engines(&mut self, blocked: Vec, cx: &mut Context) { + if self.state.set_blocked_engines(blocked) { + cx.notify(); + } + } + pub fn set_status(&mut self, status: NativeModelSelectorStatus, cx: &mut Context) { self.state.set_status(status); cx.notify(); diff --git a/modules/frontend/src/native_model_selector/render.rs b/modules/frontend/src/native_model_selector/render.rs index 9a120eed4..d3e992c15 100644 --- a/modules/frontend/src/native_model_selector/render.rs +++ b/modules/frontend/src/native_model_selector/render.rs @@ -19,5 +19,7 @@ mod rows; #[cfg(test)] pub(super) use self::menu::gradient_highlights; -pub(crate) use self::menu::{animate_picker_menu, engine_accent, engine_asset}; +pub(crate) use self::menu::{ + PickerMenuEntrance, animate_picker_menu_timed, engine_accent, engine_asset, +}; pub(crate) use self::rows::render_picker_hover_pill; diff --git a/modules/frontend/src/native_model_selector/render/menu.rs b/modules/frontend/src/native_model_selector/render/menu.rs index 2a83e3513..2ce017692 100644 --- a/modules/frontend/src/native_model_selector/render/menu.rs +++ b/modules/frontend/src/native_model_selector/render/menu.rs @@ -275,17 +275,28 @@ impl NativeModelSelector { "{NATIVE_MODEL_SELECTOR_ENGINE_SELECTOR_PREFIX}-{}", harness.id ); + // An engine that cannot offer a model right now (it needs + // sign-in, reported nothing, or reported an incomplete list) is + // greyed out and inert. + let usable = self.state.engine_usable(&harness.id); let mut tab = div() .id(format!( "{NATIVE_MODEL_SELECTOR_ENGINE_SELECTOR_PREFIX}-{}", harness.id )) .debug_selector(move || selector.clone()) - .on_click(cx.listener(move |view: &mut Self, _: &ClickEvent, _, cx| { - view.switch_engine(engine_id.clone(), cx); - })) + .when(usable, |tab| { + tab.on_click(cx.listener(move |view: &mut Self, _: &ClickEvent, _, cx| { + view.switch_engine(engine_id.clone(), cx); + })) + }) + .when(!usable, |tab| tab.opacity(0.35)) .role(gpui::Role::Button) - .aria_label(harness.label.clone()) + .aria_label(if usable { + harness.label.clone() + } else { + format!("{} (unavailable)", harness.label) + }) .flex() .items_center() .justify_center() @@ -362,6 +373,49 @@ pub(crate) fn animate_picker_menu( motion: Rc>, snapshot: PickerMenuMotion, surface: &'static str, +) -> AnyElement { + let duration = Duration::from_millis(PICKER_MENU_MOTION_DURATION_MS); + animate_picker_menu_timed( + panel, + motion, + snapshot, + surface, + PickerMenuEntrance::Slide, + duration, + duration, + ) +} + +/// How a picker menu moves while it fades in and out. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum PickerMenuEntrance { + /// Slides 8 px toward its trigger (the picker's own recipe). + Slide, + /// The transitions-dev dropdown: grows from 97% to full size about + /// `origin` (a fraction of the panel's bounds) on open, and settles to + /// 99% on close, so the exit reads quieter than the entrance. + Scale { + /// The fixed point of the scale; the corner nearest the trigger. + origin: gpui::Point, + }, +} + +/// `--dropdown-pre-scale`: where an opening dropdown starts. +const DROPDOWN_PRE_SCALE: f32 = 0.97; +/// `--dropdown-closing-scale`: where a closing dropdown ends. +const DROPDOWN_CLOSING_SCALE: f32 = 0.99; + +/// [`animate_picker_menu`] with its own entrance and separate open and close +/// durations, for a surface that follows the transitions-dev dropdown +/// (slower in than out) instead of the picker's shared 100 ms slide. +pub(crate) fn animate_picker_menu_timed( + panel: Stateful
, + motion: Rc>, + snapshot: PickerMenuMotion, + surface: &'static str, + entrance: PickerMenuEntrance, + open: Duration, + close: Duration, ) -> AnyElement { let Some((from_opacity, from_offset, to_opacity, to_offset, generation)) = snapshot.transition() @@ -380,18 +434,38 @@ pub(crate) fn animate_picker_menu( ) .into(), ); - panel - .top(px(from_offset)) - .opacity(from_opacity) + // The motion state tracks presence as an offset from 8 (hidden) to 0 + // (open); a scaled entrance reads the same presence as a scale. + let place = move |panel: Stateful
, offset: f32, opacity: f32| match entrance { + PickerMenuEntrance::Slide => panel.top(px(offset)).opacity(opacity), + PickerMenuEntrance::Scale { origin } => { + let presence = 1.0 - (offset / 8.0).clamp(0.0, 1.0); + let rest = if phase == PickerMenuPhase::Closing { + DROPDOWN_CLOSING_SCALE + } else { + DROPDOWN_PRE_SCALE + }; + panel.opacity(opacity).transform( + gpui::ElementTransform::scale(rest + (1.0 - rest) * presence).with_origin(origin), + ) + } + }; + place(panel, from_offset, from_opacity) .with_animation( animation_id, - Animation::new(Duration::from_millis(PICKER_MENU_MOTION_DURATION_MS)) - .with_easing(engine_light_smooth_out), + Animation::new(if phase == PickerMenuPhase::Closing { + close + } else { + open + }) + .with_easing(engine_light_smooth_out), move |panel, progress| { motion.borrow_mut().apply_progress(generation, progress); - panel - .top(px(from_offset + (to_offset - from_offset) * progress)) - .opacity(from_opacity + (to_opacity - from_opacity) * progress) + place( + panel, + from_offset + (to_offset - from_offset) * progress, + from_opacity + (to_opacity - from_opacity) * progress, + ) }, ) .into_any_element() diff --git a/modules/frontend/src/native_model_selector/state.rs b/modules/frontend/src/native_model_selector/state.rs index 8af7dc736..112f9a9a0 100644 --- a/modules/frontend/src/native_model_selector/state.rs +++ b/modules/frontend/src/native_model_selector/state.rs @@ -216,6 +216,7 @@ impl NativeModelSelectorState { local_error: None, model_groups_cache: RefCell::new(None), collapsed_groups: std::collections::HashSet::new(), + blocked_engines: Vec::new(), }; state.refresh_preview_and_highlight(); state @@ -231,22 +232,37 @@ impl NativeModelSelectorState { .policy .as_ref() .and_then(|policy| rebase_selection_policy(&snapshot, policy)); - let active_engine = if snapshot + // Prefer an engine that can offer a model: the current tab, then the + // policy's engine, then the first usable tab. Only when no engine is + // usable does the tab fall back to any visible one. + let visible = |engine: &str| { + snapshot + .manifest + .harness(engine) + .is_some_and(|harness| !harness.hidden) + }; + let blocked = |engine: &str| self.blocked_engines.iter().any(|id| id == engine); + let usable = |engine: &str| { + visible(engine) && snapshot.engine_has_models(engine) && !blocked(engine) + }; + let policy_engine = policy.as_ref().map(|policy| policy.engine_id.clone()); + let first_usable = snapshot .manifest - .harness(&self.active_engine) - .is_some_and(|harness| !harness.hidden) - { + .harnesses + .iter() + .find(|harness| { + !harness.hidden && snapshot.engine_has_models(&harness.id) && !blocked(&harness.id) + }) + .map(|harness| harness.id.clone()); + let active_engine = if usable(&self.active_engine) { self.active_engine.clone() } else { - policy - .as_ref() - .map(|policy| policy.engine_id.clone()) - .filter(|engine| { - snapshot - .manifest - .harness(engine) - .is_some_and(|harness| !harness.hidden) - }) + policy_engine + .clone() + .filter(|engine| usable(engine)) + .or(first_usable) + .or_else(|| Some(self.active_engine.clone()).filter(|engine| visible(engine))) + .or_else(|| policy_engine.filter(|engine| visible(engine))) .or_else(|| { snapshot .manifest @@ -395,10 +411,51 @@ impl NativeModelSelectorState { self.highlighted_model_id = None; } - /// Switches the active engine tab. + /// Whether `engine_id`'s tab can be chosen: the engine reported models + /// and is not known to need sign-in. + #[must_use] + pub fn engine_usable(&self, engine_id: &str) -> bool { + self.snapshot.engine_has_models(engine_id) + && !self + .blocked_engines + .iter() + .any(|blocked| blocked == engine_id) + } + + /// Replaces the engines that need sign-in. Returns whether the set + /// changed; an active tab that became unusable moves to a usable one. + pub fn set_blocked_engines(&mut self, mut blocked: Vec) -> bool { + blocked.sort(); + blocked.dedup(); + if blocked == self.blocked_engines { + return false; + } + self.blocked_engines = blocked; + if !self.engine_usable(&self.active_engine) + && let Some(usable) = self + .snapshot + .manifest + .harnesses + .iter() + .find(|harness| !harness.hidden && self.engine_usable(&harness.id)) + .map(|harness| harness.id.clone()) + { + self.active_engine = usable; + self.query.clear(); + self.open_axis = None; + self.refresh_preview_and_highlight(); + } + true + } + + /// Switches the active engine tab. An engine with no selectable model + /// (it needs sign-in, reported nothing, or reported an incomplete list) + /// is greyed out and cannot be chosen. pub fn set_active_engine(&mut self, engine_id: impl Into) { let engine_id = engine_id.into(); - if self.snapshot.manifest.harness(&engine_id).is_none() { + if self.snapshot.manifest.harness(&engine_id).is_none() + || (engine_id != self.active_engine && !self.engine_usable(&engine_id)) + { return; } self.active_engine = engine_id; diff --git a/modules/frontend/src/native_model_selector/tests.rs b/modules/frontend/src/native_model_selector/tests.rs index b21fe07c4..1334b9650 100644 --- a/modules/frontend/src/native_model_selector/tests.rs +++ b/modules/frontend/src/native_model_selector/tests.rs @@ -1097,3 +1097,42 @@ fn large_catalog_groups_variants_virtualizes_rows_and_collapses(cx: &mut gpui::T .is_some() ); } + +#[test] +fn engines_without_models_or_needing_sign_in_cannot_be_chosen() { + let mut snapshot = NativeModelCatalog::from_manifest_json(include_str!( + "../../../../tests/fixtures/model_catalog.json" + )) + .expect("fixture catalog"); + // Claude reported nothing. + snapshot + .manifest + .models + .retain(|model| model.harness != "claude"); + let mut state = NativeModelSelectorState::new(snapshot, None); + let start = state.active_engine().to_owned(); + assert_ne!(start, "claude"); + assert!(!state.engine_usable("claude")); + state.set_active_engine("claude".to_owned()); + assert_eq!(state.active_engine(), start, "an empty engine stays greyed"); + + // The active engine needing sign-in moves the tab to a usable engine. + assert!(state.set_blocked_engines(vec![start.clone()])); + assert!(!state.engine_usable(&start)); + assert_ne!(state.active_engine(), start); + assert!(state.engine_usable(state.active_engine())); + assert!( + !state.set_blocked_engines(vec![start.clone()]), + "unchanged set" + ); + state.set_active_engine(start.clone()); + assert_ne!( + state.active_engine(), + start, + "a blocked engine stays greyed" + ); + + assert!(state.set_blocked_engines(Vec::new())); + state.set_active_engine(start.clone()); + assert_eq!(state.active_engine(), start); +} diff --git a/modules/frontend/src/native_preferences_transport.rs b/modules/frontend/src/native_preferences_transport.rs index 893edf1fd..061b9ae9b 100644 --- a/modules/frontend/src/native_preferences_transport.rs +++ b/modules/frontend/src/native_preferences_transport.rs @@ -1,5 +1,5 @@ //! Transport-thread child for the Forge user's preferences: the read the -//! Editor makes as it connects, the navigation it reports, and the one-time +//! Editor makes as it connects, explicit model choices, navigation, and the one-time //! import of preferences an older Editor kept in files. //! //! Every command is answered by exactly one [`PreferencesEvent`]; later @@ -11,7 +11,7 @@ use artisan_domain::{ ImportLegacyPreferences, LegacyPreferencesImported, ReadUserPreferences, RecordNavigation, - UserPreferences, + SaveModelPreference, UserPreferences, }; use super::*; @@ -21,6 +21,8 @@ use super::*; pub enum PreferencesCommand { /// Read the user's preferences. Read, + /// Save an explicit model preference, including its source. + SaveModel(SaveModelPreference), /// Report that the user opened a project (and a thread in it). RecordNavigation(RecordNavigation), /// Hand an older Editor's file preferences to the Forge once. @@ -32,6 +34,13 @@ pub enum PreferencesCommand { pub enum PreferencesEvent { /// The preferences as read or as a recorded navigation left them. Loaded(Result, ServiceFailure>), + /// Preference save acknowledgement. + ModelSaved { + /// Choice this acknowledgement belongs to. + request_id: RequestId, + /// Updated preferences or the save failure. + result: Result, ServiceFailure>, + }, /// The Forge's answer to a legacy import. LegacyImported(Result, ServiceFailure>), } @@ -105,6 +114,22 @@ pub(super) async fn handle_preferences_command( }), ) } + PreferencesCommand::SaveModel(save) => { + let request_id = save.request_id.clone(); + let result = mutate( + runtime, + frames, + &request_id, + Command::SaveModelPreference(save), + PreferencesExpectation::Preferences, + ) + .await + .and_then(|payload| match payload { + ResponsePayload::UserPreferences(preferences) => Ok(Box::new(preferences)), + _ => Err(ServiceFailure::invalid(ServiceFailureStage::Request)), + }); + PreferencesEvent::ModelSaved { request_id, result } + } PreferencesCommand::ImportLegacy(import) => { let request_id = import.request_id.clone(); PreferencesEvent::LegacyImported( diff --git a/modules/frontend/src/native_transport_service.rs b/modules/frontend/src/native_transport_service.rs index c86fa078e..f96dcf934 100644 --- a/modules/frontend/src/native_transport_service.rs +++ b/modules/frontend/src/native_transport_service.rs @@ -657,6 +657,8 @@ pub struct NativeTransportService { } struct ServiceRuntime { + #[cfg(feature = "flight-recorder")] + trace_command: u64, preserve_reconnect: bool, session: Option, reconnect_lease: Option, @@ -685,6 +687,9 @@ fn publish( events: &SyncSender, event: NativeTransportEvent, ) -> Result<(), ServiceFailure> { + #[cfg(feature = "flight-recorder")] + let _trace = + artisan_tracing::span!("transport.bridge", "event.publish", "event" => event.trace_name()); events.send(event).map_err(|_| ServiceFailure::bridge()) } @@ -832,3 +837,7 @@ mod command_loop_tests; #[cfg(test)] #[path = "native_transport_service/push_stall_tests.rs"] mod push_stall_tests; + +#[cfg(feature = "flight-recorder")] +#[path = "native_transport_service/flight_recorder.rs"] +mod flight_recorder; diff --git a/modules/frontend/src/native_transport_service/connection_holds.rs b/modules/frontend/src/native_transport_service/connection_holds.rs index bd4eaf922..7389e1210 100644 --- a/modules/frontend/src/native_transport_service/connection_holds.rs +++ b/modules/frontend/src/native_transport_service/connection_holds.rs @@ -238,20 +238,27 @@ impl Drop for Hold { /// keeps the connection open until its handler has returned. #[derive(Debug)] pub struct QueuedCommand { + #[cfg(feature = "flight-recorder")] + pub(super) trace: artisan_tracing::Span, pub(super) command: NativeTransportCommand, pub(super) hold: Option, } impl From for QueuedCommand { fn from(command: NativeTransportCommand) -> Self { - Self { - command, - hold: None, - } + Self::new(command, None) } } impl QueuedCommand { + pub(super) fn new(command: NativeTransportCommand, hold: Option) -> Self { + Self { + #[cfg(feature = "flight-recorder")] + trace: artisan_tracing::span!("transport.queue", "command.wait", "command" => command.trace_name(), "thread_id" => command.trace_thread()), + command, + hold, + } + } /// The admitted command. #[must_use] pub const fn command(&self) -> &NativeTransportCommand { @@ -291,7 +298,9 @@ impl NativeTransportCommand { ComposerDraftCommand::Save { .. } | ComposerDraftCommand::Upload { .. }, ) => Some(HoldKind::Draft), Self::Preferences( - PreferencesCommand::RecordNavigation(_) | PreferencesCommand::ImportLegacy(_), + PreferencesCommand::RecordNavigation(_) + | PreferencesCommand::ImportLegacy(_) + | PreferencesCommand::SaveModel(_), ) => Some(HoldKind::Preferences), Self::ComposerState( ComposerStateCommand::ReadFooterUsage { .. } diff --git a/modules/frontend/src/native_transport_service/diagnostics.rs b/modules/frontend/src/native_transport_service/diagnostics.rs index 792cd5854..104d677d6 100644 --- a/modules/frontend/src/native_transport_service/diagnostics.rs +++ b/modules/frontend/src/native_transport_service/diagnostics.rs @@ -274,6 +274,11 @@ pub enum StartupError { /// A named startup stage failed without retaining its source. #[error("native startup stage failed")] Stage(ServiceFailureStage), + /// A startup stage could not reach what it reads, such as credential + /// files on a WSL host that is down or restarting: the host is + /// unavailable, not the credential rejected. + #[error("native startup stage could not reach the host")] + Unreachable(ServiceFailureStage), } impl StartupError { @@ -302,6 +307,9 @@ impl StartupError { }; ServiceFailure::new(stage, category) } + Self::Unreachable(stage) => { + ServiceFailure::new(stage, ServiceFailureCategory::Unavailable) + } } } } diff --git a/modules/frontend/src/native_transport_service/flight_recorder.rs b/modules/frontend/src/native_transport_service/flight_recorder.rs new file mode 100644 index 000000000..1702b91bb --- /dev/null +++ b/modules/frontend/src/native_transport_service/flight_recorder.rs @@ -0,0 +1,190 @@ +//! Payload-free event names for the flight recorder. Exhaustive matches +//! make new protocol events visible without serializing their contents. + +use super::*; + +pub(super) fn monitor(runtime: &tokio::runtime::Runtime) { + if !artisan_tracing::is_recording() { + return; + } + let heartbeat = artisan_tracing::Heartbeat::new("editor.transport"); + runtime.spawn(async move { + let mut ticks = tokio::time::interval(Duration::from_millis(100)); + ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + ticks.tick().await; + heartbeat.tick(); + } + }); +} + +impl NativeTransportCommand { + pub(crate) fn trace_thread(&self) -> Option<&str> { + match self { + Self::Subscribe { thread_id, .. } + | Self::Unsubscribe { thread_id } + | Self::ReadConversationHistory { thread_id, .. } + | Self::LoadThreadEngineSettings { thread_id, .. } + | Self::ReadComposerCatalog { thread_id, .. } + | Self::ReadModelFavorites { thread_id, .. } + | Self::ReadActiveRun { thread_id, .. } => Some(thread_id.as_str()), + Self::RequestSnapshot(thread_id) => Some(thread_id.as_str()), + _ => None, + } + } + + pub(crate) fn trace_name(&self) -> &'static str { + match self { + Self::ComposerState(..) => "ComposerState", + Self::ComposerDraft(..) => "ComposerDraft", + Self::ForgeDecision(..) => "ForgeDecision", + Self::Preferences(..) => "Preferences", + Self::EngineInstalls(..) => "EngineInstalls", + Self::ReadActiveRun { .. } => "ReadActiveRun", + Self::StopRun(..) => "StopRun", + Self::RespondApproval(..) => "RespondApproval", + Self::RespondQuestion(..) => "RespondQuestion", + Self::AnswerQuestions(..) => "AnswerQuestions", + Self::BeginProjectIntake => "BeginProjectIntake", + Self::BeginProjectIntakeAt(..) => "BeginProjectIntakeAt", + Self::RetryProjectIntake => "RetryProjectIntake", + Self::SelectProject(..) => "SelectProject", + Self::RefreshThreads { .. } => "RefreshThreads", + Self::ReadRecentThreads => "ReadRecentThreads", + Self::ReadProjects => "ReadProjects", + Self::CreateTask(..) => "CreateTask", + Self::RecoverFailedMessage { .. } => "RecoverFailedMessage", + Self::RequestSnapshot(..) => "RequestSnapshot", + Self::ReadConversationHistory { .. } => "ReadConversationHistory", + Self::ReadMessageImage(..) => "ReadMessageImage", + Self::LoadThreadEngineSettings { .. } => "LoadThreadEngineSettings", + Self::ReadComposerCatalog { .. } => "ReadComposerCatalog", + Self::ReadModelFavorites { .. } => "ReadModelFavorites", + Self::ListRegisteredProfiles => "ListRegisteredProfiles", + Self::ReadAccountUsage { .. } => "ReadAccountUsage", + Self::SetThreadEngineConfig(..) => "SetThreadEngineConfig", + Self::SetModelFavorite(..) => "SetModelFavorite", + Self::SubmitComposerDraft(..) => "SubmitComposerDraft", + Self::ResolveRichLink { .. } => "ResolveRichLink", + Self::QueryProjectRepository { .. } => "QueryProjectRepository", + Self::Subscribe { .. } => "Subscribe", + Self::Unsubscribe { .. } => "Unsubscribe", + Self::AcknowledgePatch { .. } => "AcknowledgePatch", + Self::Shutdown => "Shutdown", + } + } +} + +impl NativeTransportEvent { + pub(crate) fn trace_name(&self) -> &'static str { + match self { + Self::ComposerState(..) => "ComposerState", + Self::ComposerDraft(..) => "ComposerDraft", + Self::ForgeDecision(..) => "ForgeDecision", + Self::Preferences(..) => "Preferences", + Self::EngineInstalls(..) => "EngineInstalls", + Self::ActiveRun { .. } => "ActiveRun", + Self::ActiveRunFailed { .. } => "ActiveRunFailed", + Self::RunStopped(..) => "RunStopped", + Self::StopRunFailed { .. } => "StopRunFailed", + Self::ApprovalAnswered { .. } => "ApprovalAnswered", + Self::ApprovalFailed { .. } => "ApprovalFailed", + Self::QuestionAnswered { .. } => "QuestionAnswered", + Self::QuestionFailed { .. } => "QuestionFailed", + Self::QuestionsAnswered { .. } => "QuestionsAnswered", + Self::QuestionsAnswerFailed { .. } => "QuestionsAnswerFailed", + Self::MessageImageLoaded { .. } => "MessageImageLoaded", + Self::MessageImageFailed { .. } => "MessageImageFailed", + Self::Starting => "Starting", + Self::Projects(..) => "Projects", + Self::Threads { .. } => "Threads", + Self::ThreadsRefreshed { .. } => "ThreadsRefreshed", + Self::RecentThreads(..) => "RecentThreads", + Self::ProjectCatalog(..) => "ProjectCatalog", + Self::Snapshot(..) => "Snapshot", + Self::EmptyProjects => "EmptyProjects", + Self::EmptyThreads { .. } => "EmptyThreads", + Self::ProjectIntakeProgress(..) => "ProjectIntakeProgress", + Self::ProjectIntakeCancelled => "ProjectIntakeCancelled", + Self::ProjectIntakeReady { .. } => "ProjectIntakeReady", + Self::ProjectIntakeFailed { .. } => "ProjectIntakeFailed", + Self::Failed(..) => "Failed", + Self::ThreadEngineSettings { .. } => "ThreadEngineSettings", + Self::RegisteredProfiles(..) => "RegisteredProfiles", + Self::RegisteredProfilesFailed(..) => "RegisteredProfilesFailed", + Self::AccountUsage { .. } => "AccountUsage", + Self::AccountUsageFailed { .. } => "AccountUsageFailed", + Self::ComposerCatalog { .. } => "ComposerCatalog", + Self::ComposerCatalogFailed { .. } => "ComposerCatalogFailed", + Self::ModelFavorites { .. } => "ModelFavorites", + Self::ModelFavoritesFailed { .. } => "ModelFavoritesFailed", + Self::ModelFavoriteSet { .. } => "ModelFavoriteSet", + Self::RichLinkResolved { .. } => "RichLinkResolved", + Self::RichLinkFailed { .. } => "RichLinkFailed", + Self::ProjectRepository { .. } => "ProjectRepository", + Self::ProjectRepositoryFailed { .. } => "ProjectRepositoryFailed", + Self::ModelFavoriteFailed { .. } => "ModelFavoriteFailed", + Self::ThreadEngineConfigSet(..) => "ThreadEngineConfigSet", + Self::ThreadEngineConfigConflict { .. } => "ThreadEngineConfigConflict", + Self::ThreadEngineConfigFailed { .. } => "ThreadEngineConfigFailed", + Self::MessageQueued(..) => "MessageQueued", + Self::MessageFailed { .. } => "MessageFailed", + Self::MessageStale { .. } => "MessageStale", + Self::ThreadEngineSettingsFailed { .. } => "ThreadEngineSettingsFailed", + Self::ConversationSubscriptionStarted { .. } => "ConversationSubscriptionStarted", + Self::ConversationSubscriptionStopped { .. } => "ConversationSubscriptionStopped", + Self::ConversationHistory { .. } => "ConversationHistory", + Self::ConversationHistoryFailed { .. } => "ConversationHistoryFailed", + Self::PatchBatch(..) => "PatchBatch", + Self::EngineObservation(..) => "EngineObservation", + Self::MessageOutbox(..) => "MessageOutbox", + Self::ObservationHistoryCurrent(..) => "ObservationHistoryCurrent", + Self::HostState(..) => "HostState", + Self::DeliveryLost(..) => "DeliveryLost", + Self::Reconnected => "Reconnected", + Self::HostHome(..) => "HostHome", + Self::Stopped(..) => "Stopped", + } + } +} + +impl ExpectedResponse { + pub(super) fn trace_name(&self) -> &'static str { + match self { + Self::MessageWithdrawn { .. } => "MessageWithdrawn", + Self::FailedMessageRetried { .. } => "FailedMessageRetried", + Self::FailedMessageRecovered { .. } => "FailedMessageRecovered", + Self::RunUsage { .. } => "RunUsage", + Self::ActiveRun(..) => "ActiveRun", + Self::RunStopped(..) => "RunStopped", + Self::Directory => "Directory", + Self::Projects => "Projects", + Self::RecentThreads => "RecentThreads", + Self::AttachedProject => "AttachedProject", + Self::CreatedThread => "CreatedThread", + Self::Threads(..) => "Threads", + Self::Snapshot(..) => "Snapshot", + Self::ConversationHistory(..) => "ConversationHistory", + Self::MessageImage(..) => "MessageImage", + Self::ThreadEngineSettings(..) => "ThreadEngineSettings", + Self::RegisteredProfiles => "RegisteredProfiles", + Self::AccountUsage { .. } => "AccountUsage", + Self::ComposerCatalog { .. } => "ComposerCatalog", + Self::RichLink { .. } => "RichLink", + Self::ProjectRepository { .. } => "ProjectRepository", + Self::ModelFavorites => "ModelFavorites", + Self::ModelFavoriteSet { .. } => "ModelFavoriteSet", + Self::ThreadEngineConfigSet { .. } => "ThreadEngineConfigSet", + Self::DraftSubmitted { .. } => "DraftSubmitted", + Self::ApprovalAnswered { .. } => "ApprovalAnswered", + Self::QuestionAnswered { .. } => "QuestionAnswered", + Self::QuestionsAnswered { .. } => "QuestionsAnswered", + Self::ConversationSubscriptionStarted { .. } => "ConversationSubscriptionStarted", + Self::ConversationSubscriptionStopped { .. } => "ConversationSubscriptionStopped", + Self::ComposerDraft(..) => "ComposerDraft", + Self::ForgeDecision(..) => "ForgeDecision", + Self::EngineInstalls(..) => "EngineInstalls", + Self::Preferences(..) => "Preferences", + } + } +} diff --git a/modules/frontend/src/native_transport_service/project_intake.rs b/modules/frontend/src/native_transport_service/project_intake.rs index 29ef49f59..0caaac19a 100644 --- a/modules/frontend/src/native_transport_service/project_intake.rs +++ b/modules/frontend/src/native_transport_service/project_intake.rs @@ -55,6 +55,8 @@ pub(super) async fn begin_project_intake( frames: &mut FrameFactory, events: &SyncSender, ) -> Result<(), ServiceFailure> { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("project.intake", "begin_project_intake"); runtime.intake.reset(); pick_directory(runtime, frames, events, None).await } @@ -64,6 +66,8 @@ pub(super) async fn retry_project_intake( frames: &mut FrameFactory, events: &SyncSender, ) -> Result<(), ServiceFailure> { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("project.intake", "retry_project_intake"); let Some(retry) = runtime.intake.retry.take() else { return Ok(()); }; @@ -119,6 +123,8 @@ pub(super) async fn begin_project_intake_at( events: &SyncSender, path: String, ) -> Result<(), ServiceFailure> { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("project.intake", "begin_project_intake_at"); runtime.intake.reset(); pick_directory(runtime, frames, events, Some(path)).await } diff --git a/modules/frontend/src/native_transport_service/remote.rs b/modules/frontend/src/native_transport_service/remote.rs index 3e2555940..6bdab4b02 100644 --- a/modules/frontend/src/native_transport_service/remote.rs +++ b/modules/frontend/src/native_transport_service/remote.rs @@ -136,6 +136,8 @@ pub(super) async fn start(home: &Path) -> Result<(ServiceRuntime, FrameFactory), .map_err(|error| credential_failure("storing the next reconnect capability", &error))?; Ok(( ServiceRuntime { + #[cfg(feature = "flight-recorder")] + trace_command: 0, session: Some(session), reconnect_lease: Some(lease), reconnect_binding: binding, diff --git a/modules/frontend/src/native_transport_service/request_delivery.rs b/modules/frontend/src/native_transport_service/request_delivery.rs index ca1918e9f..5bee4d01f 100644 --- a/modules/frontend/src/native_transport_service/request_delivery.rs +++ b/modules/frontend/src/native_transport_service/request_delivery.rs @@ -194,6 +194,9 @@ pub(super) async fn request_envelope_payload( expected: ExpectedResponse, cancel: &CancelHandle, ) -> Result<(ClientSession, ResponsePayload), RequestAttemptError> { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("transport.request", "request.exchange", + "request_id" => expected_request_id.as_str(), "expected" => expected.trace_name()); let (session, resolved) = session .request(envelope, cancel) @@ -268,6 +271,8 @@ impl ServiceRuntime { frames: &mut FrameFactory, force_reconnect: bool, ) -> Result<(), ServiceFailure> { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("transport.connection", "ensure_session"); let needs_reconnect = match self.session.as_ref() { Some(session) => session_needs_reconnect( session.admitted(), @@ -286,6 +291,21 @@ impl ServiceRuntime { &mut self, attempt: Result<(ClientSession, ResponsePayload), RequestAttemptError>, ) -> Result { + #[cfg(feature = "flight-recorder")] + match &attempt { + Ok(_) => artisan_tracing::instant!("transport.request", "request.succeeded"), + // The Forge answered with a refusal (a link title it could not + // fetch, a stale catalog): the connection is fine, so the + // failure is recorded but saves no incident snapshot. + Err(RequestAttemptError::Retained { failure, .. }) => { + artisan_tracing::instant!("transport.request", "request.failed", "stage" => failure.stage.to_string(), "category" => failure.category.to_string()); + } + // The session itself was lost: an incident worth the history. + Err(RequestAttemptError::Terminal { failure, .. }) => { + artisan_tracing::instant!("transport.request", "request.failed", "stage" => failure.stage.to_string(), "category" => failure.category.to_string()); + artisan_tracing::incident!("request.failed"); + } + } match attempt { Ok((session, payload)) => { self.session = Some(session); @@ -355,6 +375,8 @@ impl ServiceRuntime { expected_request_id: RequestId, expected: ExpectedResponse, ) -> Result<(ClientSession, ResponsePayload), RequestAttemptError> { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("transport.request", "request.await_response", "request_id" => expected_request_id.as_str(), "command_span" => self.trace_command); let request = request_envelope_payload( session, envelope, @@ -511,12 +533,26 @@ pub(super) async fn command_loop_with_delivery( // The hold travels with its command and drops when this arm // ends: after the handler returns, or with its failure. let Some(QueuedCommand { + #[cfg(feature = "flight-recorder")] + trace, command, hold: _hold, }) = cmd else { return Ok(()); }; + #[cfg(feature = "flight-recorder")] + let queue_span = trace.id(); + #[cfg(feature = "flight-recorder")] + trace.handoff(); + #[cfg(feature = "flight-recorder")] + drop(trace); + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("transport.command", "command.execute", "command" => command.trace_name(), "thread_id" => command.trace_thread(), "queue_span" => queue_span); + #[cfg(feature = "flight-recorder")] + { + runtime.trace_command = _trace.id(); + } match command { NativeTransportCommand::Shutdown => return Ok(()), NativeTransportCommand::BeginProjectIntakeAt(path) => { @@ -693,6 +729,10 @@ pub(super) async fn command_loop_with_delivery( handle_acknowledge_patch(runtime, &thread_id, cursor)? } } + #[cfg(feature = "flight-recorder")] + { + runtime.trace_command = 0; + } } LoopStep::Delivery(delivery) => { let recovery = forward_delivery(&mut runtime.custody, events, delivery)?; diff --git a/modules/frontend/src/native_transport_service/service_lifecycle.rs b/modules/frontend/src/native_transport_service/service_lifecycle.rs index 6e89afd6e..b56142948 100644 --- a/modules/frontend/src/native_transport_service/service_lifecycle.rs +++ b/modules/frontend/src/native_transport_service/service_lifecycle.rs @@ -82,12 +82,16 @@ impl NativeTransportService { let join = thread::Builder::new() .name("artisan-native-transport".to_owned()) .spawn(move || { + #[cfg(feature = "flight-recorder")] + artisan_tracing::name_thread("editor.transport"); let starting_sent = event_tx.send(NativeTransportEvent::Starting).is_ok(); if starting_sent { if let Ok(runtime) = tokio::runtime::Builder::new_current_thread() .enable_all() .build() { + #[cfg(feature = "flight-recorder")] + super::flight_recorder::monitor(&runtime); runtime.block_on(service_main(command_rx, event_tx, home)); } else { let _ = event_tx.send(NativeTransportEvent::Failed( @@ -138,7 +142,7 @@ impl NativeTransportService { Some(kind) => Some(self.holds.try_hold(kind).ok_or(CommandSendError::Busy)?), None => None, }; - try_send_command(&self.commands, QueuedCommand { command, hold }) + try_send_command(&self.commands, QueuedCommand::new(command, hold)) } } } @@ -162,7 +166,7 @@ impl NativeTransportService { return Err(CommandSendError::Stopped); } let hold = command.hold_kind().map(|_| parent.extend()); - try_send_command(&self.commands, QueuedCommand { command, hold }) + try_send_command(&self.commands, QueuedCommand::new(command, hold)) } /// Requests shutdown once, retaining nonblocking admission semantics. @@ -239,11 +243,24 @@ pub fn try_send_command( sender: &tokio::sync::mpsc::Sender, command: impl Into, ) -> Result<(), CommandSendError> { - match sender.try_send(command.into()) { + let command = command.into(); + #[cfg(feature = "flight-recorder")] + artisan_tracing::counter!("transport.queue", "command.depth", "queued" => sender.max_capacity() - sender.capacity()); + #[cfg(feature = "flight-recorder")] + artisan_tracing::instant!("transport.queue", "command.admission", "command" => command.command.trace_name()); + let result = match sender.try_send(command) { Ok(()) => Ok(()), Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => Err(CommandSendError::Busy), Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => Err(CommandSendError::Stopped), + }; + #[cfg(feature = "flight-recorder")] + if let Err(error) = &result { + #[cfg(feature = "flight-recorder")] + artisan_tracing::instant!("transport.queue", "command.rejected", "reason" => format!("{error:?}")); + #[cfg(feature = "flight-recorder")] + artisan_tracing::incident!("command.rejected"); } + result } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -283,6 +300,8 @@ impl ServiceRuntime { .expect("reconnect binding"); Self { preserve_reconnect: false, + #[cfg(feature = "flight-recorder")] + trace_command: 0, session: None, reconnect_lease: None, reconnect_binding: binding, @@ -427,11 +446,22 @@ async fn start_dev_service(home: &Path) -> Result<(ServiceRuntime, FrameFactory) // files the manually started backend needs; the readiness wait below // then fails honestly until that backend is up. let credentials = load_client_credentials(home).map_err(|error| { - dev_startup_failure( + let failure = dev_startup_failure( ServiceFailureStage::Credentials, "loading the client credentials", &error, - ) + ); + // Unreadable files mean the host is down or restarting (the dev home + // lives inside WSL), not that the credential was rejected: report + // Forge as offline instead of asking for a fresh invitation. + if matches!( + error, + artisan_editor_cli::credentials::ForgeCredentialError::Io { .. } + ) { + StartupError::Unreachable(ServiceFailureStage::Credentials) + } else { + failure + } })?; let ready_path = dev_endpoint::dev_ready_path(home, dev_endpoint::dev_ready_override_from_env().as_deref()); @@ -541,6 +571,8 @@ async fn start_dev_service(home: &Path) -> Result<(ServiceRuntime, FrameFactory) eprintln!("artisan dev forge: connected ({})", readiness.endpoint()); Ok(( ServiceRuntime { + #[cfg(feature = "flight-recorder")] + trace_command: 0, preserve_reconnect: false, session: Some(session), reconnect_lease: Some(reconnect_lease), diff --git a/modules/frontend/src/native_transport_service/subscriptions.rs b/modules/frontend/src/native_transport_service/subscriptions.rs index 33f8cb0ad..06700b0f8 100644 --- a/modules/frontend/src/native_transport_service/subscriptions.rs +++ b/modules/frontend/src/native_transport_service/subscriptions.rs @@ -234,6 +234,8 @@ impl ServiceRuntime { frames: &mut FrameFactory, restore_subscription: bool, ) -> Result<(), ServiceFailure> { + #[cfg(feature = "flight-recorder")] + let _trace = artisan_tracing::span!("transport.connection", "reconnect"); // Custody order: cancel delivery task, await its join exactly once, // drop the old session, check out the fenced capability, connect, // publish the rotated capability, take_delivery exactly once, and diff --git a/modules/frontend/src/parity_visual_proof.rs b/modules/frontend/src/parity_visual_proof.rs index 2adb13195..3dc1b7270 100644 --- a/modules/frontend/src/parity_visual_proof.rs +++ b/modules/frontend/src/parity_visual_proof.rs @@ -848,6 +848,7 @@ impl ParityProofShell { div().into_any_element(), body, inspector_width, + None, window.is_maximized(), ) .into_any_element() diff --git a/modules/frontend/src/recent_thread_groups.rs b/modules/frontend/src/recent_thread_groups.rs index dcff94519..0508202ca 100644 --- a/modules/frontend/src/recent_thread_groups.rs +++ b/modules/frontend/src/recent_thread_groups.rs @@ -1,15 +1,23 @@ -//! Age groups of the sidebar's recent threads. +//! Sections and age groups of the sidebar's recent threads. //! //! The Forge decides which threads are recent, orders them newest activity //! first, and resolves each row's subtitle; the Editor only sorts the rows -//! it received into age groups for presentation, as a pure function of the -//! listing and the clock. Groups keep the Forge's order, and empty groups -//! are not shown. As time passes a row moves to an older group; the next -//! instant any row crosses a boundary is [`next_regrouping`]. +//! it received for presentation, as a pure function of the listing and the +//! clock. +//! +//! The sidebar is an inbox: only threads with something the reader has not +//! seen show at the top ([`SidebarSection::Unread`]). Threads the Forge is +//! still running collapse into one `Working` group, and everything read +//! and idle collapses into `History`, which keeps its age groups inside. +//! Groups keep the Forge's order, and empty groups are not shown. As time +//! passes a row moves to an older age group; the next instant any row +//! crosses a boundary is [`next_regrouping`]. #![forbid(unsafe_code)] -use artisan_domain::{RecentThread, RecentThreadListing, UnixMillis}; +use artisan_domain::{ + RecentThread, RecentThreadListing, ThreadAttention, ThreadSummary, UnixMillis, +}; const HOUR_MS: i64 = 60 * 60 * 1_000; const DAY_MS: i64 = 24 * HOUR_MS; @@ -98,12 +106,17 @@ pub fn group_recent_threads( listing: &RecentThreadListing, now: UnixMillis, ) -> Vec> { + group_by_age(&listing.threads().iter().collect::>(), now) +} + +/// Sorts rows, kept in their given order, into non-empty age groups. +fn group_by_age<'a>(threads: &[&'a RecentThread], now: UnixMillis) -> Vec> { RecentThreadAge::ALL .into_iter() .filter_map(|age| { - let threads = listing - .threads() + let threads = threads .iter() + .copied() .filter(|thread| RecentThreadAge::of_age(age_ms(thread, now)) == age) .collect::>(); (!threads.is_empty()).then_some(RecentThreadGroup { age, threads }) @@ -111,6 +124,76 @@ pub fn group_recent_threads( .collect() } +/// Which sidebar section a thread belongs to. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum SidebarSection { + /// Something the reader has not seen: an open question or approval, or + /// an outcome that finished or failed since they last read the thread. + /// Always shown. + Unread, + /// The Forge owns a live run and nothing waits on the reader. + Working, + /// Read and idle. + History, +} + +impl SidebarSection { + /// The section of one thread. + /// + /// Mirrors the row's state dot: waiting on the reader outranks the work + /// it blocks, and a new run after an unread outcome reads as working. + /// An unread outcome stays unread while its thread is open, so opening + /// it never pulls the row out from under the pointer; leaving the + /// thread marks it read and moves it to history. + #[must_use] + pub fn of(thread: &ThreadSummary) -> Self { + match thread.attention { + ThreadAttention::AwaitingAnswer => Self::Unread, + _ if thread.has_active_work => Self::Working, + ThreadAttention::Finished | ThreadAttention::Failed => Self::Unread, + ThreadAttention::None => Self::History, + } + } +} + +/// The sidebar's recent threads sorted into sections at one instant. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SidebarSections<'a> { + /// Unread rows, newest activity first. + pub unread: Vec<&'a RecentThread>, + /// Working rows, newest activity first. + pub working: Vec<&'a RecentThread>, + /// Read and idle rows in their non-empty age groups. + pub history: Vec>, +} + +impl SidebarSections<'_> { + /// How many rows the history holds across its age groups. + #[must_use] + pub fn history_len(&self) -> usize { + self.history.iter().map(|group| group.threads.len()).sum() + } +} + +/// Sorts the listing into the sidebar's sections at `now`. +#[must_use] +pub fn section_recent_threads( + listing: &RecentThreadListing, + now: UnixMillis, +) -> SidebarSections<'_> { + let mut sections = SidebarSections::default(); + let mut history = Vec::new(); + for thread in listing.threads() { + match SidebarSection::of(&thread.thread) { + SidebarSection::Unread => sections.unread.push(thread), + SidebarSection::Working => sections.working.push(thread), + SidebarSection::History => history.push(thread), + } + } + sections.history = group_by_age(&history, now); + sections +} + /// The next instant after `now` at which any row moves to an older group; /// `None` when every row is already in the oldest group. #[must_use] @@ -127,3 +210,40 @@ pub fn next_regrouping(listing: &RecentThreadListing, now: UnixMillis) -> Option .min() .map(UnixMillis::from_millis) } + +#[cfg(test)] +mod tests { + use artisan_domain::{ProjectId, ThreadId, ThreadTitle}; + + use super::*; + + fn thread(working: bool, attention: ThreadAttention) -> ThreadSummary { + ThreadSummary { + has_started_response: true, + has_active_work: working, + attention, + last_message_at: None, + thread_id: ThreadId::parse("thread").expect("thread id"), + project_id: ProjectId::parse("project").expect("project id"), + title: ThreadTitle::parse("Thread").expect("title"), + created_at: UnixMillis::from_millis(1), + updated_at: UnixMillis::from_millis(1), + } + } + + #[test] + fn only_unseen_threads_stay_out_of_the_collapsed_groups() { + let of = |working, attention| SidebarSection::of(&thread(working, attention)); + assert_eq!(of(false, ThreadAttention::Finished), SidebarSection::Unread); + assert_eq!(of(false, ThreadAttention::Failed), SidebarSection::Unread); + // The reader's turn outranks the work it blocks. + assert_eq!( + of(true, ThreadAttention::AwaitingAnswer), + SidebarSection::Unread + ); + assert_eq!(of(true, ThreadAttention::None), SidebarSection::Working); + // A new run after an unread outcome reads as working. + assert_eq!(of(true, ThreadAttention::Finished), SidebarSection::Working); + assert_eq!(of(false, ThreadAttention::None), SidebarSection::History); + } +} diff --git a/modules/frontend/src/thread_agents.rs b/modules/frontend/src/thread_agents.rs index 48143de57..4d2dcc42b 100644 --- a/modules/frontend/src/thread_agents.rs +++ b/modules/frontend/src/thread_agents.rs @@ -8,9 +8,10 @@ //! [`EngineObservationState`]; it holds no state of its own and asks the //! backend for nothing. //! -//! Only the run the thread is on is listed: its subagents while it works, -//! and the same list, settled, once it finishes. A new run starts the list -//! over, so the section never accumulates a whole thread's history. +//! Only the run the thread is on is listed, and only while it is live: once +//! it settles the section clears (see the inspector sync). A new run starts +//! the list over, so the section never accumulates a whole thread's +//! history. #![forbid(unsafe_code)] diff --git a/modules/frontend/src/thread_screen.rs b/modules/frontend/src/thread_screen.rs index aa972ad2c..b2a3ea163 100644 --- a/modules/frontend/src/thread_screen.rs +++ b/modules/frontend/src/thread_screen.rs @@ -70,7 +70,8 @@ use gpui::{ use crate::conversation_host::{ConversationHost, ConversationHostError}; use crate::desktop_shell::{ - DESKTOP_COLUMN_INSET_PX, DESKTOP_SIDEBAR_WIDTH_PX, DesktopShellStyle, desktop_section_label, + DESKTOP_COLUMN_INSET_PX, DESKTOP_CROSSHAIR_SIZE_PX, DESKTOP_SIDEBAR_WIDTH_PX, + DesktopShellStyle, desktop_section_label, junction_crosshair, }; use crate::native_composer::NativeComposer; use crate::shell_layout::{ProseWidth, desktop_inspector_pixels}; @@ -123,6 +124,14 @@ pub const THREAD_SCREEN_INSPECTOR_SELECTOR: &str = "artisan-thread-screen-inspec /// Stable debug selector for the inspector column's one-device-pixel left /// rule. pub const THREAD_SCREEN_INSPECTOR_RULE_SELECTOR: &str = "artisan-thread-screen-inspector-rule"; +/// Rule closing the inspector's Context section, edge to edge. +pub const THREAD_SCREEN_CONTEXT_RULE_SELECTOR: &str = "artisan-thread-screen-context-rule"; +/// The rule closing the Agents section. +pub const THREAD_SCREEN_AGENTS_RULE_SELECTOR: &str = "artisan-thread-screen-agents-rule"; +/// The rule closing the Checklist section. +pub const THREAD_SCREEN_CHECKLIST_RULE_SELECTOR: &str = "artisan-thread-screen-checklist-rule"; +/// The rule closing the Terminals section. +pub const THREAD_SCREEN_TERMINALS_RULE_SELECTOR: &str = "artisan-thread-screen-terminals-rule"; /// Stable debug selector for the composer dock. pub const THREAD_SCREEN_COMPOSER_SELECTOR: &str = "artisan-thread-screen-composer-dock"; @@ -1076,6 +1085,42 @@ mod tests { assert_eq!(hidden_width, None, "a hidden column publishes no junction"); } + /// A one-device-pixel rule closes the Context section across the whole + /// column, the column inset below its last row, like a grid line. + #[gpui::test] + fn context_rule_spans_the_column_under_its_rows(cx: &mut gpui::TestAppContext) { + let (_view, cx) = cx.add_window_view(|_, cx| { + mount_proof_screen("shell-proof-context-rule", EXPANDED_WIDE_CONTENT, cx) + }); + cx.run_until_parked(); + let inspector = cx + .debug_bounds(THREAD_SCREEN_INSPECTOR_SELECTOR) + .expect("inspector lays out"); + let section = cx + .debug_bounds(THREAD_SCREEN_CONTEXT_SELECTOR) + .expect("context section lays out"); + let rule = cx + .debug_bounds(THREAD_SCREEN_CONTEXT_RULE_SELECTOR) + .expect("context rule paints"); + let one_device_pixel = cx.update(|window, _| { + DesktopShellStyle::resolve(false, window.scale_factor()).one_device_pixel + }); + assert_eq!( + rule.origin.x, inspector.origin.x, + "the rule starts at the left rule" + ); + assert_eq!( + rule.size.width, inspector.size.width, + "the rule runs edge to edge" + ); + assert_eq!(rule.size.height, one_device_pixel); + assert_eq!( + rule.origin.y, + section.bottom() + px(DESKTOP_COLUMN_INSET_PX), + "the rule sits the column inset below the last row" + ); + } + /// The mounted column crosses the 1104 px threshold in both directions: /// exactly at it the 240 px minimum column seats, one pixel below it is /// gone with no reserved space, and it returns on widening. @@ -1193,6 +1238,27 @@ mod tests { tops.windows(2).all(|pair| pair[0] < pair[1]), "sections must read Context, Agents, Checklist, Terminals: {tops:?}" ); + // Every section closes with the grid rule, the column inset below it. + for (section, rule) in [ + ( + THREAD_SCREEN_AGENTS_SELECTOR, + THREAD_SCREEN_AGENTS_RULE_SELECTOR, + ), + ( + THREAD_SCREEN_CHECKLIST_SELECTOR, + THREAD_SCREEN_CHECKLIST_RULE_SELECTOR, + ), + ( + THREAD_SCREEN_TERMINALS_SELECTOR, + THREAD_SCREEN_TERMINALS_RULE_SELECTOR, + ), + ] { + let bounds = cx.debug_bounds(section).expect("section lays out"); + let rule = cx + .debug_bounds(rule) + .unwrap_or_else(|| panic!("{section} closes with a rule")); + assert_eq!(rule.origin.y, bounds.bottom() + px(DESKTOP_COLUMN_INSET_PX)); + } update_screen(&view, cx, |screen| { screen.set_terminals(Vec::new()); @@ -1213,6 +1279,11 @@ mod tests { assert!(cx.debug_bounds(THREAD_SCREEN_AGENTS_SELECTOR).is_none()); assert!(cx.debug_bounds(THREAD_SCREEN_CHECKLIST_SELECTOR).is_none()); assert!(cx.debug_bounds(THREAD_SCREEN_TERMINALS_SELECTOR).is_none()); + assert!( + cx.debug_bounds(THREAD_SCREEN_AGENTS_RULE_SELECTOR) + .is_none(), + "a hidden section takes its rule with it" + ); } /// The Project row appears only once a project label is published, and diff --git a/modules/frontend/src/thread_screen/render.rs b/modules/frontend/src/thread_screen/render.rs index 7eb7745c7..bbb7c06f6 100644 --- a/modules/frontend/src/thread_screen/render.rs +++ b/modules/frontend/src/thread_screen/render.rs @@ -489,22 +489,48 @@ impl ThreadScreen { /// plain absolute element with no pointer listener, so it cannot /// intercept input. The titlebar end of that rule is the shell's /// junction crosshair, fed by [`ThreadScreen::visible_inspector_width`]. + /// + /// A rule closes every section across the column's full width, so the + /// inspector reads as a grid with the titlebar rule above it; each + /// junction with the left rule carries the shell's crosshair. fn render_inspector(&self, theme: &ArtisanTheme, width: f32, rule: Pixels) -> impl IntoElement { + let section_gap = theme.spacing.steps(3.0); let mut sections = div() .flex() .min_h_0() .flex_1() .flex_col() - .gap(theme.spacing.steps(3.0)) - .child(self.render_context_section(theme)); - if let Some(agents) = self.render_agents_section(theme) { - sections = sections.child(agents); - } - if let Some(checklist) = self.render_checklist_section(theme) { - sections = sections.child(checklist); - } - if let Some(terminals) = self.render_terminals_section(theme) { - sections = sections.child(terminals); + .gap(section_gap) + .child(self.render_context_section(theme)) + .child(Self::render_section_rule( + width, + rule, + section_gap, + THREAD_SCREEN_CONTEXT_RULE_SELECTOR, + )); + let closed = [ + ( + self.render_agents_section(theme), + THREAD_SCREEN_AGENTS_RULE_SELECTOR, + ), + ( + self.render_checklist_section(theme), + THREAD_SCREEN_CHECKLIST_RULE_SELECTOR, + ), + ( + self.render_terminals_section(theme), + THREAD_SCREEN_TERMINALS_RULE_SELECTOR, + ), + ]; + for (section, rule_selector) in closed { + if let Some(section) = section { + sections = sections.child(section).child(Self::render_section_rule( + width, + rule, + section_gap, + rule_selector, + )); + } } div() .relative() @@ -529,6 +555,41 @@ impl ThreadScreen { .child(sections) } + /// The full-width rule closing one inspector section. + /// + /// It escapes the column padding to run edge to edge, and sits the + /// column inset away from the rows on both sides (the section gap plus + /// a signed margin), the spacing the titlebar rule keeps above the + /// first row. + /// The crosshair centres both arms on the junction with the left rule; + /// like that rule it is a plain element that cannot intercept input. + fn render_section_rule( + width: f32, + rule: Pixels, + section_gap: Pixels, + selector: &'static str, + ) -> impl IntoElement { + let line = DesktopTheme::neutral_dark(); + // Negative when the gap exceeds the inset: the rule then pulls both + // neighbours in so it still sits exactly the inset from each. + let breathing = px(DESKTOP_COLUMN_INSET_PX) - section_gap; + let arm_offset = (px(DESKTOP_CROSSHAIR_SIZE_PX) - rule) / 2.0; + div() + .relative() + .flex_none() + .w(px(width)) + .h(rule) + .ml(px(-DESKTOP_COLUMN_INSET_PX)) + .my(breathing) + .bg(line.line) + .debug_selector(move || selector.to_owned()) + .child( + junction_crosshair(line, rule) + .left(-arm_offset) + .top(-arm_offset), + ) + } + /// Renders the composer overlay frame around the packet-2 composer surface. /// /// Legacy frame (`thread-composer.svelte:526`): `prose-column-frame diff --git a/modules/protocol/schema/artisan.capnp b/modules/protocol/schema/artisan.capnp index 690f9af88..0af67d115 100644 --- a/modules/protocol/schema/artisan.capnp +++ b/modules/protocol/schema/artisan.capnp @@ -259,6 +259,11 @@ struct RecentThread { # At most 256 UTF-8 bytes, nonblank. subtitle @1 :Text; + # Empty for disk-only projects. Optional fields retain compatibility with + # peers that predate project icons. + repositoryHost @2 :Text; + # Normalized 48-pixel PNG, at most 16 KiB. Empty selects the host logo. + projectIconPng @3 :Data; } # Recently active threads across every attached project, newest activity @@ -922,6 +927,9 @@ struct Request { # scrolls toward them, or a settled turn's held-back work rows when its # section opens. Fresh ordinal; existing ordinals frozen. conversationHistory @51 :ConversationHistoryRequest; + + # Explicit model preference, answered with UserPreferences. + saveModelPreference @52 :SaveModelPreferenceRequest; } } @@ -3022,3 +3030,15 @@ struct EarlierTurnMarker { turnOrdinal @1 :UInt64; label @2 :Text; } + +# Agent is the wire default so omitted provenance cannot change user choices. +enum ModelPreferenceSource { + agent @0; + user @1; + system @2; +} + +struct SaveModelPreferenceRequest { + selection @0 :ComposerState.CatalogSelection; + source @1 :ModelPreferenceSource; +} diff --git a/modules/protocol/src/artisan_capnp.rs b/modules/protocol/src/artisan_capnp.rs index 520b3ccc6..e36643211 100644 --- a/modules/protocol/src/artisan_capnp.rs +++ b/modules/protocol/src/artisan_capnp.rs @@ -2912,11 +2912,27 @@ pub mod recent_thread { pub fn has_subtitle(&self) -> bool { !self.reader.get_pointer_field(1).is_null() } + #[inline] + pub fn get_repository_host(self) -> ::capnp::Result<::capnp::text::Reader<'a>> { + ::capnp::traits::FromPointerReader::get_from_pointer(&self.reader.get_pointer_field(2), ::core::option::Option::None) + } + #[inline] + pub fn has_repository_host(&self) -> bool { + !self.reader.get_pointer_field(2).is_null() + } + #[inline] + pub fn get_project_icon_png(self) -> ::capnp::Result<::capnp::data::Reader<'a>> { + ::capnp::traits::FromPointerReader::get_from_pointer(&self.reader.get_pointer_field(3), ::core::option::Option::None) + } + #[inline] + pub fn has_project_icon_png(&self) -> bool { + !self.reader.get_pointer_field(3).is_null() + } } pub struct Builder<'a> { builder: ::capnp::private::layout::StructBuilder<'a> } impl <> ::capnp::traits::HasStructSize for Builder<'_,> { - const STRUCT_SIZE: ::capnp::private::layout::StructSize = ::capnp::private::layout::StructSize { data: 0, pointers: 2 }; + const STRUCT_SIZE: ::capnp::private::layout::StructSize = ::capnp::private::layout::StructSize { data: 0, pointers: 4 }; } impl <> ::capnp::traits::HasTypeId for Builder<'_,> { const TYPE_ID: u64 = _private::TYPE_ID; @@ -2998,6 +3014,38 @@ pub mod recent_thread { pub fn has_subtitle(&self) -> bool { !self.builder.is_pointer_field_null(1) } + #[inline] + pub fn get_repository_host(self) -> ::capnp::Result<::capnp::text::Builder<'a>> { + ::capnp::traits::FromPointerBuilder::get_from_pointer(self.builder.get_pointer_field(2), ::core::option::Option::None) + } + #[inline] + pub fn set_repository_host(&mut self, value: impl ::capnp::traits::SetterInput<::capnp::text::Owned>) { + ::capnp::traits::SetterInput::set_pointer_builder(self.builder.reborrow().get_pointer_field(2), value, false).unwrap() + } + #[inline] + pub fn init_repository_host(self, size: u32) -> ::capnp::text::Builder<'a> { + self.builder.get_pointer_field(2).init_text(size) + } + #[inline] + pub fn has_repository_host(&self) -> bool { + !self.builder.is_pointer_field_null(2) + } + #[inline] + pub fn get_project_icon_png(self) -> ::capnp::Result<::capnp::data::Builder<'a>> { + ::capnp::traits::FromPointerBuilder::get_from_pointer(self.builder.get_pointer_field(3), ::core::option::Option::None) + } + #[inline] + pub fn set_project_icon_png(&mut self, value: ::capnp::data::Reader<'_>) { + self.builder.reborrow().get_pointer_field(3).set_data(value); + } + #[inline] + pub fn init_project_icon_png(self, size: u32) -> ::capnp::data::Builder<'a> { + self.builder.get_pointer_field(3).init_data(size) + } + #[inline] + pub fn has_project_icon_png(&self) -> bool { + !self.builder.is_pointer_field_null(3) + } } pub struct Pipeline { _typeless: ::capnp::any_pointer::Pipeline } @@ -3012,18 +3060,18 @@ pub mod recent_thread { } } mod _private { - pub(crate) static ENCODED_NODE: [::capnp::Word; 50] = [ + pub(crate) static ENCODED_NODE: [::capnp::Word; 82] = [ ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), ::capnp::word(228, 225, 20, 84, 81, 189, 39, 174), ::capnp::word(14, 0, 0, 0, 1, 0, 0, 0), ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), - ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), + ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(131, 39, 0, 0, 243, 39, 0, 0), + ::capnp::word(131, 39, 0, 0, 229, 40, 0, 0), ::capnp::word(21, 0, 0, 0, 218, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(29, 0, 0, 0, 119, 0, 0, 0), + ::capnp::word(29, 0, 0, 0, 231, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(97, 114, 116, 105, 115, 97, 110, 46), @@ -3031,21 +3079,35 @@ pub mod recent_thread { ::capnp::word(99, 101, 110, 116, 84, 104, 114, 101), ::capnp::word(97, 100, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 1, 0, 1, 0), - ::capnp::word(8, 0, 0, 0, 3, 0, 4, 0), + ::capnp::word(16, 0, 0, 0, 3, 0, 4, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(41, 0, 0, 0, 58, 0, 0, 0), + ::capnp::word(97, 0, 0, 0, 58, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(36, 0, 0, 0, 3, 0, 1, 0), - ::capnp::word(48, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(92, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(104, 0, 0, 0, 2, 0, 1, 0), ::capnp::word(1, 0, 0, 0, 1, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 1, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(45, 0, 0, 0, 74, 0, 0, 0), + ::capnp::word(101, 0, 0, 0, 74, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(44, 0, 0, 0, 3, 0, 1, 0), - ::capnp::word(56, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(100, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(112, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(2, 0, 0, 0, 2, 0, 0, 0), + ::capnp::word(0, 0, 1, 0, 2, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(109, 0, 0, 0, 122, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(108, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(120, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(3, 0, 0, 0, 3, 0, 0, 0), + ::capnp::word(0, 0, 1, 0, 3, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(117, 0, 0, 0, 122, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(116, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(128, 0, 0, 0, 2, 0, 1, 0), ::capnp::word(116, 104, 114, 101, 97, 100, 0, 0), ::capnp::word(16, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(199, 10, 71, 141, 159, 209, 200, 193), @@ -3063,11 +3125,31 @@ pub mod recent_thread { ::capnp::word(12, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(114, 101, 112, 111, 115, 105, 116, 111), + ::capnp::word(114, 121, 72, 111, 115, 116, 0, 0), + ::capnp::word(12, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(12, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(112, 114, 111, 106, 101, 99, 116, 73), + ::capnp::word(99, 111, 110, 80, 110, 103, 0, 0), + ::capnp::word(13, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(13, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ]; pub(crate) fn get_field_types(index: u16) -> ::capnp::introspect::Type { match index { 0 => ::introspect(), 1 => <::capnp::text::Owned as ::capnp::introspect::Introspect>::introspect(), + 2 => <::capnp::text::Owned as ::capnp::introspect::Introspect>::introspect(), + 3 => <::capnp::data::Owned as ::capnp::introspect::Introspect>::introspect(), _ => ::capnp::introspect::panic_invalid_field_index(index), } } @@ -3081,9 +3163,9 @@ pub mod recent_thread { MEMBERS_BY_DISCRIMINANT, MEMBERS_BY_NAME ); - pub(crate) static NONUNION_MEMBERS : &[u16] = &[0,1]; + pub(crate) static NONUNION_MEMBERS : &[u16] = &[0,1,2,3]; pub(crate) static MEMBERS_BY_DISCRIMINANT : &[u16] = &[]; - pub(crate) static MEMBERS_BY_NAME : &[u16] = &[1,0]; + pub(crate) static MEMBERS_BY_NAME : &[u16] = &[3,2,1,0]; pub(crate) const TYPE_ID: u64 = 0xae27_bd51_5414_e1e4; } } @@ -3245,7 +3327,7 @@ pub mod recent_thread_list { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(120, 40, 0, 0, 181, 40, 0, 0), + ::capnp::word(106, 41, 0, 0, 167, 41, 0, 0), ::capnp::word(21, 0, 0, 0, 250, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -3458,7 +3540,7 @@ pub mod project_list { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(64, 42, 0, 0, 116, 42, 0, 0), + ::capnp::word(50, 43, 0, 0, 102, 43, 0, 0), ::capnp::word(21, 0, 0, 0, 210, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -3687,7 +3769,7 @@ pub mod create_project_thread_result { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(37, 43, 0, 0, 139, 43, 0, 0), + ::capnp::word(23, 44, 0, 0, 125, 44, 0, 0), ::capnp::word(21, 0, 0, 0, 66, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -3986,7 +4068,7 @@ pub mod first_message_receipt { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(187, 43, 0, 0, 155, 45, 0, 0), + ::capnp::word(173, 44, 0, 0, 141, 46, 0, 0), ::capnp::word(21, 0, 0, 0, 18, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -4336,7 +4418,7 @@ pub mod first_message_queued { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(208, 45, 0, 0, 1, 48, 0, 0), + ::capnp::word(194, 46, 0, 0, 243, 48, 0, 0), ::capnp::word(21, 0, 0, 0, 10, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -4626,7 +4708,7 @@ pub mod hello { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(64, 49, 0, 0, 162, 52, 0, 0), + ::capnp::word(50, 50, 0, 0, 148, 53, 0, 0), ::capnp::word(21, 0, 0, 0, 162, 0, 0, 0), ::capnp::word(29, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -5198,7 +5280,7 @@ pub mod welcome { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(22, 53, 0, 0, 249, 56, 0, 0), + ::capnp::word(8, 54, 0, 0, 235, 57, 0, 0), ::capnp::word(21, 0, 0, 0, 178, 0, 0, 0), ::capnp::word(29, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -5438,7 +5520,7 @@ pub mod lifecycle_status_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(154, 58, 0, 0, 186, 58, 0, 0), + ::capnp::word(140, 59, 0, 0, 172, 59, 0, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -5617,7 +5699,7 @@ pub mod lifecycle_stop_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(85, 59, 0, 0, 140, 59, 0, 0), + ::capnp::word(71, 60, 0, 0, 126, 60, 0, 0), ::capnp::word(21, 0, 0, 0, 26, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -5878,7 +5960,7 @@ pub mod lifecycle_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 2, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(215, 59, 0, 0, 74, 60, 0, 0), + ::capnp::word(201, 60, 0, 0, 60, 61, 0, 0), ::capnp::word(21, 0, 0, 0, 250, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -5992,7 +6074,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 32] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(145, 60, 0, 0, 206, 60, 0, 0), + ::capnp::word(131, 61, 0, 0, 192, 61, 0, 0), ::capnp::word(21, 0, 0, 0, 234, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -6182,7 +6264,7 @@ pub mod lifecycle_status { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(9, 61, 0, 0, 93, 61, 0, 0), + ::capnp::word(251, 61, 0, 0, 79, 62, 0, 0), ::capnp::word(21, 0, 0, 0, 242, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -6291,7 +6373,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 35] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(133, 61, 0, 0, 219, 61, 0, 0), + ::capnp::word(119, 62, 0, 0, 205, 62, 0, 0), ::capnp::word(21, 0, 0, 0, 58, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -6484,7 +6566,7 @@ pub mod lifecycle_stop_receipt { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(42, 62, 0, 0, 145, 62, 0, 0), + ::capnp::word(28, 63, 0, 0, 131, 63, 0, 0), ::capnp::word(21, 0, 0, 0, 26, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -6761,7 +6843,7 @@ pub mod lifecycle_response { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 2, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(222, 62, 0, 0, 75, 63, 0, 0), + ::capnp::word(208, 63, 0, 0, 61, 64, 0, 0), ::capnp::word(21, 0, 0, 0, 2, 1, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -6985,7 +7067,7 @@ pub mod list_directories_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(129, 66, 0, 0, 119, 67, 0, 0), + ::capnp::word(115, 67, 0, 0, 105, 68, 0, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -7455,7 +7537,7 @@ pub mod attach_project_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(121, 67, 0, 0, 62, 68, 0, 0), + ::capnp::word(107, 68, 0, 0, 48, 69, 0, 0), ::capnp::word(21, 0, 0, 0, 26, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -7666,7 +7748,7 @@ pub mod list_project_threads_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(64, 68, 0, 0, 190, 68, 0, 0), + ::capnp::word(50, 69, 0, 0, 176, 69, 0, 0), ::capnp::word(21, 0, 0, 0, 66, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -7901,7 +7983,7 @@ pub mod create_project_thread_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(192, 68, 0, 0, 152, 69, 0, 0), + ::capnp::word(178, 69, 0, 0, 138, 70, 0, 0), ::capnp::word(21, 0, 0, 0, 74, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -8153,7 +8235,7 @@ pub mod queue_first_message_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(154, 69, 0, 0, 100, 70, 0, 0), + ::capnp::word(140, 70, 0, 0, 86, 71, 0, 0), ::capnp::word(21, 0, 0, 0, 58, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -8356,7 +8438,7 @@ pub mod list_attached_projects_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(67, 71, 0, 0, 104, 71, 0, 0), + ::capnp::word(53, 72, 0, 0, 90, 72, 0, 0), ::capnp::word(21, 0, 0, 0, 82, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -8602,7 +8684,7 @@ pub mod set_thread_engine_config_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(106, 71, 0, 0, 241, 71, 0, 0), + ::capnp::word(92, 72, 0, 0, 227, 72, 0, 0), ::capnp::word(21, 0, 0, 0, 90, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -8859,7 +8941,7 @@ pub mod engine_config_precondition { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(243, 71, 0, 0, 62, 72, 0, 0), + ::capnp::word(229, 72, 0, 0, 48, 73, 0, 0), ::capnp::word(21, 0, 0, 0, 58, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -9278,7 +9360,7 @@ pub mod engine_run_config { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(8, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(64, 72, 0, 0, 34, 75, 0, 0), + ::capnp::word(50, 73, 0, 0, 20, 76, 0, 0), ::capnp::word(21, 0, 0, 0, 242, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -9643,7 +9725,7 @@ pub mod engine_variant { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(36, 75, 0, 0, 92, 75, 0, 0), + ::capnp::word(22, 76, 0, 0, 78, 76, 0, 0), ::capnp::word(21, 0, 0, 0, 226, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -9988,7 +10070,7 @@ pub mod engine_permission_policy { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(6, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(94, 75, 0, 0, 2, 76, 0, 0), + ::capnp::word(80, 76, 0, 0, 244, 76, 0, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -10426,7 +10508,7 @@ pub mod engine_runtime_controls { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(4, 76, 0, 0, 214, 77, 0, 0), + ::capnp::word(246, 76, 0, 0, 200, 78, 0, 0), ::capnp::word(21, 0, 0, 0, 34, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -10988,7 +11070,7 @@ pub mod engine_selection_v2 { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 5, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(244, 78, 0, 0, 196, 79, 0, 0), + ::capnp::word(230, 79, 0, 0, 182, 80, 0, 0), ::capnp::word(21, 0, 0, 0, 2, 1, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -11381,7 +11463,7 @@ pub mod codex_engine_selection { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(5, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(198, 79, 0, 0, 141, 80, 0, 0), + ::capnp::word(184, 80, 0, 0, 127, 81, 0, 0), ::capnp::word(21, 0, 0, 0, 26, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -11800,7 +11882,7 @@ pub mod claude_engine_selection { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(5, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(143, 80, 0, 0, 94, 81, 0, 0), + ::capnp::word(129, 81, 0, 0, 80, 82, 0, 0), ::capnp::word(21, 0, 0, 0, 34, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -12210,7 +12292,7 @@ pub mod grok_engine_selection { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(5, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(96, 81, 0, 0, 8, 82, 0, 0), + ::capnp::word(82, 82, 0, 0, 250, 82, 0, 0), ::capnp::word(21, 0, 0, 0, 18, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -12611,7 +12693,7 @@ pub mod cursor_engine_selection { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(6, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(10, 82, 0, 0, 198, 82, 0, 0), + ::capnp::word(252, 82, 0, 0, 184, 83, 0, 0), ::capnp::word(21, 0, 0, 0, 34, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -12953,7 +13035,7 @@ pub mod set_thread_engine_config_result { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(200, 82, 0, 0, 86, 83, 0, 0), + ::capnp::word(186, 83, 0, 0, 72, 84, 0, 0), ::capnp::word(21, 0, 0, 0, 82, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -13216,7 +13298,7 @@ pub mod read_thread_engine_settings_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(88, 83, 0, 0, 151, 83, 0, 0), + ::capnp::word(74, 84, 0, 0, 137, 84, 0, 0), ::capnp::word(21, 0, 0, 0, 114, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -13445,7 +13527,7 @@ pub mod thread_engine_settings_result { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(153, 83, 0, 0, 54, 84, 0, 0), + ::capnp::word(139, 84, 0, 0, 40, 85, 0, 0), ::capnp::word(21, 0, 0, 0, 74, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -13949,7 +14031,7 @@ pub mod configured_thread_engine_settings { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(56, 84, 0, 0, 150, 84, 0, 0), + ::capnp::word(42, 85, 0, 0, 136, 85, 0, 0), ::capnp::word(21, 0, 0, 0, 106, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -14153,7 +14235,7 @@ pub mod list_registered_engine_profiles_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(152, 84, 0, 0, 197, 84, 0, 0), + ::capnp::word(138, 85, 0, 0, 183, 85, 0, 0), ::capnp::word(21, 0, 0, 0, 146, 1, 0, 0), ::capnp::word(45, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -14339,7 +14421,7 @@ pub mod registered_engine_profiles_result { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(199, 84, 0, 0, 88, 85, 0, 0), + ::capnp::word(185, 85, 0, 0, 74, 86, 0, 0), ::capnp::word(21, 0, 0, 0, 106, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -14812,7 +14894,7 @@ pub mod registered_engine_profile_list { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(90, 85, 0, 0, 157, 85, 0, 0), + ::capnp::word(76, 86, 0, 0, 143, 86, 0, 0), ::capnp::word(21, 0, 0, 0, 82, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -15052,7 +15134,7 @@ pub mod read_composer_catalog_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(125, 86, 0, 0, 205, 86, 0, 0), + ::capnp::word(111, 87, 0, 0, 191, 87, 0, 0), ::capnp::word(21, 0, 0, 0, 74, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -15365,7 +15447,7 @@ pub mod set_model_favorite_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(103, 87, 0, 0, 249, 87, 0, 0), + ::capnp::word(89, 88, 0, 0, 235, 88, 0, 0), ::capnp::word(21, 0, 0, 0, 50, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -15691,7 +15773,7 @@ pub mod composer_catalog_result { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(200, 88, 0, 0, 44, 89, 0, 0), + ::capnp::word(186, 89, 0, 0, 30, 90, 0, 0), ::capnp::word(21, 0, 0, 0, 34, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -15948,7 +16030,7 @@ pub mod model_favorites_snapshot { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(236, 89, 0, 0, 63, 90, 0, 0), + ::capnp::word(222, 90, 0, 0, 49, 91, 0, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -16255,7 +16337,7 @@ pub mod set_model_favorite_receipt { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(35, 91, 0, 0, 209, 91, 0, 0), + ::capnp::word(21, 92, 0, 0, 195, 92, 0, 0), ::capnp::word(21, 0, 0, 0, 50, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -16533,7 +16615,7 @@ pub mod resolve_rich_link_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(249, 92, 0, 0, 42, 93, 0, 0), + ::capnp::word(235, 93, 0, 0, 28, 94, 0, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -16803,7 +16885,7 @@ pub mod rich_link_page_metadata { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(27, 95, 0, 0, 191, 95, 0, 0), + ::capnp::word(13, 96, 0, 0, 177, 96, 0, 0), ::capnp::word(21, 0, 0, 0, 26, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -16960,7 +17042,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 58] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(107, 96, 0, 0, 253, 96, 0, 0), + ::capnp::word(93, 97, 0, 0, 239, 97, 0, 0), ::capnp::word(21, 0, 0, 0, 234, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -17059,7 +17141,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 34] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(121, 97, 0, 0, 193, 97, 0, 0), + ::capnp::word(107, 98, 0, 0, 179, 98, 0, 0), ::capnp::word(21, 0, 0, 0, 26, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -17263,7 +17345,7 @@ pub mod repository_branch { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(195, 97, 0, 0, 16, 98, 0, 0), + ::capnp::word(181, 98, 0, 0, 2, 99, 0, 0), ::capnp::word(21, 0, 0, 0, 250, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -17548,7 +17630,7 @@ pub mod repository_remote { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(249, 98, 0, 0, 99, 99, 0, 0), + ::capnp::word(235, 99, 0, 0, 85, 100, 0, 0), ::capnp::word(21, 0, 0, 0, 250, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -17856,7 +17938,7 @@ pub mod repository_snapshot { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(47, 100, 0, 0, 171, 100, 0, 0), + ::capnp::word(33, 101, 0, 0, 157, 101, 0, 0), ::capnp::word(21, 0, 0, 0, 10, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -17984,7 +18066,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 30] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(112, 101, 0, 0, 180, 101, 0, 0), + ::capnp::word(98, 102, 0, 0, 166, 102, 0, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -18187,7 +18269,7 @@ pub mod project_repository { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(182, 101, 0, 0, 25, 102, 0, 0), + ::capnp::word(168, 102, 0, 0, 11, 103, 0, 0), ::capnp::word(21, 0, 0, 0, 2, 1, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -18440,7 +18522,7 @@ pub mod project_repository_entry { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(90, 102, 0, 0, 181, 102, 0, 0), + ::capnp::word(76, 103, 0, 0, 167, 103, 0, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -18668,7 +18750,7 @@ pub mod project_repository_query { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(54, 103, 0, 0, 116, 103, 0, 0), + ::capnp::word(40, 104, 0, 0, 102, 104, 0, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -18883,7 +18965,7 @@ pub mod project_repository_query_result { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(38, 104, 0, 0, 126, 104, 0, 0), + ::capnp::word(24, 105, 0, 0, 112, 105, 0, 0), ::capnp::word(21, 0, 0, 0, 90, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -18943,7 +19025,7 @@ pub mod project_repository_query_result { } pub mod request { - pub use self::Which::{ListDirectories,AttachProject,ListProjectThreads,CreateProjectThread,QueueFirstMessage,ListAttachedProjects,ConversationQuery,ConversationSubscribe,ConversationUnsubscribe,PickDirectory,LifecycleControl,SetThreadEngineConfig,ReadThreadEngineSettings,ListRegisteredEngineProfiles,QueueMessage,ReadMessageImage,StopRun,ReadActiveRun,ReadComposerCatalog,ReadModelFavorites,SetModelFavorite,ListQueuedMessages,WithdrawQueuedMessage,ReadRecalledMessage,ReadRunUsage,ReadAccountUsage,RespondApproval,RespondQuestion,ListFailedMessages,ResolveRichLink,QueryProjectRepository,ValidateDirectory,SaveComposerDraft,ReadComposerDraft,UploadComposerAttachment,ReadComposerAttachment,QueueStoredMessage,RetryFailedMessage,RecoverFailedMessage,SubmitComposerDraft,ReadHostCatalog,ResolveModelSelection,ReadUserPreferences,RecordNavigation,ImportLegacyPreferences,ResolveEngineConfiguration,ReadRecentThreads,ReadEngineInstalls,ListEngineVersions,ChangeEngineVersion,AnswerQuestions,ConversationHistory}; + pub use self::Which::{ListDirectories,AttachProject,ListProjectThreads,CreateProjectThread,QueueFirstMessage,ListAttachedProjects,ConversationQuery,ConversationSubscribe,ConversationUnsubscribe,PickDirectory,LifecycleControl,SetThreadEngineConfig,ReadThreadEngineSettings,ListRegisteredEngineProfiles,QueueMessage,ReadMessageImage,StopRun,ReadActiveRun,ReadComposerCatalog,ReadModelFavorites,SetModelFavorite,ListQueuedMessages,WithdrawQueuedMessage,ReadRecalledMessage,ReadRunUsage,ReadAccountUsage,RespondApproval,RespondQuestion,ListFailedMessages,ResolveRichLink,QueryProjectRepository,ValidateDirectory,SaveComposerDraft,ReadComposerDraft,UploadComposerAttachment,ReadComposerAttachment,QueueStoredMessage,RetryFailedMessage,RecoverFailedMessage,SubmitComposerDraft,ReadHostCatalog,ResolveModelSelection,ReadUserPreferences,RecordNavigation,ImportLegacyPreferences,ResolveEngineConfiguration,ReadRecentThreads,ReadEngineInstalls,ListEngineVersions,ChangeEngineVersion,AnswerQuestions,ConversationHistory,SaveModelPreference}; #[derive(Copy, Clone)] pub struct Owned(()); @@ -19236,6 +19318,11 @@ pub mod request { !self.reader.get_pointer_field(0).is_null() } #[inline] + pub fn has_save_model_preference(&self) -> bool { + if self.reader.get_data_field::(0) != 52 { return false; } + !self.reader.get_pointer_field(0).is_null() + } + #[inline] pub fn which(self) -> ::core::result::Result, ::capnp::NotInSchema> { match self.reader.get_data_field::(0) { 0 => { @@ -19498,6 +19585,11 @@ pub mod request { ::capnp::traits::FromPointerReader::get_from_pointer(&self.reader.get_pointer_field(0), ::core::option::Option::None) )) } + 52 => { + ::core::result::Result::Ok(SaveModelPreference( + ::capnp::traits::FromPointerReader::get_from_pointer(&self.reader.get_pointer_field(0), ::core::option::Option::None) + )) + } x => ::core::result::Result::Err(::capnp::NotInSchema(x)) } } @@ -20270,6 +20362,21 @@ pub mod request { !self.builder.is_pointer_field_null(0) } #[inline] + pub fn set_save_model_preference(&mut self, value: crate::artisan_capnp::save_model_preference_request::Reader<'_>) -> ::capnp::Result<()> { + self.builder.set_data_field::(0, 52); + ::capnp::traits::SetterInput::set_pointer_builder(self.builder.reborrow().get_pointer_field(0), value, false) + } + #[inline] + pub fn init_save_model_preference(self, ) -> crate::artisan_capnp::save_model_preference_request::Builder<'a> { + self.builder.set_data_field::(0, 52); + ::capnp::traits::FromPointerBuilder::init_pointer(self.builder.get_pointer_field(0), 0) + } + #[inline] + pub fn has_save_model_preference(&self) -> bool { + if self.builder.get_data_field::(0) != 52 { return false; } + !self.builder.is_pointer_field_null(0) + } + #[inline] pub fn which(self) -> ::core::result::Result, ::capnp::NotInSchema> { match self.builder.get_data_field::(0) { 0 => { @@ -20532,6 +20639,11 @@ pub mod request { ::capnp::traits::FromPointerBuilder::get_from_pointer(self.builder.get_pointer_field(0), ::core::option::Option::None) )) } + 52 => { + ::core::result::Result::Ok(SaveModelPreference( + ::capnp::traits::FromPointerBuilder::get_from_pointer(self.builder.get_pointer_field(0), ::core::option::Option::None) + )) + } x => ::core::result::Result::Err(::capnp::NotInSchema(x)) } } @@ -20546,389 +20658,396 @@ pub mod request { impl Pipeline { } mod _private { - pub(crate) static ENCODED_NODE: [::capnp::Word; 893] = [ + pub(crate) static ENCODED_NODE: [::capnp::Word; 910] = [ ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), ::capnp::word(111, 250, 212, 198, 178, 220, 55, 171), ::capnp::word(14, 0, 0, 0, 1, 0, 1, 0), ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), - ::capnp::word(1, 0, 7, 0, 0, 0, 52, 0), + ::capnp::word(1, 0, 7, 0, 0, 0, 53, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(223, 105, 0, 0, 218, 132, 0, 0), + ::capnp::word(209, 106, 0, 0, 70, 134, 0, 0), ::capnp::word(21, 0, 0, 0, 178, 0, 0, 0), ::capnp::word(29, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(25, 0, 0, 0, 103, 11, 0, 0), + ::capnp::word(25, 0, 0, 0, 159, 11, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(97, 114, 116, 105, 115, 97, 110, 46), ::capnp::word(99, 97, 112, 110, 112, 58, 82, 101), ::capnp::word(113, 117, 101, 115, 116, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 1, 0, 1, 0), - ::capnp::word(208, 0, 0, 0, 3, 0, 4, 0), + ::capnp::word(212, 0, 0, 0, 3, 0, 4, 0), ::capnp::word(0, 0, 255, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(161, 5, 0, 0, 130, 0, 0, 0), + ::capnp::word(189, 5, 0, 0, 130, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(160, 5, 0, 0, 3, 0, 1, 0), - ::capnp::word(172, 5, 0, 0, 2, 0, 1, 0), + ::capnp::word(188, 5, 0, 0, 3, 0, 1, 0), + ::capnp::word(200, 5, 0, 0, 2, 0, 1, 0), ::capnp::word(1, 0, 254, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 1, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(169, 5, 0, 0, 114, 0, 0, 0), + ::capnp::word(197, 5, 0, 0, 114, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(168, 5, 0, 0, 3, 0, 1, 0), - ::capnp::word(180, 5, 0, 0, 2, 0, 1, 0), + ::capnp::word(196, 5, 0, 0, 3, 0, 1, 0), + ::capnp::word(208, 5, 0, 0, 2, 0, 1, 0), ::capnp::word(2, 0, 253, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 2, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(177, 5, 0, 0, 154, 0, 0, 0), + ::capnp::word(205, 5, 0, 0, 154, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(180, 5, 0, 0, 3, 0, 1, 0), - ::capnp::word(192, 5, 0, 0, 2, 0, 1, 0), + ::capnp::word(208, 5, 0, 0, 3, 0, 1, 0), + ::capnp::word(220, 5, 0, 0, 2, 0, 1, 0), ::capnp::word(3, 0, 252, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 3, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(189, 5, 0, 0, 162, 0, 0, 0), + ::capnp::word(217, 5, 0, 0, 162, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(192, 5, 0, 0, 3, 0, 1, 0), - ::capnp::word(204, 5, 0, 0, 2, 0, 1, 0), + ::capnp::word(220, 5, 0, 0, 3, 0, 1, 0), + ::capnp::word(232, 5, 0, 0, 2, 0, 1, 0), ::capnp::word(4, 0, 251, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 4, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(201, 5, 0, 0, 146, 0, 0, 0), + ::capnp::word(229, 5, 0, 0, 146, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(204, 5, 0, 0, 3, 0, 1, 0), - ::capnp::word(216, 5, 0, 0, 2, 0, 1, 0), + ::capnp::word(232, 5, 0, 0, 3, 0, 1, 0), + ::capnp::word(244, 5, 0, 0, 2, 0, 1, 0), ::capnp::word(5, 0, 250, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 5, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(213, 5, 0, 0, 170, 0, 0, 0), + ::capnp::word(241, 5, 0, 0, 170, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(216, 5, 0, 0, 3, 0, 1, 0), - ::capnp::word(228, 5, 0, 0, 2, 0, 1, 0), + ::capnp::word(244, 5, 0, 0, 3, 0, 1, 0), + ::capnp::word(0, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(6, 0, 249, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 6, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(225, 5, 0, 0, 146, 0, 0, 0), + ::capnp::word(253, 5, 0, 0, 146, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(228, 5, 0, 0, 3, 0, 1, 0), - ::capnp::word(240, 5, 0, 0, 2, 0, 1, 0), + ::capnp::word(0, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(12, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(7, 0, 248, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(237, 5, 0, 0, 178, 0, 0, 0), + ::capnp::word(9, 6, 0, 0, 178, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(240, 5, 0, 0, 3, 0, 1, 0), - ::capnp::word(252, 5, 0, 0, 2, 0, 1, 0), + ::capnp::word(12, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(24, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(8, 0, 247, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 8, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(249, 5, 0, 0, 194, 0, 0, 0), + ::capnp::word(21, 6, 0, 0, 194, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(252, 5, 0, 0, 3, 0, 1, 0), - ::capnp::word(8, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(24, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(36, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(9, 0, 246, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 9, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(5, 6, 0, 0, 114, 0, 0, 0), + ::capnp::word(33, 6, 0, 0, 114, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(4, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(16, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(32, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(44, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(10, 0, 245, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 10, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(13, 6, 0, 0, 138, 0, 0, 0), + ::capnp::word(41, 6, 0, 0, 138, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(16, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(28, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(44, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(56, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(11, 0, 244, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 11, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(25, 6, 0, 0, 178, 0, 0, 0), + ::capnp::word(53, 6, 0, 0, 178, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(28, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(40, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(56, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(68, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(12, 0, 243, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 12, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(37, 6, 0, 0, 202, 0, 0, 0), + ::capnp::word(65, 6, 0, 0, 202, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(44, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(56, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(72, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(84, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(13, 0, 242, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 13, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(53, 6, 0, 0, 234, 0, 0, 0), + ::capnp::word(81, 6, 0, 0, 234, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(60, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(72, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(88, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(100, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(14, 0, 241, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 14, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(69, 6, 0, 0, 106, 0, 0, 0), + ::capnp::word(97, 6, 0, 0, 106, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(68, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(80, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(96, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(108, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(15, 0, 240, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 15, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(77, 6, 0, 0, 138, 0, 0, 0), + ::capnp::word(105, 6, 0, 0, 138, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(80, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(92, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(108, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(120, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(16, 0, 239, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 16, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(89, 6, 0, 0, 66, 0, 0, 0), + ::capnp::word(117, 6, 0, 0, 66, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(84, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(96, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(112, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(124, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(17, 0, 238, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 17, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(93, 6, 0, 0, 114, 0, 0, 0), + ::capnp::word(121, 6, 0, 0, 114, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(92, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(104, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(120, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(132, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(18, 0, 237, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 18, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(101, 6, 0, 0, 162, 0, 0, 0), + ::capnp::word(129, 6, 0, 0, 162, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(104, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(116, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(132, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(144, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(19, 0, 236, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 19, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(113, 6, 0, 0, 154, 0, 0, 0), + ::capnp::word(141, 6, 0, 0, 154, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(116, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(128, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(144, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(156, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(20, 0, 235, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 20, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(125, 6, 0, 0, 138, 0, 0, 0), + ::capnp::word(153, 6, 0, 0, 138, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(128, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(140, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(156, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(168, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(21, 0, 234, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 21, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(137, 6, 0, 0, 154, 0, 0, 0), + ::capnp::word(165, 6, 0, 0, 154, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(140, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(152, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(168, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(180, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(22, 0, 233, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 22, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(149, 6, 0, 0, 178, 0, 0, 0), + ::capnp::word(177, 6, 0, 0, 178, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(152, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(164, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(180, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(192, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(23, 0, 232, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 23, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(161, 6, 0, 0, 162, 0, 0, 0), + ::capnp::word(189, 6, 0, 0, 162, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(164, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(176, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(192, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(204, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(24, 0, 231, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 24, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(173, 6, 0, 0, 106, 0, 0, 0), + ::capnp::word(201, 6, 0, 0, 106, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(172, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(184, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(200, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(212, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(26, 0, 230, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 25, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(181, 6, 0, 0, 138, 0, 0, 0), + ::capnp::word(209, 6, 0, 0, 138, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(184, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(196, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(212, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(224, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(27, 0, 229, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 26, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(193, 6, 0, 0, 130, 0, 0, 0), + ::capnp::word(221, 6, 0, 0, 130, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(192, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(204, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(220, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(232, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(28, 0, 228, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 27, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(201, 6, 0, 0, 130, 0, 0, 0), + ::capnp::word(229, 6, 0, 0, 130, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(200, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(212, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(228, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(240, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(25, 0, 227, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 28, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(209, 6, 0, 0, 154, 0, 0, 0), + ::capnp::word(237, 6, 0, 0, 154, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(212, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(224, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(240, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(252, 6, 0, 0, 2, 0, 1, 0), ::capnp::word(29, 0, 226, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 29, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(221, 6, 0, 0, 130, 0, 0, 0), + ::capnp::word(249, 6, 0, 0, 130, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(220, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(232, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(248, 6, 0, 0, 3, 0, 1, 0), + ::capnp::word(4, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(30, 0, 225, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 30, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(229, 6, 0, 0, 186, 0, 0, 0), + ::capnp::word(1, 7, 0, 0, 186, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(232, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(244, 6, 0, 0, 2, 0, 1, 0), + ::capnp::word(4, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(16, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(31, 0, 224, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 31, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(241, 6, 0, 0, 146, 0, 0, 0), + ::capnp::word(13, 7, 0, 0, 146, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(244, 6, 0, 0, 3, 0, 1, 0), - ::capnp::word(0, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(16, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(28, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(32, 0, 223, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 32, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(253, 6, 0, 0, 146, 0, 0, 0), + ::capnp::word(25, 7, 0, 0, 146, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(0, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(12, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(28, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(40, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(33, 0, 222, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 33, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(9, 7, 0, 0, 146, 0, 0, 0), + ::capnp::word(37, 7, 0, 0, 146, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(12, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(24, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(40, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(52, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(34, 0, 221, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 34, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(21, 7, 0, 0, 202, 0, 0, 0), + ::capnp::word(49, 7, 0, 0, 202, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(28, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(40, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(56, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(68, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(35, 0, 220, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 35, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(37, 7, 0, 0, 186, 0, 0, 0), + ::capnp::word(65, 7, 0, 0, 186, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(40, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(52, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(68, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(80, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(36, 0, 219, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 36, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(49, 7, 0, 0, 154, 0, 0, 0), + ::capnp::word(77, 7, 0, 0, 154, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(52, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(64, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(80, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(92, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(37, 0, 218, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 37, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(61, 7, 0, 0, 154, 0, 0, 0), + ::capnp::word(89, 7, 0, 0, 154, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(64, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(76, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(92, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(104, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(38, 0, 217, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 38, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(73, 7, 0, 0, 170, 0, 0, 0), + ::capnp::word(101, 7, 0, 0, 170, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(76, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(88, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(104, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(116, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(39, 0, 216, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 39, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(85, 7, 0, 0, 162, 0, 0, 0), + ::capnp::word(113, 7, 0, 0, 162, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(88, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(100, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(116, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(128, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(40, 0, 215, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 40, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(97, 7, 0, 0, 130, 0, 0, 0), + ::capnp::word(125, 7, 0, 0, 130, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(96, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(108, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(124, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(136, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(41, 0, 214, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 41, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(105, 7, 0, 0, 178, 0, 0, 0), + ::capnp::word(133, 7, 0, 0, 178, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(108, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(120, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(136, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(148, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(42, 0, 213, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 42, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(117, 7, 0, 0, 162, 0, 0, 0), + ::capnp::word(145, 7, 0, 0, 162, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(120, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(132, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(148, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(160, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(43, 0, 212, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 43, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(129, 7, 0, 0, 138, 0, 0, 0), + ::capnp::word(157, 7, 0, 0, 138, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(132, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(144, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(160, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(172, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(44, 0, 211, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 44, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(141, 7, 0, 0, 194, 0, 0, 0), + ::capnp::word(169, 7, 0, 0, 194, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(144, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(156, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(172, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(184, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(45, 0, 210, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 45, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(153, 7, 0, 0, 218, 0, 0, 0), + ::capnp::word(181, 7, 0, 0, 218, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(160, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(172, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(188, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(200, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(46, 0, 209, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 46, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(169, 7, 0, 0, 146, 0, 0, 0), + ::capnp::word(197, 7, 0, 0, 146, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(172, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(184, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(200, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(212, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(47, 0, 208, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 47, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(181, 7, 0, 0, 154, 0, 0, 0), + ::capnp::word(209, 7, 0, 0, 154, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(184, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(196, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(212, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(224, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(48, 0, 207, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 48, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(193, 7, 0, 0, 154, 0, 0, 0), + ::capnp::word(221, 7, 0, 0, 154, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(196, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(208, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(224, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(236, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(49, 0, 206, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 49, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(205, 7, 0, 0, 162, 0, 0, 0), + ::capnp::word(233, 7, 0, 0, 162, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(208, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(220, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(236, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(248, 7, 0, 0, 2, 0, 1, 0), ::capnp::word(50, 0, 205, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 50, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(217, 7, 0, 0, 130, 0, 0, 0), + ::capnp::word(245, 7, 0, 0, 130, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(216, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(228, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(244, 7, 0, 0, 3, 0, 1, 0), + ::capnp::word(0, 8, 0, 0, 2, 0, 1, 0), ::capnp::word(51, 0, 204, 255, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 51, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(225, 7, 0, 0, 162, 0, 0, 0), + ::capnp::word(253, 7, 0, 0, 162, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 8, 0, 0, 3, 0, 1, 0), + ::capnp::word(12, 8, 0, 0, 2, 0, 1, 0), + ::capnp::word(52, 0, 203, 255, 0, 0, 0, 0), + ::capnp::word(0, 0, 1, 0, 52, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(228, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(240, 7, 0, 0, 2, 0, 1, 0), + ::capnp::word(9, 8, 0, 0, 162, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(12, 8, 0, 0, 3, 0, 1, 0), + ::capnp::word(24, 8, 0, 0, 2, 0, 1, 0), ::capnp::word(108, 105, 115, 116, 68, 105, 114, 101), ::capnp::word(99, 116, 111, 114, 105, 101, 115, 0), ::capnp::word(16, 0, 0, 0, 0, 0, 0, 0), @@ -21440,6 +21559,16 @@ pub mod request { ::capnp::word(16, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(115, 97, 118, 101, 77, 111, 100, 101), + ::capnp::word(108, 80, 114, 101, 102, 101, 114, 101), + ::capnp::word(110, 99, 101, 0, 0, 0, 0, 0), + ::capnp::word(16, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(23, 0, 133, 41, 134, 88, 170, 167), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(16, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ]; pub(crate) fn get_field_types(index: u16) -> ::capnp::introspect::Type { match index { @@ -21495,6 +21624,7 @@ pub mod request { 49 => ::introspect(), 50 => ::introspect(), 51 => ::introspect(), + 52 => ::introspect(), _ => ::capnp::introspect::panic_invalid_field_index(index), } } @@ -21509,11 +21639,11 @@ pub mod request { MEMBERS_BY_NAME ); pub(crate) static NONUNION_MEMBERS : &[u16] = &[]; - pub(crate) static MEMBERS_BY_DISCRIMINANT : &[u16] = &[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51]; - pub(crate) static MEMBERS_BY_NAME : &[u16] = &[50,1,49,51,6,7,8,3,44,10,5,0,48,28,2,21,13,9,30,4,14,36,25,17,35,18,33,47,40,15,19,23,46,24,12,42,43,38,45,41,29,26,27,37,32,20,11,16,39,34,31,22]; + pub(crate) static MEMBERS_BY_DISCRIMINANT : &[u16] = &[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52]; + pub(crate) static MEMBERS_BY_NAME : &[u16] = &[50,1,49,51,6,7,8,3,44,10,5,0,48,28,2,21,13,9,30,4,14,36,25,17,35,18,33,47,40,15,19,23,46,24,12,42,43,38,45,41,29,26,27,37,32,52,20,11,16,39,34,31,22]; pub(crate) const TYPE_ID: u64 = 0xab37_dcb2_c6d4_fa6f; } - pub enum Which { + pub enum Which { ListDirectories(A0), AttachProject(A1), ListProjectThreads(A2), @@ -21566,9 +21696,10 @@ pub mod request { ChangeEngineVersion(A43), AnswerQuestions(A44), ConversationHistory(A45), + SaveModelPreference(A46), } - pub type WhichReader<'a,> = Which<::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result<::capnp::text::Reader<'a>>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result<::capnp::text::Reader<'a>>,::capnp::Result>,::capnp::Result>,::capnp::Result>>; - pub type WhichBuilder<'a,> = Which<::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result<::capnp::text::Builder<'a>>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result<::capnp::text::Builder<'a>>,::capnp::Result>,::capnp::Result>,::capnp::Result>>; + pub type WhichReader<'a,> = Which<::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result<::capnp::text::Reader<'a>>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result<::capnp::text::Reader<'a>>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>>; + pub type WhichBuilder<'a,> = Which<::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result<::capnp::text::Builder<'a>>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result<::capnp::text::Builder<'a>>,::capnp::Result>,::capnp::Result>,::capnp::Result>,::capnp::Result>>; } pub mod response { @@ -23182,7 +23313,7 @@ pub mod response { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 48, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(10, 134, 0, 0, 82, 154, 0, 0), + ::capnp::word(118, 135, 0, 0, 190, 155, 0, 0), ::capnp::word(21, 0, 0, 0, 186, 0, 0, 0), ::capnp::word(29, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -24313,7 +24444,7 @@ pub mod stop_run_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(224, 154, 0, 0, 32, 155, 0, 0), + ::capnp::word(76, 156, 0, 0, 140, 156, 0, 0), ::capnp::word(21, 0, 0, 0, 234, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -24539,7 +24670,7 @@ pub mod read_active_run_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(106, 155, 0, 0, 158, 155, 0, 0), + ::capnp::word(214, 156, 0, 0, 10, 157, 0, 0), ::capnp::word(21, 0, 0, 0, 26, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -24633,7 +24764,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 36] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(160, 155, 0, 0, 242, 155, 0, 0), + ::capnp::word(12, 157, 0, 0, 94, 157, 0, 0), ::capnp::word(21, 0, 0, 0, 10, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -24887,7 +25018,7 @@ pub mod stop_run_receipt { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(244, 155, 0, 0, 112, 156, 0, 0), + ::capnp::word(96, 157, 0, 0, 220, 157, 0, 0), ::capnp::word(21, 0, 0, 0, 234, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -25200,7 +25331,7 @@ pub mod active_run_result { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(114, 156, 0, 0, 36, 158, 0, 0), + ::capnp::word(222, 157, 0, 0, 144, 159, 0, 0), ::capnp::word(21, 0, 0, 0, 242, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -25604,7 +25735,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 34] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(38, 158, 0, 0, 111, 158, 0, 0), + ::capnp::word(146, 159, 0, 0, 219, 159, 0, 0), ::capnp::word(21, 0, 0, 0, 194, 0, 0, 0), ::capnp::word(29, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -25830,7 +25961,7 @@ pub mod directory_pick_outcome { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 2, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(50, 160, 0, 0, 243, 160, 0, 0), + ::capnp::word(158, 161, 0, 0, 95, 162, 0, 0), ::capnp::word(21, 0, 0, 0, 26, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -26546,7 +26677,7 @@ pub mod event { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 16, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(21, 162, 0, 0, 142, 177, 0, 0), + ::capnp::word(129, 163, 0, 0, 250, 178, 0, 0), ::capnp::word(21, 0, 0, 0, 162, 0, 0, 0), ::capnp::word(29, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -26934,7 +27065,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 34] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(236, 180, 0, 0, 59, 181, 0, 0), + ::capnp::word(88, 182, 0, 0, 167, 182, 0, 0), ::capnp::word(21, 0, 0, 0, 50, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -27011,7 +27142,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 38] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(104, 181, 0, 0, 192, 181, 0, 0), + ::capnp::word(212, 182, 0, 0, 44, 183, 0, 0), ::capnp::word(21, 0, 0, 0, 34, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -27092,7 +27223,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 37] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(233, 181, 0, 0, 61, 182, 0, 0), + ::capnp::word(85, 183, 0, 0, 169, 183, 0, 0), ::capnp::word(21, 0, 0, 0, 34, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -27168,7 +27299,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 29] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(98, 182, 0, 0, 155, 182, 0, 0), + ::capnp::word(206, 183, 0, 0, 7, 184, 0, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -27236,7 +27367,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 29] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(202, 182, 0, 0, 7, 183, 0, 0), + ::capnp::word(54, 184, 0, 0, 115, 184, 0, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -27304,7 +27435,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 29] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(64, 183, 0, 0, 125, 183, 0, 0), + ::capnp::word(172, 184, 0, 0, 233, 184, 0, 0), ::capnp::word(21, 0, 0, 0, 74, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -27376,7 +27507,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 37] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(183, 183, 0, 0, 16, 184, 0, 0), + ::capnp::word(35, 185, 0, 0, 124, 185, 0, 0), ::capnp::word(21, 0, 0, 0, 58, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -27452,7 +27583,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 30] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(65, 184, 0, 0, 129, 184, 0, 0), + ::capnp::word(173, 185, 0, 0, 237, 185, 0, 0), ::capnp::word(21, 0, 0, 0, 58, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -27523,7 +27654,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 33] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(179, 184, 0, 0, 255, 184, 0, 0), + ::capnp::word(31, 186, 0, 0, 107, 186, 0, 0), ::capnp::word(21, 0, 0, 0, 50, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -27595,7 +27726,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 30] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(48, 185, 0, 0, 112, 185, 0, 0), + ::capnp::word(156, 186, 0, 0, 220, 186, 0, 0), ::capnp::word(21, 0, 0, 0, 58, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -27666,7 +27797,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 35] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(142, 185, 0, 0, 224, 185, 0, 0), + ::capnp::word(250, 186, 0, 0, 76, 187, 0, 0), ::capnp::word(21, 0, 0, 0, 74, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -27740,7 +27871,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 30] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(19, 186, 0, 0, 84, 186, 0, 0), + ::capnp::word(127, 187, 0, 0, 192, 187, 0, 0), ::capnp::word(21, 0, 0, 0, 74, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -27809,7 +27940,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 31] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(137, 186, 0, 0, 204, 186, 0, 0), + ::capnp::word(245, 187, 0, 0, 56, 188, 0, 0), ::capnp::word(21, 0, 0, 0, 90, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -27881,7 +28012,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 32] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(249, 186, 0, 0, 63, 187, 0, 0), + ::capnp::word(101, 188, 0, 0, 171, 188, 0, 0), ::capnp::word(21, 0, 0, 0, 18, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -27958,7 +28089,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 42] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(105, 187, 0, 0, 207, 187, 0, 0), + ::capnp::word(213, 188, 0, 0, 59, 189, 0, 0), ::capnp::word(21, 0, 0, 0, 26, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -28047,7 +28178,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 47] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(4, 188, 0, 0, 129, 188, 0, 0), + ::capnp::word(112, 189, 0, 0, 237, 189, 0, 0), ::capnp::word(21, 0, 0, 0, 58, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -28135,7 +28266,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 33] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(186, 188, 0, 0, 3, 189, 0, 0), + ::capnp::word(38, 190, 0, 0, 111, 190, 0, 0), ::capnp::word(21, 0, 0, 0, 34, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -28209,7 +28340,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 33] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(55, 189, 0, 0, 127, 189, 0, 0), + ::capnp::word(163, 190, 0, 0, 235, 190, 0, 0), ::capnp::word(21, 0, 0, 0, 74, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -28287,7 +28418,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 44] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(182, 189, 0, 0, 38, 190, 0, 0), + ::capnp::word(34, 191, 0, 0, 146, 191, 0, 0), ::capnp::word(21, 0, 0, 0, 82, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -28372,7 +28503,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 32] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(98, 190, 0, 0, 167, 190, 0, 0), + ::capnp::word(206, 191, 0, 0, 19, 192, 0, 0), ::capnp::word(21, 0, 0, 0, 34, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -28658,7 +28789,7 @@ pub mod observation_agent_message_delta { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(223, 190, 0, 0, 137, 191, 0, 0), + ::capnp::word(75, 192, 0, 0, 245, 192, 0, 0), ::capnp::word(21, 0, 0, 0, 90, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -29046,7 +29177,7 @@ pub mod observation_agent_message_completed { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(179, 191, 0, 0, 99, 192, 0, 0), + ::capnp::word(31, 193, 0, 0, 207, 193, 0, 0), ::capnp::word(21, 0, 0, 0, 122, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -29451,7 +29582,7 @@ pub mod observation_reasoning_summary_delta { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(167, 192, 0, 0, 167, 193, 0, 0), + ::capnp::word(19, 194, 0, 0, 19, 195, 0, 0), ::capnp::word(21, 0, 0, 0, 122, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -30094,7 +30225,7 @@ pub mod observation_reasoning_summary_completed { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(211, 193, 0, 0, 155, 194, 0, 0), + ::capnp::word(63, 195, 0, 0, 7, 196, 0, 0), ::capnp::word(21, 0, 0, 0, 154, 1, 0, 0), ::capnp::word(45, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -30467,7 +30598,7 @@ pub mod observation_tool { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(222, 194, 0, 0, 187, 195, 0, 0), + ::capnp::word(74, 196, 0, 0, 39, 197, 0, 0), ::capnp::word(21, 0, 0, 0, 242, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -30840,7 +30971,7 @@ pub mod observation_file { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(247, 195, 0, 0, 18, 197, 0, 0), + ::capnp::word(99, 197, 0, 0, 126, 198, 0, 0), ::capnp::word(21, 0, 0, 0, 242, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -31730,7 +31861,7 @@ pub mod observation_search { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(67, 197, 0, 0, 181, 198, 0, 0), + ::capnp::word(175, 198, 0, 0, 33, 200, 0, 0), ::capnp::word(21, 0, 0, 0, 2, 1, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -32674,7 +32805,7 @@ pub mod observation_terminal_activity { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(5, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(254, 198, 0, 0, 101, 201, 0, 0), + ::capnp::word(106, 200, 0, 0, 209, 202, 0, 0), ::capnp::word(21, 0, 0, 0, 82, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -33830,7 +33961,7 @@ pub mod observation_approval_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(38, 202, 0, 0, 166, 202, 0, 0), + ::capnp::word(146, 203, 0, 0, 18, 204, 0, 0), ::capnp::word(21, 0, 0, 0, 74, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -34205,7 +34336,7 @@ pub mod observation_approval { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(26, 203, 0, 0, 37, 204, 0, 0), + ::capnp::word(134, 204, 0, 0, 145, 205, 0, 0), ::capnp::word(21, 0, 0, 0, 18, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -34785,7 +34916,7 @@ pub mod observation_question_option { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(84, 204, 0, 0, 229, 204, 0, 0), + ::capnp::word(192, 205, 0, 0, 81, 206, 0, 0), ::capnp::word(21, 0, 0, 0, 66, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -35185,7 +35316,7 @@ pub mod observation_question { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(7, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(169, 205, 0, 0, 57, 208, 0, 0), + ::capnp::word(21, 207, 0, 0, 165, 209, 0, 0), ::capnp::word(21, 0, 0, 0, 18, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -35846,7 +35977,7 @@ pub mod observation_plan_entry { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(94, 208, 0, 0, 198, 208, 0, 0), + ::capnp::word(202, 209, 0, 0, 50, 210, 0, 0), ::capnp::word(21, 0, 0, 0, 26, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -36148,7 +36279,7 @@ pub mod observation_plan { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(236, 208, 0, 0, 168, 209, 0, 0), + ::capnp::word(88, 210, 0, 0, 20, 211, 0, 0), ::capnp::word(21, 0, 0, 0, 242, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -36499,7 +36630,7 @@ pub mod observation_compaction { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(212, 209, 0, 0, 67, 211, 0, 0), + ::capnp::word(64, 211, 0, 0, 175, 212, 0, 0), ::capnp::word(21, 0, 0, 0, 34, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -37122,7 +37253,7 @@ pub mod observation_retry { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(127, 211, 0, 0, 46, 212, 0, 0), + ::capnp::word(235, 212, 0, 0, 154, 213, 0, 0), ::capnp::word(21, 0, 0, 0, 250, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -37438,7 +37569,7 @@ pub mod observation_run_state_observation { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(97, 212, 0, 0, 209, 212, 0, 0), + ::capnp::word(205, 213, 0, 0, 61, 214, 0, 0), ::capnp::word(21, 0, 0, 0, 106, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -37730,7 +37861,7 @@ pub mod observation_turn_state_observation { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(15, 213, 0, 0, 148, 213, 0, 0), + ::capnp::word(123, 214, 0, 0, 0, 215, 0, 0), ::capnp::word(21, 0, 0, 0, 114, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -38134,7 +38265,7 @@ pub mod observation_subagent { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(6, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(198, 213, 0, 0, 60, 215, 0, 0), + ::capnp::word(50, 215, 0, 0, 168, 216, 0, 0), ::capnp::word(21, 0, 0, 0, 18, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -38499,7 +38630,7 @@ pub mod observation_transcript_agent_message_delta { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(94, 215, 0, 0, 217, 215, 0, 0), + ::capnp::word(202, 216, 0, 0, 69, 217, 0, 0), ::capnp::word(21, 0, 0, 0, 170, 1, 0, 0), ::capnp::word(45, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -38779,7 +38910,7 @@ pub mod observation_transcript_agent_message_completed { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(252, 215, 0, 0, 125, 216, 0, 0), + ::capnp::word(104, 217, 0, 0, 233, 217, 0, 0), ::capnp::word(21, 0, 0, 0, 202, 1, 0, 0), ::capnp::word(49, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -39060,7 +39191,7 @@ pub mod observation_transcript_reasoning_summary_delta { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(163, 216, 0, 0, 24, 217, 0, 0), + ::capnp::word(15, 218, 0, 0, 132, 218, 0, 0), ::capnp::word(21, 0, 0, 0, 202, 1, 0, 0), ::capnp::word(49, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -39330,7 +39461,7 @@ pub mod observation_transcript_reasoning_summary_completed { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(59, 217, 0, 0, 212, 217, 0, 0), + ::capnp::word(167, 218, 0, 0, 64, 219, 0, 0), ::capnp::word(21, 0, 0, 0, 234, 1, 0, 0), ::capnp::word(49, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -39646,7 +39777,7 @@ pub mod observation_transcript_terminal_activity { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(245, 217, 0, 0, 35, 220, 0, 0), + ::capnp::word(97, 219, 0, 0, 143, 221, 0, 0), ::capnp::word(21, 0, 0, 0, 162, 1, 0, 0), ::capnp::word(45, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -40757,7 +40888,7 @@ pub mod observation_transcript_tool { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(55, 220, 0, 0, 248, 220, 0, 0), + ::capnp::word(163, 221, 0, 0, 100, 222, 0, 0), ::capnp::word(21, 0, 0, 0, 66, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -41062,7 +41193,7 @@ pub mod observation_transcript_file { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(12, 221, 0, 0, 11, 222, 0, 0), + ::capnp::word(120, 222, 0, 0, 119, 223, 0, 0), ::capnp::word(21, 0, 0, 0, 66, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -41886,7 +42017,7 @@ pub mod observation_transcript_search { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(33, 222, 0, 0, 119, 223, 0, 0), + ::capnp::word(141, 223, 0, 0, 227, 224, 0, 0), ::capnp::word(21, 0, 0, 0, 82, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -42884,7 +43015,7 @@ pub mod observation_subagent_transcript_content { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 8, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(220, 223, 0, 0, 253, 225, 0, 0), + ::capnp::word(72, 225, 0, 0, 105, 227, 0, 0), ::capnp::word(21, 0, 0, 0, 154, 1, 0, 0), ::capnp::word(45, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -43318,7 +43449,7 @@ pub mod observation_subagent_transcript { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(54, 226, 0, 0, 249, 226, 0, 0), + ::capnp::word(162, 227, 0, 0, 101, 228, 0, 0), ::capnp::word(21, 0, 0, 0, 98, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -43759,7 +43890,7 @@ pub mod observation_engine_error_ref { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(7, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(204, 227, 0, 0, 239, 228, 0, 0), + ::capnp::word(56, 229, 0, 0, 91, 230, 0, 0), ::capnp::word(21, 0, 0, 0, 66, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -44503,7 +44634,7 @@ pub mod observation_usage { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(134, 229, 0, 0, 172, 232, 0, 0), + ::capnp::word(242, 230, 0, 0, 24, 234, 0, 0), ::capnp::word(21, 0, 0, 0, 250, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -46200,7 +46331,7 @@ pub mod observation_native_action { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(238, 232, 0, 0, 14, 234, 0, 0), + ::capnp::word(90, 234, 0, 0, 122, 235, 0, 0), ::capnp::word(21, 0, 0, 0, 50, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -46819,7 +46950,7 @@ pub mod observation_process_diagnostic { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(69, 234, 0, 0, 43, 235, 0, 0), + ::capnp::word(177, 235, 0, 0, 151, 236, 0, 0), ::capnp::word(21, 0, 0, 0, 90, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -47406,7 +47537,7 @@ pub mod observation_protocol_diagnostic { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(93, 235, 0, 0, 231, 235, 0, 0), + ::capnp::word(201, 236, 0, 0, 83, 237, 0, 0), ::capnp::word(21, 0, 0, 0, 98, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -47731,7 +47862,7 @@ pub mod observation_run_terminal { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(23, 236, 0, 0, 66, 237, 0, 0), + ::capnp::word(131, 237, 0, 0, 174, 238, 0, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -48919,7 +49050,7 @@ pub mod engine_observation { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 22, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(203, 237, 0, 0, 249, 241, 0, 0), + ::capnp::word(55, 239, 0, 0, 101, 243, 0, 0), ::capnp::word(21, 0, 0, 0, 2, 1, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -49563,7 +49694,7 @@ pub mod engine_observation_attribution { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(115, 243, 0, 0, 253, 243, 0, 0), + ::capnp::word(223, 244, 0, 0, 105, 245, 0, 0), ::capnp::word(21, 0, 0, 0, 90, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -49870,7 +50001,7 @@ pub mod engine_observation_event { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(68, 244, 0, 0, 41, 246, 0, 0), + ::capnp::word(176, 245, 0, 0, 149, 247, 0, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -50270,7 +50401,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 56] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(132, 251, 0, 0, 124, 253, 0, 0), + ::capnp::word(240, 252, 0, 0, 232, 254, 0, 0), ::capnp::word(21, 0, 0, 0, 34, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -50544,7 +50675,7 @@ pub mod conversation_turn { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(197, 253, 0, 0, 9, 0, 1, 0), + ::capnp::word(49, 255, 0, 0, 117, 1, 1, 0), ::capnp::word(21, 0, 0, 0, 250, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -50969,7 +51100,7 @@ pub mod user_message_item { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(87, 0, 1, 0, 244, 3, 1, 0), + ::capnp::word(195, 1, 1, 0, 96, 5, 1, 0), ::capnp::word(21, 0, 0, 0, 242, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -51194,7 +51325,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 34] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(133, 5, 1, 0, 81, 6, 1, 0), + ::capnp::word(241, 6, 1, 0, 189, 7, 1, 0), ::capnp::word(21, 0, 0, 0, 34, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -51530,7 +51661,7 @@ pub mod assistant_message_item { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(165, 6, 1, 0, 96, 11, 1, 0), + ::capnp::word(17, 8, 1, 0, 204, 12, 1, 0), ::capnp::word(21, 0, 0, 0, 26, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -51982,7 +52113,7 @@ pub mod conversation_item { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 4, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(82, 13, 1, 0, 14, 16, 1, 0), + ::capnp::word(190, 14, 1, 0, 122, 17, 1, 0), ::capnp::word(21, 0, 0, 0, 250, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -52325,7 +52456,7 @@ pub mod conversation_snapshot { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(141, 18, 1, 0, 143, 21, 1, 0), + ::capnp::word(249, 19, 1, 0, 251, 22, 1, 0), ::capnp::word(21, 0, 0, 0, 26, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -52657,7 +52788,7 @@ pub mod item_append { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(207, 21, 1, 0, 236, 24, 1, 0), + ::capnp::word(59, 23, 1, 0, 88, 26, 1, 0), ::capnp::word(21, 0, 0, 0, 202, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -52952,7 +53083,7 @@ pub mod item_lifecycle_patch { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(43, 25, 1, 0, 199, 27, 1, 0), + ::capnp::word(151, 26, 1, 0, 51, 29, 1, 0), ::capnp::word(21, 0, 0, 0, 10, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -53249,7 +53380,7 @@ pub mod turn_lifecycle_patch { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(255, 27, 1, 0, 155, 30, 1, 0), + ::capnp::word(107, 29, 1, 0, 7, 32, 1, 0), ::capnp::word(21, 0, 0, 0, 10, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -53686,7 +53817,7 @@ pub mod conversation_patch { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 5, 0), ::capnp::word(4, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(158, 31, 1, 0, 187, 34, 1, 0), + ::capnp::word(10, 33, 1, 0, 39, 36, 1, 0), ::capnp::word(21, 0, 0, 0, 2, 1, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -54054,7 +54185,7 @@ pub mod patch_batch { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(150, 36, 1, 0, 113, 38, 1, 0), + ::capnp::word(2, 38, 1, 0, 221, 39, 1, 0), ::capnp::word(21, 0, 0, 0, 202, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -54306,7 +54437,7 @@ pub mod query_window { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(200, 39, 1, 0, 61, 40, 1, 0), + ::capnp::word(52, 41, 1, 0, 169, 41, 1, 0), ::capnp::word(21, 0, 0, 0, 210, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -54534,7 +54665,7 @@ pub mod query_range { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(116, 40, 1, 0, 90, 43, 1, 0), + ::capnp::word(224, 41, 1, 0, 198, 44, 1, 0), ::capnp::word(21, 0, 0, 0, 202, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -55043,7 +55174,7 @@ pub mod conversation_query_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(27, 44, 1, 0, 43, 45, 1, 0), + ::capnp::word(135, 45, 1, 0, 151, 46, 1, 0), ::capnp::word(21, 0, 0, 0, 58, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -55591,7 +55722,7 @@ pub mod conversation_subscribe_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(116, 45, 1, 0, 31, 49, 1, 0), + ::capnp::word(224, 46, 1, 0, 139, 50, 1, 0), ::capnp::word(21, 0, 0, 0, 90, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -56345,7 +56476,7 @@ pub mod conversation_unsubscribe_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(102, 49, 1, 0, 226, 49, 1, 0), + ::capnp::word(210, 50, 1, 0, 78, 51, 1, 0), ::capnp::word(21, 0, 0, 0, 106, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -56569,7 +56700,7 @@ pub mod conversation_resume_point { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(13, 50, 1, 0, 87, 51, 1, 0), + ::capnp::word(121, 51, 1, 0, 195, 52, 1, 0), ::capnp::word(21, 0, 0, 0, 50, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -56846,7 +56977,7 @@ pub mod conversation_subscription_started { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 2, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(172, 52, 1, 0, 176, 53, 1, 0), + ::capnp::word(24, 54, 1, 0, 28, 55, 1, 0), ::capnp::word(21, 0, 0, 0, 114, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -57079,7 +57210,7 @@ pub mod conversation_subscription_stopped { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(252, 53, 1, 0, 114, 54, 1, 0), + ::capnp::word(104, 55, 1, 0, 222, 55, 1, 0), ::capnp::word(21, 0, 0, 0, 114, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -57373,7 +57504,7 @@ pub mod protocol_error { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 2, 0), ::capnp::word(2, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(153, 54, 1, 0, 134, 56, 1, 0), + ::capnp::word(5, 56, 1, 0, 242, 57, 1, 0), ::capnp::word(21, 0, 0, 0, 226, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -57696,7 +57827,7 @@ pub mod envelope { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(103, 57, 1, 0, 128, 62, 1, 0), + ::capnp::word(211, 58, 1, 0, 236, 63, 1, 0), ::capnp::word(21, 0, 0, 0, 186, 0, 0, 0), ::capnp::word(29, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -58514,7 +58645,7 @@ pub mod image_attachment { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(105, 63, 1, 0, 187, 63, 1, 0), + ::capnp::word(213, 64, 1, 0, 39, 65, 1, 0), ::capnp::word(21, 0, 0, 0, 242, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -58821,7 +58952,7 @@ pub mod queue_message_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(184, 64, 1, 0, 106, 65, 1, 0), + ::capnp::word(36, 66, 1, 0, 214, 66, 1, 0), ::capnp::word(21, 0, 0, 0, 18, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -59416,7 +59547,7 @@ pub mod queue_message_receipt { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(54, 66, 1, 0, 212, 66, 1, 0), + ::capnp::word(162, 67, 1, 0, 64, 68, 1, 0), ::capnp::word(21, 0, 0, 0, 18, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -59843,7 +59974,7 @@ pub mod multimodal_user_message_item { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(5, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(154, 67, 1, 0, 185, 69, 1, 0), + ::capnp::word(6, 69, 1, 0, 37, 71, 1, 0), ::capnp::word(21, 0, 0, 0, 66, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -60586,7 +60717,7 @@ pub mod image_attachment_ref { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(5, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(63, 70, 1, 0, 236, 70, 1, 0), + ::capnp::word(171, 71, 1, 0, 88, 72, 1, 0), ::capnp::word(21, 0, 0, 0, 10, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -60932,7 +61063,7 @@ pub mod read_message_image_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(132, 71, 1, 0, 229, 71, 1, 0), + ::capnp::word(240, 72, 1, 0, 81, 73, 1, 0), ::capnp::word(21, 0, 0, 0, 50, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -61203,7 +61334,7 @@ pub mod message_image_result { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(119, 72, 1, 0, 202, 72, 1, 0), + ::capnp::word(227, 73, 1, 0, 54, 74, 1, 0), ::capnp::word(21, 0, 0, 0, 10, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -61435,7 +61566,7 @@ pub mod read_account_usage_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(63, 76, 1, 0, 88, 77, 1, 0), + ::capnp::word(171, 77, 1, 0, 196, 78, 1, 0), ::capnp::word(21, 0, 0, 0, 50, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -61805,7 +61936,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 36] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(153, 77, 1, 0, 238, 77, 1, 0), + ::capnp::word(5, 79, 1, 0, 90, 79, 1, 0), ::capnp::word(21, 0, 0, 0, 34, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -61882,7 +62013,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 34] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(55, 78, 1, 0, 145, 78, 1, 0), + ::capnp::word(163, 79, 1, 0, 253, 79, 1, 0), ::capnp::word(21, 0, 0, 0, 66, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -61957,7 +62088,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 33] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(30, 79, 1, 0, 99, 79, 1, 0), + ::capnp::word(138, 80, 1, 0, 207, 80, 1, 0), ::capnp::word(21, 0, 0, 0, 218, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -62232,7 +62363,7 @@ pub mod engine_usage_window { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(83, 80, 1, 0, 247, 82, 1, 0), + ::capnp::word(191, 81, 1, 0, 99, 84, 1, 0), ::capnp::word(21, 0, 0, 0, 2, 1, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -62700,7 +62831,7 @@ pub mod engine_usage_report { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(7, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(249, 83, 1, 0, 196, 88, 1, 0), + ::capnp::word(101, 85, 1, 0, 48, 90, 1, 0), ::capnp::word(21, 0, 0, 0, 2, 1, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -63316,7 +63447,7 @@ pub mod engine_usage_snapshot { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(93, 89, 1, 0, 89, 90, 1, 0), + ::capnp::word(201, 90, 1, 0, 197, 91, 1, 0), ::capnp::word(21, 0, 0, 0, 18, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -63607,7 +63738,7 @@ pub mod respond_approval_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(214, 93, 1, 0, 74, 94, 1, 0), + ::capnp::word(66, 95, 1, 0, 182, 95, 1, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -63940,7 +64071,7 @@ pub mod respond_question_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(245, 94, 1, 0, 76, 96, 1, 0), + ::capnp::word(97, 96, 1, 0, 184, 97, 1, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -64089,7 +64220,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 39] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(85, 97, 1, 0, 190, 97, 1, 0), + ::capnp::word(193, 98, 1, 0, 42, 99, 1, 0), ::capnp::word(21, 0, 0, 0, 66, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -64394,7 +64525,7 @@ pub mod respond_approval_receipt { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(136, 98, 1, 0, 97, 99, 1, 0), + ::capnp::word(244, 99, 1, 0, 205, 100, 1, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -64825,7 +64956,7 @@ pub mod respond_question_receipt { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(5, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(20, 100, 1, 0, 242, 100, 1, 0), + ::capnp::word(128, 101, 1, 0, 94, 102, 1, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -65024,7 +65155,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 39] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(115, 102, 1, 0, 206, 102, 1, 0), + ::capnp::word(223, 103, 1, 0, 58, 104, 1, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -65233,7 +65364,7 @@ pub mod engine_readiness { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(208, 102, 1, 0, 156, 103, 1, 0), + ::capnp::word(60, 104, 1, 0, 8, 105, 1, 0), ::capnp::word(21, 0, 0, 0, 242, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -65458,7 +65589,7 @@ pub mod host_catalog_result { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(19, 104, 1, 0, 72, 104, 1, 0), + ::capnp::word(127, 105, 1, 0, 180, 105, 1, 0), ::capnp::word(21, 0, 0, 0, 2, 1, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -65695,7 +65826,7 @@ pub mod resolve_model_selection_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(74, 104, 1, 0, 182, 104, 1, 0), + ::capnp::word(182, 105, 1, 0, 34, 106, 1, 0), ::capnp::word(21, 0, 0, 0, 90, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -66025,7 +66156,7 @@ pub mod model_selection_resolution { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 2, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(252, 104, 1, 0, 197, 105, 1, 0), + ::capnp::word(104, 106, 1, 0, 49, 107, 1, 0), ::capnp::word(21, 0, 0, 0, 58, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -66316,7 +66447,7 @@ pub mod navigation_project { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(42, 107, 1, 0, 117, 107, 1, 0), + ::capnp::word(150, 108, 1, 0, 225, 108, 1, 0), ::capnp::word(21, 0, 0, 0, 2, 1, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -66567,7 +66698,7 @@ pub mod navigation_route { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(186, 107, 1, 0, 255, 107, 1, 0), + ::capnp::word(38, 109, 1, 0, 107, 109, 1, 0), ::capnp::word(21, 0, 0, 0, 242, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -66818,7 +66949,7 @@ pub mod account_profile { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(57, 108, 1, 0, 127, 108, 1, 0), + ::capnp::word(165, 109, 1, 0, 235, 109, 1, 0), ::capnp::word(21, 0, 0, 0, 234, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -67138,7 +67269,7 @@ pub mod user_preferences { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(129, 108, 1, 0, 230, 109, 1, 0), + ::capnp::word(237, 109, 1, 0, 82, 111, 1, 0), ::capnp::word(21, 0, 0, 0, 242, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -67443,7 +67574,7 @@ pub mod record_navigation_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(40, 110, 1, 0, 117, 110, 1, 0), + ::capnp::word(148, 111, 1, 0, 225, 111, 1, 0), ::capnp::word(21, 0, 0, 0, 50, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -67698,7 +67829,7 @@ pub mod import_legacy_preferences_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(9, 111, 1, 0, 136, 111, 1, 0), + ::capnp::word(117, 112, 1, 0, 244, 112, 1, 0), ::capnp::word(21, 0, 0, 0, 106, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -67817,7 +67948,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 37] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(138, 111, 1, 0, 219, 111, 1, 0), + ::capnp::word(246, 112, 1, 0, 71, 113, 1, 0), ::capnp::word(21, 0, 0, 0, 18, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -68039,7 +68170,7 @@ pub mod legacy_preferences_imported { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(221, 111, 1, 0, 116, 112, 1, 0), + ::capnp::word(73, 113, 1, 0, 224, 113, 1, 0), ::capnp::word(21, 0, 0, 0, 66, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -68308,7 +68439,7 @@ pub mod thread_retitled { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(5, 113, 1, 0, 69, 113, 1, 0), + ::capnp::word(113, 114, 1, 0, 177, 114, 1, 0), ::capnp::word(21, 0, 0, 0, 234, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -68558,7 +68689,7 @@ pub mod resolve_engine_configuration_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(201, 113, 1, 0, 31, 114, 1, 0), + ::capnp::word(53, 115, 1, 0, 139, 115, 1, 0), ::capnp::word(21, 0, 0, 0, 130, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -68885,7 +69016,7 @@ pub mod engine_configuration_resolution { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 2, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(100, 114, 1, 0, 23, 115, 1, 0), + ::capnp::word(208, 115, 1, 0, 131, 116, 1, 0), ::capnp::word(21, 0, 0, 0, 98, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -69040,7 +69171,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 43] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(76, 115, 1, 0, 182, 115, 1, 0), + ::capnp::word(184, 116, 1, 0, 34, 117, 1, 0), ::capnp::word(21, 0, 0, 0, 10, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -69481,7 +69612,7 @@ pub mod engine_install_status { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(8, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(65, 116, 1, 0, 19, 119, 1, 0), + ::capnp::word(173, 117, 1, 0, 127, 120, 1, 0), ::capnp::word(21, 0, 0, 0, 18, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -69793,7 +69924,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 30] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(21, 119, 1, 0, 93, 119, 1, 0), + ::capnp::word(129, 120, 1, 0, 201, 120, 1, 0), ::capnp::word(21, 0, 0, 0, 242, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -69981,7 +70112,7 @@ pub mod engine_install_snapshot { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(140, 119, 1, 0, 213, 119, 1, 0), + ::capnp::word(248, 120, 1, 0, 65, 121, 1, 0), ::capnp::word(21, 0, 0, 0, 34, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -70231,7 +70362,7 @@ pub mod engine_version_entry { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(11, 120, 1, 0, 124, 120, 1, 0), + ::capnp::word(119, 121, 1, 0, 232, 121, 1, 0), ::capnp::word(21, 0, 0, 0, 10, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -70515,7 +70646,7 @@ pub mod engine_version_list { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(191, 120, 1, 0, 25, 121, 1, 0), + ::capnp::word(43, 122, 1, 0, 133, 122, 1, 0), ::capnp::word(21, 0, 0, 0, 2, 1, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -70818,7 +70949,7 @@ pub mod engine_version_change { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 3, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(81, 121, 1, 0, 212, 121, 1, 0), + ::capnp::word(189, 122, 1, 0, 64, 123, 1, 0), ::capnp::word(21, 0, 0, 0, 18, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -71085,7 +71216,7 @@ pub mod observation_history_current { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(81, 123, 1, 0, 159, 123, 1, 0), + ::capnp::word(189, 124, 1, 0, 11, 125, 1, 0), ::capnp::word(21, 0, 0, 0, 66, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -71320,7 +71451,7 @@ pub mod question_answer { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(252, 123, 1, 0, 70, 124, 1, 0), + ::capnp::word(104, 125, 1, 0, 178, 125, 1, 0), ::capnp::word(21, 0, 0, 0, 234, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -71598,7 +71729,7 @@ pub mod answer_questions_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(105, 125, 1, 0, 19, 126, 1, 0), + ::capnp::word(213, 126, 1, 0, 127, 127, 1, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -71728,7 +71859,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 34] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(61, 126, 1, 0, 130, 127, 1, 0), + ::capnp::word(169, 127, 1, 0, 238, 128, 1, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -71980,7 +72111,7 @@ pub mod answer_questions_receipt { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(254, 127, 1, 0, 132, 128, 1, 0), + ::capnp::word(106, 129, 1, 0, 240, 129, 1, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -72125,7 +72256,7 @@ pub(crate) static ENCODED_NODE: [::capnp::Word; 37] = [ ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(232, 128, 1, 0, 80, 130, 1, 0), + ::capnp::word(84, 130, 1, 0, 188, 131, 1, 0), ::capnp::word(21, 0, 0, 0, 242, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -72337,7 +72468,7 @@ pub mod live_thinking { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(195, 130, 1, 0, 131, 131, 1, 0), + ::capnp::word(47, 132, 1, 0, 239, 132, 1, 0), ::capnp::word(21, 0, 0, 0, 218, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -72917,7 +73048,7 @@ pub mod live_thinking_block { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(229, 131, 1, 0, 132, 132, 1, 0), + ::capnp::word(81, 133, 1, 0, 240, 133, 1, 0), ::capnp::word(21, 0, 0, 0, 2, 1, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -73225,7 +73356,7 @@ pub mod conversation_history_request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(242, 132, 1, 0, 36, 134, 1, 0), + ::capnp::word(94, 134, 1, 0, 144, 135, 1, 0), ::capnp::word(21, 0, 0, 0, 74, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -73742,7 +73873,7 @@ pub mod turn_work_range { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(97, 134, 1, 0, 240, 134, 1, 0), + ::capnp::word(205, 135, 1, 0, 92, 136, 1, 0), ::capnp::word(21, 0, 0, 0, 226, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -74028,7 +74159,7 @@ pub mod held_back_turn_work { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(131, 135, 1, 0, 34, 136, 1, 0), + ::capnp::word(239, 136, 1, 0, 142, 137, 1, 0), ::capnp::word(21, 0, 0, 0, 250, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -74330,7 +74461,7 @@ pub mod held_back_work { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(96, 136, 1, 0, 176, 136, 1, 0), + ::capnp::word(204, 137, 1, 0, 28, 138, 1, 0), ::capnp::word(21, 0, 0, 0, 218, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -74642,7 +74773,7 @@ pub mod conversation_history_page { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(221, 136, 1, 0, 139, 139, 1, 0), + ::capnp::word(73, 138, 1, 0, 247, 140, 1, 0), ::capnp::word(21, 0, 0, 0, 50, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -75456,7 +75587,7 @@ pub mod earlier_turn_markers { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(24, 140, 1, 0, 113, 140, 1, 0), + ::capnp::word(132, 141, 1, 0, 221, 141, 1, 0), ::capnp::word(21, 0, 0, 0, 10, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -75723,7 +75854,7 @@ pub mod earlier_turn_marker { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(225, 140, 1, 0, 60, 141, 1, 0), + ::capnp::word(77, 142, 1, 0, 168, 142, 1, 0), ::capnp::word(21, 0, 0, 0, 2, 1, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -75807,3 +75938,319 @@ pub mod earlier_turn_marker { pub(crate) const TYPE_ID: u64 = 0xf811_c1f9_c881_a9ed; } } + +#[repr(u16)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ModelPreferenceSource { + Agent = 0, + User = 1, + System = 2, +} + +impl ::capnp::introspect::Introspect for ModelPreferenceSource { + fn introspect() -> ::capnp::introspect::Type { ::capnp::introspect::TypeVariant::Enum(::capnp::introspect::RawEnumSchema::new(&model_preference_source::ARENA, model_preference_source::get_annotation_types)).into() } +} +impl ::core::convert::From for ::capnp::dynamic_value::Reader<'_> { + fn from(e: ModelPreferenceSource) -> Self { ::capnp::dynamic_value::Enum::new(e.into(), ::capnp::introspect::RawEnumSchema::new(&model_preference_source::ARENA, model_preference_source::get_annotation_types).into()).into() } +} +impl ::core::convert::TryFrom for ModelPreferenceSource { + type Error = ::capnp::NotInSchema; + fn try_from(value: u16) -> ::core::result::Result>::Error> { + match value { + 0 => ::core::result::Result::Ok(Self::Agent), + 1 => ::core::result::Result::Ok(Self::User), + 2 => ::core::result::Result::Ok(Self::System), + n => ::core::result::Result::Err(::capnp::NotInSchema(n)), + } + } +} +impl From for u16 { + #[inline] + fn from(x: ModelPreferenceSource) -> u16 { x as u16 } +} +impl ::capnp::traits::HasTypeId for ModelPreferenceSource { + const TYPE_ID: u64 = 0x8d9f_08b4_ca18_2eddu64; +} +mod model_preference_source { +pub(crate) static ENCODED_NODE: [::capnp::Word; 32] = [ + ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), + ::capnp::word(221, 46, 24, 202, 180, 8, 159, 141), + ::capnp::word(14, 0, 0, 0, 2, 0, 0, 0), + ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(248, 142, 1, 0, 58, 143, 1, 0), + ::capnp::word(21, 0, 0, 0, 34, 1, 0, 0), + ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(33, 0, 0, 0, 79, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(97, 114, 116, 105, 115, 97, 110, 46), + ::capnp::word(99, 97, 112, 110, 112, 58, 77, 111), + ::capnp::word(100, 101, 108, 80, 114, 101, 102, 101), + ::capnp::word(114, 101, 110, 99, 101, 83, 111, 117), + ::capnp::word(114, 99, 101, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 1, 0, 1, 0), + ::capnp::word(12, 0, 0, 0, 1, 0, 2, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(29, 0, 0, 0, 50, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(1, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(21, 0, 0, 0, 42, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(2, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(13, 0, 0, 0, 58, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(97, 103, 101, 110, 116, 0, 0, 0), + ::capnp::word(117, 115, 101, 114, 0, 0, 0, 0), + ::capnp::word(115, 121, 115, 116, 101, 109, 0, 0), +]; +pub(crate) fn get_annotation_types(child_index: Option, index: u32) -> ::capnp::introspect::Type { + ::capnp::introspect::panic_invalid_annotation_indices(child_index, index) +} +pub(crate) static ARENA: ::capnp::private::arena::GeneratedCodeArena = ::capnp::private::arena::GeneratedCodeArena::new(&ENCODED_NODE); +} + +pub mod save_model_preference_request { + #[derive(Copy, Clone)] + pub struct Owned(()); + impl ::capnp::introspect::Introspect for Owned { fn introspect() -> ::capnp::introspect::Type { ::capnp::introspect::TypeVariant::Struct(::capnp::introspect::RawBrandedStructSchema { generic: &_private::RAW_SCHEMA, field_types: _private::get_field_types, annotation_types: _private::get_annotation_types, type_id: ::core::any::TypeId::of::() }).into() } } + impl ::capnp::traits::Owned for Owned { type Reader<'a> = Reader<'a>; type Builder<'a> = Builder<'a>; } + impl ::capnp::traits::OwnedStruct for Owned { type Reader<'a> = Reader<'a>; type Builder<'a> = Builder<'a>; } + impl ::capnp::traits::Pipelined for Owned { type Pipeline = Pipeline; } + + pub struct Reader<'a> { reader: ::capnp::private::layout::StructReader<'a> } + impl <> ::core::marker::Copy for Reader<'_,> {} + impl <> ::core::clone::Clone for Reader<'_,> { + fn clone(&self) -> Self { *self } + } + + impl <> ::capnp::traits::HasTypeId for Reader<'_,> { + const TYPE_ID: u64 = _private::TYPE_ID; + } + impl <'a,> ::core::convert::From<::capnp::private::layout::StructReader<'a>> for Reader<'a,> { + fn from(reader: ::capnp::private::layout::StructReader<'a>) -> Self { + Self { reader, } + } + } + + impl <'a,> ::core::convert::From> for ::capnp::dynamic_value::Reader<'a> { + fn from(reader: Reader<'a,>) -> Self { + Self::Struct(::capnp::dynamic_struct::Reader::new(reader.reader, ::capnp::schema::StructSchema::new(::capnp::introspect::RawBrandedStructSchema { generic: &_private::RAW_SCHEMA, field_types: _private::get_field_types::<>, annotation_types: _private::get_annotation_types::<>, type_id: ::core::any::TypeId::of::>()}))) + } + } + + impl <> ::core::fmt::Debug for Reader<'_,> { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::result::Result<(), ::core::fmt::Error> { + core::fmt::Debug::fmt(&::core::convert::Into::<::capnp::dynamic_value::Reader<'_>>::into(*self), f) + } + } + + impl <'a,> ::capnp::traits::FromPointerReader<'a> for Reader<'a,> { + fn get_from_pointer(reader: &::capnp::private::layout::PointerReader<'a>, default: ::core::option::Option<&'a [::capnp::Word]>) -> ::capnp::Result { + ::core::result::Result::Ok(reader.get_struct(default)?.into()) + } + } + + impl <'a,> ::capnp::traits::IntoInternalStructReader<'a> for Reader<'a,> { + fn into_internal_struct_reader(self) -> ::capnp::private::layout::StructReader<'a> { + self.reader + } + } + + impl <'a,> ::capnp::traits::Imbue<'a> for Reader<'a,> { + fn imbue(&mut self, cap_table: &'a ::capnp::private::layout::CapTable) { + self.reader.imbue(::capnp::private::layout::CapTableReader::from_ref(cap_table)) + } + } + + impl <'a,> Reader<'a,> { + pub fn reborrow(&self) -> Reader<'_,> { + Self { .. *self } + } + + pub fn total_size(&self) -> ::capnp::Result<::capnp::MessageSize> { + self.reader.total_size() + } + #[inline] + pub fn get_selection(self) -> ::capnp::Result> { + ::capnp::traits::FromPointerReader::get_from_pointer(&self.reader.get_pointer_field(0), ::core::option::Option::None) + } + #[inline] + pub fn has_selection(&self) -> bool { + !self.reader.get_pointer_field(0).is_null() + } + #[inline] + pub fn get_source(self) -> ::core::result::Result { + ::core::convert::TryFrom::try_from(self.reader.get_data_field::(0)) + } + } + + pub struct Builder<'a> { builder: ::capnp::private::layout::StructBuilder<'a> } + impl <> ::capnp::traits::HasStructSize for Builder<'_,> { + const STRUCT_SIZE: ::capnp::private::layout::StructSize = ::capnp::private::layout::StructSize { data: 1, pointers: 1 }; + } + impl <> ::capnp::traits::HasTypeId for Builder<'_,> { + const TYPE_ID: u64 = _private::TYPE_ID; + } + impl <'a,> ::core::convert::From<::capnp::private::layout::StructBuilder<'a>> for Builder<'a,> { + fn from(builder: ::capnp::private::layout::StructBuilder<'a>) -> Self { + Self { builder, } + } + } + + impl <'a,> ::core::convert::From> for ::capnp::dynamic_value::Builder<'a> { + fn from(builder: Builder<'a,>) -> Self { + Self::Struct(::capnp::dynamic_struct::Builder::new(builder.builder, ::capnp::schema::StructSchema::new(::capnp::introspect::RawBrandedStructSchema { generic: &_private::RAW_SCHEMA, field_types: _private::get_field_types::<>, annotation_types: _private::get_annotation_types::<>, type_id: ::core::any::TypeId::of::>()}))) + } + } + + impl <'a,> ::capnp::traits::ImbueMut<'a> for Builder<'a,> { + fn imbue_mut(&mut self, cap_table: &'a mut ::capnp::private::layout::CapTable) { + self.builder.imbue(::capnp::private::layout::CapTableBuilder::from_ref(cap_table)) + } + } + + impl <'a,> ::capnp::traits::FromPointerBuilder<'a> for Builder<'a,> { + fn init_pointer(builder: ::capnp::private::layout::PointerBuilder<'a>, _size: u32) -> Self { + builder.init_struct(::STRUCT_SIZE).into() + } + fn get_from_pointer(builder: ::capnp::private::layout::PointerBuilder<'a>, default: ::core::option::Option<&'a [::capnp::Word]>) -> ::capnp::Result { + ::core::result::Result::Ok(builder.get_struct(::STRUCT_SIZE, default)?.into()) + } + } + + impl <> ::capnp::traits::SetterInput> for Reader<'_,> { + fn set_pointer_builder(mut pointer: ::capnp::private::layout::PointerBuilder<'_>, value: Self, canonicalize: bool) -> ::capnp::Result<()> { pointer.set_struct(&value.reader, canonicalize) } + } + + impl <'a,> Builder<'a,> { + pub fn into_reader(self) -> Reader<'a,> { + self.builder.into_reader().into() + } + pub fn reborrow(&mut self) -> Builder<'_,> { + Builder { builder: self.builder.reborrow() } + } + pub fn reborrow_as_reader(&self) -> Reader<'_,> { + self.builder.as_reader().into() + } + + pub fn total_size(&self) -> ::capnp::Result<::capnp::MessageSize> { + self.builder.as_reader().total_size() + } + #[inline] + pub fn get_selection(self) -> ::capnp::Result> { + ::capnp::traits::FromPointerBuilder::get_from_pointer(self.builder.get_pointer_field(0), ::core::option::Option::None) + } + #[inline] + pub fn set_selection(&mut self, value: crate::composer_state_capnp::catalog_selection::Reader<'_>) -> ::capnp::Result<()> { + ::capnp::traits::SetterInput::set_pointer_builder(self.builder.reborrow().get_pointer_field(0), value, false) + } + #[inline] + pub fn init_selection(self, ) -> crate::composer_state_capnp::catalog_selection::Builder<'a> { + ::capnp::traits::FromPointerBuilder::init_pointer(self.builder.get_pointer_field(0), 0) + } + #[inline] + pub fn has_selection(&self) -> bool { + !self.builder.is_pointer_field_null(0) + } + #[inline] + pub fn get_source(self) -> ::core::result::Result { + ::core::convert::TryFrom::try_from(self.builder.get_data_field::(0)) + } + #[inline] + pub fn set_source(&mut self, value: crate::artisan_capnp::ModelPreferenceSource) { + self.builder.set_data_field::(0, value as u16); + } + } + + pub struct Pipeline { _typeless: ::capnp::any_pointer::Pipeline } + impl ::capnp::capability::FromTypelessPipeline for Pipeline { + fn new(typeless: ::capnp::any_pointer::Pipeline) -> Self { + Self { _typeless: typeless, } + } + } + impl Pipeline { + pub fn get_selection(&self) -> crate::composer_state_capnp::catalog_selection::Pipeline { + ::capnp::capability::FromTypelessPipeline::new(self._typeless.get_pointer_field(0)) + } + } + mod _private { + pub(crate) static ENCODED_NODE: [::capnp::Word; 52] = [ + ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), + ::capnp::word(23, 0, 133, 41, 134, 88, 170, 167), + ::capnp::word(14, 0, 0, 0, 1, 0, 1, 0), + ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), + ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(60, 143, 1, 0, 181, 143, 1, 0), + ::capnp::word(21, 0, 0, 0, 74, 1, 0, 0), + ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(37, 0, 0, 0, 119, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(97, 114, 116, 105, 115, 97, 110, 46), + ::capnp::word(99, 97, 112, 110, 112, 58, 83, 97), + ::capnp::word(118, 101, 77, 111, 100, 101, 108, 80), + ::capnp::word(114, 101, 102, 101, 114, 101, 110, 99), + ::capnp::word(101, 82, 101, 113, 117, 101, 115, 116), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 1, 0, 1, 0), + ::capnp::word(8, 0, 0, 0, 3, 0, 4, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 1, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(41, 0, 0, 0, 82, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(40, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(52, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(1, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 1, 0, 1, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(49, 0, 0, 0, 58, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(44, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(56, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(115, 101, 108, 101, 99, 116, 105, 111), + ::capnp::word(110, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(16, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(93, 195, 54, 24, 140, 181, 151, 250), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(16, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(115, 111, 117, 114, 99, 101, 0, 0), + ::capnp::word(15, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(221, 46, 24, 202, 180, 8, 159, 141), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(15, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ]; + pub(crate) fn get_field_types(index: u16) -> ::capnp::introspect::Type { + match index { + 0 => ::introspect(), + 1 => ::introspect(), + _ => ::capnp::introspect::panic_invalid_field_index(index), + } + } + pub(crate) fn get_annotation_types(child_index: Option, index: u32) -> ::capnp::introspect::Type { + ::capnp::introspect::panic_invalid_annotation_indices(child_index, index) + } + pub(crate) static ARENA: ::capnp::private::arena::GeneratedCodeArena = ::capnp::private::arena::GeneratedCodeArena::new(&ENCODED_NODE); + pub(crate) static RAW_SCHEMA: ::capnp::introspect::RawStructSchema = ::capnp::introspect::RawStructSchema::new( + &ARENA, + NONUNION_MEMBERS, + MEMBERS_BY_DISCRIMINANT, + MEMBERS_BY_NAME + ); + pub(crate) static NONUNION_MEMBERS : &[u16] = &[0,1]; + pub(crate) static MEMBERS_BY_DISCRIMINANT : &[u16] = &[]; + pub(crate) static MEMBERS_BY_NAME : &[u16] = &[0,1]; + pub(crate) const TYPE_ID: u64 = 0xa7aa_5886_2985_0017; + } +} diff --git a/modules/protocol/src/codec/envelope.rs b/modules/protocol/src/codec/envelope.rs index 63e1eedfe..59c6da239 100644 --- a/modules/protocol/src/codec/envelope.rs +++ b/modules/protocol/src/codec/envelope.rs @@ -278,7 +278,9 @@ pub(crate) fn encode_request( } ClientRequest::Query(Query::ReadUserPreferences(_)) | ClientRequest::Command( - Command::RecordNavigation(_) | Command::ImportLegacyPreferences(_), + Command::RecordNavigation(_) + | Command::ImportLegacyPreferences(_) + | Command::SaveModelPreference(_), ) => encode_user_preferences_request(builder, value)?, ClientRequest::Query(Query::ReadRecentThreads(_)) => { encode_recent_threads_request(builder); @@ -825,7 +827,8 @@ pub(crate) fn decode_request( | request::Which::ResolveEngineConfiguration(_) => decode_forge_decision_request(value), request::Which::ReadUserPreferences(()) | request::Which::RecordNavigation(_) - | request::Which::ImportLegacyPreferences(_) => { + | request::Which::ImportLegacyPreferences(_) + | request::Which::SaveModelPreference(_) => { decode_user_preferences_request(value, &request_id) } request::Which::ReadRecentThreads(()) => Ok(decode_recent_threads_request()), diff --git a/modules/protocol/src/codec/recent_threads.rs b/modules/protocol/src/codec/recent_threads.rs index a59d85dd4..f59b711ca 100644 --- a/modules/protocol/src/codec/recent_threads.rs +++ b/modules/protocol/src/codec/recent_threads.rs @@ -3,7 +3,10 @@ #[allow(clippy::wildcard_imports)] use super::*; -use artisan_domain::{RECENT_THREADS_MAX, ReadRecentThreads, RecentThread, RecentThreadListing}; +use artisan_domain::{ + RECENT_PROJECT_ICON_MAX_BYTES, RECENT_THREADS_MAX, ReadRecentThreads, RecentProjectIcon, + RecentThread, RecentThreadListing, +}; /// Encodes the recent-threads read. pub(crate) fn encode_recent_threads_request(mut builder: request::Builder<'_>) { @@ -26,6 +29,12 @@ pub(crate) fn encode_recent_threads( let mut encoded = threads.reborrow().get(list_index(field, index)?); encode_thread(encoded.reborrow().init_thread(), &row.thread); encoded.set_subtitle(row.subtitle.as_str()); + if let Some(host) = &row.project_icon.host { + encoded.set_repository_host(host.as_str()); + if row.project_icon.png.len() <= RECENT_PROJECT_ICON_MAX_BYTES { + encoded.set_project_icon_png(&row.project_icon.png); + } + } } Ok(()) } @@ -48,10 +57,29 @@ pub(crate) fn decode_recent_threads( let rows = threads .iter() .map(|row| { + let host = read_text(row.get_repository_host(), "recentThreads.repositoryHost")?; + let png = row.get_project_icon_png()?; Ok(RecentThread { thread: decode_thread(row.get_thread()?)?, subtitle: DisplayName::parse(read_text(row.get_subtitle(), field)?) .map_err(|source| ProtocolDecodeError::DisplayName { field, source })?, + project_icon: RecentProjectIcon { + host: if host.is_empty() { + None + } else { + Some(DisplayName::parse(&host).map_err(|source| { + ProtocolDecodeError::DisplayName { + field: "recentThreads.repositoryHost", + source, + } + })?) + }, + png: if !host.is_empty() && png.len() <= RECENT_PROJECT_ICON_MAX_BYTES { + png.into() + } else { + Default::default() + }, + }, }) }) .collect::, ProtocolDecodeError>>()?; diff --git a/modules/protocol/src/codec/user_preferences.rs b/modules/protocol/src/codec/user_preferences.rs index 2a36f5494..69bfd1372 100644 --- a/modules/protocol/src/codec/user_preferences.rs +++ b/modules/protocol/src/codec/user_preferences.rs @@ -6,8 +6,8 @@ use super::*; use artisan_domain::{ AccountProfile, ImportLegacyPreferences, LegacyImportOutcome, LegacyPreferencesImported, - NAVIGATION_PROJECTS_MAX, NavigationProject, NavigationRecord, NavigationRoute, - ReadUserPreferences, RecordNavigation, UserPreferences, + ModelPreferenceSource, NAVIGATION_PROJECTS_MAX, NavigationProject, NavigationRecord, + NavigationRoute, ReadUserPreferences, RecordNavigation, SaveModelPreference, UserPreferences, }; use crate::composer_state_codec as leaf; @@ -26,6 +26,15 @@ pub(crate) fn encode_user_preferences_request( encoded.set_project_id(record.project_id.as_str()); encoded.set_thread_id(record.thread_id.as_ref().map_or("", ThreadId::as_str)); } + ClientRequest::Command(Command::SaveModelPreference(save)) => { + let mut encoded = builder.init_save_model_preference(); + encoded.set_source(match save.source { + ModelPreferenceSource::Agent => artisan_capnp::ModelPreferenceSource::Agent, + ModelPreferenceSource::User => artisan_capnp::ModelPreferenceSource::User, + ModelPreferenceSource::System => artisan_capnp::ModelPreferenceSource::System, + }); + leaf::encode_catalog_selection(encoded.init_selection(), &save.selection); + } ClientRequest::Command(Command::ImportLegacyPreferences(import)) => { let mut encoded = builder.init_import_legacy_preferences(); if let Some(selection) = &import.default_selection { @@ -78,6 +87,24 @@ pub(crate) fn decode_user_preferences_request( }, ))) } + request::Which::SaveModelPreference(save) => { + let save = save?; + let source = match save.get_source()? { + artisan_capnp::ModelPreferenceSource::Agent => ModelPreferenceSource::Agent, + artisan_capnp::ModelPreferenceSource::User => ModelPreferenceSource::User, + artisan_capnp::ModelPreferenceSource::System => ModelPreferenceSource::System, + }; + Ok(ClientRequest::Command(Command::SaveModelPreference( + SaveModelPreference { + request_id: request_id.clone(), + selection: leaf::decode_catalog_selection( + save.get_selection()?, + "request.saveModelPreference.selection", + )?, + source, + }, + ))) + } request::Which::ImportLegacyPreferences(import) => { let import = import?; let field = "request.importLegacyPreferences.projectOrder"; diff --git a/modules/tracing/Cargo.toml b/modules/tracing/Cargo.toml new file mode 100644 index 000000000..e1e954a62 --- /dev/null +++ b/modules/tracing/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "artisan-tracing" +edition.workspace = true +license-file.workspace = true +rust-version.workspace = true +version.workspace = true + +[features] +default = [] +enabled = ["dep:serde_json", "dep:artisan-build-info"] + +[dependencies] +artisan-build-info = { path = "../build_info", optional = true } +serde_json = { workspace = true, optional = true } + +[lints] +workspace = true diff --git a/modules/tracing/src/event.rs b/modules/tracing/src/event.rs new file mode 100644 index 000000000..879b1aa2e --- /dev/null +++ b/modules/tracing/src/event.rs @@ -0,0 +1,84 @@ +use std::cell::Cell; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde_json::{Value, json}; + +static NEXT_THREAD: AtomicU64 = AtomicU64::new(1); +thread_local! { + static THREAD: Cell = const { Cell::new(0) }; +} + +pub(crate) fn thread_id() -> u64 { + THREAD.with(|slot| { + if slot.get() == 0 { + slot.set(NEXT_THREAD.fetch_add(1, Ordering::Relaxed)); + } + slot.get() + }) +} + +#[derive(Clone, Debug)] +pub(crate) struct Event { + pub ts: u64, + pub tid: u64, + pub category: &'static str, + pub name: &'static str, + pub phase: &'static str, + pub id: Option, + pub args: Value, +} + +impl Event { + pub fn json(&self, pid: u32) -> Value { + let mut event = json!({ + "ts": self.ts, "pid": pid, "tid": self.tid, + "cat": self.category, "name": self.name, "ph": self.phase, + "args": self.args, + }); + if let Some(id) = self.id { + // Hex strings preserve 64-bit identities in JavaScript viewers. + event["id"] = json!(format!("{id:x}")); + } + if self.phase == "i" { + event["s"] = json!("t"); + } + if self.phase == "X" { + event["dur"] = self.args["duration_us"].clone(); + } + if matches!(self.phase, "s" | "f") { + // Flow endpoints need their own thread slices: an async span + // lives on a separate track and cannot enclose a legacy flow. + event["ph"] = json!("X"); + // One microsecond keeps the endpoint on the parser's enclosing + // slice stack while it binds the flow. This is a marker, not work. + event["dur"] = json!(1); + event["bind_id"] = event["id"].take(); + event.as_object_mut().unwrap().remove("id"); + event[if self.phase == "s" { + "flow_out" + } else { + "flow_in" + }] = json!(true); + } + event + } +} + +/// Bound each record as well as the record count. Arbitrary payload trees +/// are rejected; strings are bounded, and only sixteen scalar fields survive. +pub(crate) fn bounded_args(args: Value) -> Value { + let Value::Object(fields) = args else { + return json!({}); + }; + let fields = fields.into_iter().take(16).filter_map(|(key, mut value)| { + if key.len() > 64 || matches!(value, Value::Array(_) | Value::Object(_)) { + return None; + } + if let Value::String(text) = &mut value { + let end = text.char_indices().nth(128).map_or(text.len(), |(i, _)| i); + text.truncate(end); + } + Some((key, value)) + }); + Value::Object(fields.collect()) +} diff --git a/modules/tracing/src/export.rs b/modules/tracing/src/export.rs new file mode 100644 index 000000000..b5dbe3bf0 --- /dev/null +++ b/modules/tracing/src/export.rs @@ -0,0 +1,162 @@ +use std::collections::HashSet; +use std::fs::{self, OpenOptions}; +use std::io::{self, BufWriter, Write}; +use std::path::Path; +use std::sync::mpsc::Receiver; + +use serde_json::{Value, json}; + +use crate::event::Event; +use crate::recorder::{HISTORY_US, History}; + +pub(crate) struct Snapshot { + events: Vec, + now: u64, + dropped: u64, + evicted: u64, + reason: &'static str, +} + +impl Snapshot { + pub fn new(history: &History, now: u64, dropped: u64, reason: &'static str) -> Self { + let mut events: Vec<_> = history.events.iter().cloned().collect(); + let begins: HashSet<_> = events + .iter() + .filter(|e| e.phase == "b") + .filter_map(|e| e.id) + .collect(); + events.extend(history.threads.values().cloned()); + // Preserve an unfinished operation even when its beginning aged out. + for (begin, _) in history.open.values() { + if !begins.contains(&begin.id.unwrap_or(0)) { + let mut begin = begin.clone(); + begin.ts = begin.ts.max(now.saturating_sub(HISTORY_US)); + begin.args["begin_truncated"] = json!(true); + events.push(begin); + } + } + events.sort_by_key(|e| (e.ts, e.phase == "e")); + let mut seen = HashSet::new(); + events.retain(|event| match (event.phase, event.id) { + ("b", Some(id)) => seen.insert(id), + ("e", Some(id)) => seen.remove(&id), + _ => true, + }); + // Close unfinished async slices at the snapshot boundary, explicitly + // marking them unfinished, so every viewer displays their duration. + let unfinished: Vec<_> = events + .iter() + .filter(|e| e.phase == "b" && e.id.is_some_and(|id| seen.contains(&id))) + .cloned() + .collect(); + for mut event in unfinished { + event.ts = now; + event.phase = "e"; + event.args = json!({"unfinished": true}); + events.push(event); + } + Self { + events, + now, + dropped, + evicted: history.evicted, + reason, + } + } + + pub fn json(&self, role: &str, pid: u32) -> Value { + let tids: HashSet<_> = self.events.iter().map(|e| e.tid).collect(); + let mut events = vec![ + json!({"ph":"M", "name":"process_name", "pid":pid, "tid":0, "args":{"name":role}}), + ]; + for tid in tids { + events.push(json!({"ph":"M", "name":"thread_name", "pid":pid, "tid":tid, "args":{"name":format!("{role} thread {tid}")}})); + } + events.extend(self.events.iter().map(|event| event.json(pid))); + events.push(json!({"ph":"i", "s":"g", "cat":"recorder", "name":"capture", "ts":self.now, "pid":pid, "tid":0, + "args":{"reason":self.reason, "dropped":self.dropped, "evicted":self.evicted}})); + json!({"traceEvents":events, "displayTimeUnit":"ms", "metadata":{ + "format_version":1, "role":role, "reason":self.reason, + "session_start_us": crate::recorder::session_start_us(), + "build": artisan_build_info::version_line(), + "clock":"Unix microseconds anchored to process monotonic clock", + "dropped_records":self.dropped, "evicted_records":self.evicted, + }}) + } +} + +pub(crate) fn writer(snapshots: Receiver, directory: &Path, role: &str) { + let pid = std::process::id(); + for (sequence, snapshot) in snapshots.into_iter().enumerate() { + if let Err(error) = write_snapshot(directory, role, pid, sequence, &snapshot) { + eprintln!("Artisan trace export failed: {error}"); + } + } +} + +fn write_snapshot( + directory: &Path, + role: &str, + pid: u32, + sequence: usize, + snapshot: &Snapshot, +) -> io::Result<()> { + fs::create_dir_all(directory)?; + let stem = format!("{role}-{pid}-{}-{sequence}", snapshot.now); + let partial = directory.join(format!("{stem}.partial")); + let destination = directory.join(format!("{stem}.json")); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let result = (|| { + let mut output = BufWriter::new(options.open(&partial)?); + serde_json::to_writer(&mut output, &snapshot.json(role, pid))?; + output.flush()?; + drop(output); + fs::rename(&partial, &destination) + })(); + if result.is_err() { + let _ = fs::remove_file(&partial); + } + result?; + retain_recent(directory, role)?; + eprintln!( + "Artisan trace saved ({}): {}", + snapshot.reason, + destination.display() + ); + Ok(()) +} + +fn retain_recent(directory: &Path, role: &str) -> io::Result<()> { + let prefix = format!("{role}-"); + let mut files: Vec<_> = fs::read_dir(directory)? + .filter_map(Result::ok) + .filter(|entry| { + entry.file_name().to_string_lossy().starts_with(&prefix) + && entry.path().extension().is_some_and(|ext| ext == "json") + }) + .filter_map(|entry| { + entry + .metadata() + .ok() + .filter(std::fs::Metadata::is_file) + .and_then(|m| m.modified().ok()) + .map(|modified| (modified, entry.path())) + }) + .collect(); + files.sort_by_key(|(modified, _)| *modified); + let remove = files.len().saturating_sub(16); + for (_, path) in files.into_iter().take(remove) { + fs::remove_file(path)?; + } + Ok(()) +} + +#[cfg(test)] +#[path = "../../../tests/tracing/export.rs"] +mod tests; diff --git a/modules/tracing/src/lib.rs b/modules/tracing/src/lib.rs new file mode 100644 index 000000000..4eba065bd --- /dev/null +++ b/modules/tracing/src/lib.rs @@ -0,0 +1,106 @@ +//! Local, bounded flight recorder. Without `enabled`, only erasing macros +//! exist: arguments are neither evaluated nor compiled, and no runtime, +//! serializer, writer, environment handling, or watchdog is included. +#![forbid(unsafe_code)] + +#[cfg(feature = "enabled")] +mod event; +#[cfg(feature = "enabled")] +mod export; +#[cfg(feature = "enabled")] +mod recorder; +#[cfg(feature = "enabled")] +mod watchdog; +#[cfg(feature = "enabled")] +pub use recorder::{ + Session, Span, completed, incident, instant, is_recording, name_thread, save, span, start, +}; +#[cfg(feature = "enabled")] +#[doc(hidden)] +pub use serde_json::json; +#[cfg(feature = "enabled")] +pub use watchdog::Heartbeat; + +/// Async span, valid across executor threads and concurrent operations. +#[cfg(feature = "enabled")] +#[macro_export] +macro_rules! span { + ($category:expr, $name:expr $(, $key:literal => $value:expr)* $(,)?) => { + if $crate::is_recording() { + $crate::span($category, $name, $crate::json!({$($key: $value),*})) + } else { + $crate::Span::inactive() + } + }; +} +#[cfg(not(feature = "enabled"))] +#[macro_export] +macro_rules! span { + ($($tokens:tt)*) => { + () + }; +} + +/// Instant breadcrumb. Call sites must supply enums, IDs, and counts only. +#[cfg(feature = "enabled")] +#[macro_export] +macro_rules! instant { + ($category:expr, $name:expr $(, $key:literal => $value:expr)* $(,)?) => { + if $crate::is_recording() { + $crate::instant($category, $name, $crate::json!({$($key: $value),*})) + } + }; +} +#[cfg(not(feature = "enabled"))] +#[macro_export] +macro_rules! instant { + ($($tokens:tt)*) => { + () + }; +} + +#[cfg(feature = "enabled")] +#[macro_export] +macro_rules! counter { + ($category:expr, $name:expr $(, $key:literal => $value:expr)* $(,)?) => { + if $crate::is_recording() { + $crate::recorder_counter($category, $name, $crate::json!({$($key: $value),*})) + } + }; +} +#[cfg(feature = "enabled")] +#[doc(hidden)] +pub use recorder::counter as recorder_counter; +#[cfg(not(feature = "enabled"))] +#[macro_export] +macro_rules! counter { + ($($tokens:tt)*) => { + () + }; +} + +#[cfg(feature = "enabled")] +#[macro_export] +macro_rules! incident { + ($reason:expr) => { + $crate::incident($reason) + }; +} +#[cfg(not(feature = "enabled"))] +#[macro_export] +macro_rules! incident { + ($($tokens:tt)*) => { + () + }; +} + +#[cfg(all(test, not(feature = "enabled")))] +mod disabled_tests { + #[test] + fn erased_arguments_need_not_resolve_or_run() { + crate::span!("ui", "removed", "value" => nonexistent::function()); + crate::instant!("ui", "removed", "value" => panic!("must not run")); + crate::counter!("ui", "removed", "value" => nonexistent::function()); + crate::incident!(nonexistent::function()); + } +} diff --git a/modules/tracing/src/recorder.rs b/modules/tracing/src/recorder.rs new file mode 100644 index 000000000..b6985aecd --- /dev/null +++ b/modules/tracing/src/recorder.rs @@ -0,0 +1,477 @@ +use std::collections::{HashMap, VecDeque}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc::{self, Receiver, SyncSender}; +use std::sync::{Arc, OnceLock}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde_json::{Value, json}; + +use crate::event::{Event, bounded_args, thread_id}; +use crate::export::{Snapshot, writer}; + +const CHANNEL_CAPACITY: usize = 8192; +pub(crate) const HISTORY_CAPACITY: usize = 32768; +pub(crate) const HISTORY_US: u64 = 120_000_000; +const OPEN_CAPACITY: usize = 2048; +const SLOW_OPERATION_US: u64 = 5_000_000; +const POST_INCIDENT_US: u64 = 2_000_000; +const COOLDOWN_US: u64 = 10_000_000; +/// A reason that repeats within this window saves no further snapshot: the +/// first one of a burst already holds the history leading into it, and a +/// storm of repeats would rotate older, distinct incidents out of the folder. +const REPEAT_COOLDOWN_US: u64 = 300_000_000; + +static RECORDER: OnceLock> = OnceLock::new(); +static NEXT_SPAN: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug)] +struct Recorder { + origin: Instant, + epoch_us: u64, + events: SyncSender, + controls: SyncSender, + dropped: AtomicU64, + running: AtomicBool, +} + +impl Recorder { + fn now(&self) -> u64 { + self.epoch_us.saturating_add(micros(self.origin.elapsed())) + } + + fn record(&self, event: Event) { + if self.running.load(Ordering::Relaxed) && self.events.try_send(event).is_err() { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + } +} + +#[derive(Debug)] +enum Control { + Save, + Incident(&'static str), +} + +/// Own until the application exits. Drop flushes on shutdown, after the UI +/// loop has returned. Recording never waits for a writer or queue capacity. +pub struct Session { + recorder: Arc, + worker: Option>, +} + +impl Drop for Session { + fn drop(&mut self) { + self.recorder.running.store(false, Ordering::Relaxed); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +/// Start once per process. Debug builds record by default; set +/// `ARTISAN_TRACE=0` to disable, or `ARTISAN_TRACE_DIR` to choose the directory +/// (see [`default_directory`] otherwise). +/// Production builds do not compile this function at all. +pub fn start(role: &'static str) -> Option { + if RECORDER.get().is_some() || std::env::var("ARTISAN_TRACE").as_deref() == Ok("0") { + return None; + } + let directory = + std::env::var_os("ARTISAN_TRACE_DIR").map_or_else(default_directory, PathBuf::from); + start_at(role, directory) +} + +/// Where traces go without `ARTISAN_TRACE_DIR`. On Linux that is the XDG +/// state directory, not `/tmp`: a WSL restart empties `/tmp`, and the traces +/// a hang left behind are exactly what explains the restart. Elsewhere the +/// temporary directory persists across restarts already. +fn default_directory() -> PathBuf { + #[cfg(target_os = "linux")] + { + let state = std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .or_else(|| { + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")) + }); + if let Some(state) = state { + return state.join("artisan").join("traces"); + } + } + std::env::temp_dir().join("artisan-traces") +} + +fn start_at(role: &'static str, directory: PathBuf) -> Option { + let (events, receiver) = mpsc::sync_channel(CHANNEL_CAPACITY); + let (controls, control_rx) = mpsc::sync_channel(16); + let recorder = Arc::new(Recorder { + origin: Instant::now(), + epoch_us: micros( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(), + ), + events, + controls, + dropped: AtomicU64::new(0), + running: AtomicBool::new(true), + }); + let worker_recorder = Arc::clone(&recorder); + let worker = std::thread::Builder::new() + .name("artisan-trace-recorder".into()) + .spawn(move || collect(&worker_recorder, &receiver, &control_rx, directory, role)) + .ok()?; + if RECORDER.set(Arc::clone(&recorder)).is_err() { + recorder.running.store(false, Ordering::Relaxed); + let _ = worker.join(); + return None; + } + Some(Session { + recorder, + worker: Some(worker), + }) +} + +/// Nonblocking request to save the preceding history immediately. +#[must_use] +pub fn save() -> bool { + control(Control::Save) +} + +#[must_use] +pub fn is_recording() -> bool { + RECORDER + .get() + .is_some_and(|r| r.running.load(Ordering::Relaxed)) +} + +pub(crate) fn session_start_us() -> Option { + RECORDER.get().map(|recorder| recorder.epoch_us) +} + +pub fn incident(reason: &'static str) { + let _ = control(Control::Incident(reason)); +} + +fn control(control: Control) -> bool { + RECORDER.get().is_some_and(|recorder| { + recorder.running.load(Ordering::Relaxed) && recorder.controls.try_send(control).is_ok() + }) +} + +fn emit(category: &'static str, name: &'static str, phase: &'static str, args: Value) { + if let Some(recorder) = RECORDER.get() { + recorder.record(Event { + ts: recorder.now(), + tid: thread_id(), + category, + name, + phase, + id: None, + args: bounded_args(args), + }); + } +} + +pub fn instant(category: &'static str, name: &'static str, args: Value) { + emit(category, name, "i", args); +} + +pub fn counter(category: &'static str, name: &'static str, args: Value) { + emit(category, name, "C", args); +} + +pub fn name_thread(name: &'static str) { + emit("metadata", "thread_name", "M", json!({"name":name})); +} + +/// Import an already measured synchronous task or frame using the same +/// monotonic clock as the recorder, rather than its later collection time. +pub fn completed( + category: &'static str, + name: &'static str, + start: Instant, + end: Instant, + args: Value, +) { + if let Some(recorder) = RECORDER.get() { + let ts = if start >= recorder.origin { + recorder + .epoch_us + .saturating_add(micros(start.duration_since(recorder.origin))) + } else { + recorder + .epoch_us + .saturating_sub(micros(recorder.origin.duration_since(start))) + }; + let mut args = bounded_args(args); + args["duration_us"] = json!(micros(end.saturating_duration_since(start))); + recorder.record(Event { + ts, + tid: thread_id(), + category, + name, + phase: "X", + id: None, + args, + }); + } +} + +/// Async begin/end pair. Its ID remains stable if the executor migrates it. +#[derive(Debug)] +pub struct Span { + begin: Option, +} + +pub fn span(category: &'static str, name: &'static str, args: Value) -> Span { + let Some(recorder) = RECORDER.get().filter(|r| r.running.load(Ordering::Relaxed)) else { + return Span { begin: None }; + }; + let begin = Event { + ts: recorder.now(), + tid: thread_id(), + category, + name, + phase: "b", + id: Some(NEXT_SPAN.fetch_add(1, Ordering::Relaxed)), + args: bounded_args(args), + }; + recorder.record(begin.clone()); + Span { begin: Some(begin) } +} + +impl Span { + #[must_use] + pub const fn inactive() -> Self { + Self { begin: None } + } + + /// Connect the producer and consumer lanes with a Chrome flow arrow. + pub fn handoff(&self) { + if let (Some(begin), Some(recorder)) = (&self.begin, RECORDER.get()) { + let mut flow = begin.clone(); + flow.phase = "s"; + flow.category = "flow"; + flow.name = "command.handoff"; + flow.args = json!({}); + recorder.record(flow.clone()); + flow.phase = "f"; + flow.ts = recorder.now(); + flow.tid = thread_id(); + recorder.record(flow); + } + } + + #[must_use] + pub fn id(&self) -> u64 { + self.begin.as_ref().and_then(|event| event.id).unwrap_or(0) + } +} + +impl Drop for Span { + fn drop(&mut self) { + if let (Some(mut end), Some(recorder)) = (self.begin.take(), RECORDER.get()) { + end.phase = "e"; + end.ts = recorder.now(); + end.args = json!({}); + recorder.record(end); + } + } +} + +pub(crate) fn micros(duration: Duration) -> u64 { + u64::try_from(duration.as_micros()).unwrap_or(u64::MAX) +} + +pub(crate) struct History { + pub events: VecDeque, + pub open: HashMap, + pub threads: HashMap, + pub evicted: u64, +} + +impl History { + pub fn new() -> Self { + Self { + events: VecDeque::new(), + open: HashMap::new(), + threads: HashMap::new(), + evicted: 0, + } + } + + pub fn push(&mut self, event: Event) { + if event.phase == "M" && event.name == "thread_name" && self.threads.len() < 256 { + self.threads.insert(event.tid, event.clone()); + } + if let Some(id) = event.id { + match event.phase { + "b" => { + if self.open.len() >= OPEN_CAPACITY + && let Some(oldest) = self + .open + .iter() + .min_by_key(|(_, (e, _))| e.ts) + .map(|(id, _)| *id) + { + self.open.remove(&oldest); + self.evicted += 1; + } + self.open.insert(id, (event.clone(), false)); + } + "e" => { + self.open.remove(&id); + } + _ => {} + } + } + self.events.push_back(event); + self.prune(); + } + + fn prune(&mut self) { + // Producers can enqueue in a different order from their timestamps. + let newest = self.events.back().map_or(0, |event| event.ts); + while self.events.len() > HISTORY_CAPACITY + || self + .events + .front() + .is_some_and(|e| newest.saturating_sub(e.ts) > HISTORY_US) + { + self.events.pop_front(); + self.evicted += 1; + } + } + + fn expire(&mut self, now: u64) { + let minimum = now.saturating_sub(HISTORY_US); + let before = self.events.len(); + self.events.retain(|e| e.ts >= minimum); + self.evicted += (before - self.events.len()) as u64; + } + + fn slow_operation(&mut self, now: u64) -> Option<&'static str> { + for (event, warned) in self.open.values_mut() { + if !*warned + && matches!( + event.category, + "navigation" | "project.intake" | "transport.request" | "ui" + ) + && now.saturating_sub(event.ts) >= SLOW_OPERATION_US + { + *warned = true; + return Some("operation.stalled"); + } + } + None + } +} + +fn collect( + recorder: &Recorder, + events: &Receiver, + controls: &Receiver, + directory: PathBuf, + role: &'static str, +) { + let (snapshots, snapshot_rx) = mpsc::sync_channel(2); + let Ok(writer_thread) = std::thread::Builder::new() + .name("artisan-trace-writer".into()) + .spawn(move || writer(snapshot_rx, &directory, role)) + else { + recorder.running.store(false, Ordering::Relaxed); + eprintln!("Artisan trace recorder could not start its writer thread"); + return; + }; + let mut history = History::new(); + let mut pending: Option<(u64, &'static str)> = None; + let mut last_incident: Option = None; + let mut last_by_reason: Vec<(&'static str, u64)> = Vec::new(); + while recorder.running.load(Ordering::Relaxed) { + if let Ok(event) = events.recv_timeout(Duration::from_millis(100)) { + history.push(event); + } + // Bound each drain so continuous producers cannot starve controls. + for event in events.try_iter().take(1024) { + history.push(event); + } + let now = recorder.now(); + history.expire(now); + for control in controls.try_iter().take(16) { + match control { + Control::Save => send_snapshot(&snapshots, &history, recorder, "manual"), + Control::Incident(reason) => { + schedule(&mut pending, last_incident, &last_by_reason, now, reason); + } + } + } + if let Some(reason) = history.slow_operation(now) { + schedule(&mut pending, last_incident, &last_by_reason, now, reason); + } + if let Some((due, reason)) = pending + && now >= due + { + send_snapshot(&snapshots, &history, recorder, reason); + pending = None; + last_incident = Some(now); + last_by_reason.retain(|(known, _)| *known != reason); + last_by_reason.push((reason, now)); + } + } + for event in events.try_iter() { + history.push(event); + } + // Only this background thread may wait for disk on final shutdown. + let _ = snapshots.send(Snapshot::new( + &history, + recorder.now(), + recorder.dropped.load(Ordering::Relaxed), + "shutdown", + )); + drop(snapshots); + let _ = writer_thread.join(); +} + +#[cfg(test)] +#[path = "../../../tests/tracing/recorder.rs"] +mod tests; + +fn schedule( + pending: &mut Option<(u64, &'static str)>, + last: Option, + last_by_reason: &[(&'static str, u64)], + now: u64, + reason: &'static str, +) { + let repeated = last_by_reason + .iter() + .any(|(known, at)| *known == reason && now.saturating_sub(*at) < REPEAT_COOLDOWN_US); + if pending.is_none() + && !repeated + && last.is_none_or(|last| now.saturating_sub(last) >= COOLDOWN_US) + { + *pending = Some((now.saturating_add(POST_INCIDENT_US), reason)); + } +} + +fn send_snapshot( + snapshots: &SyncSender, + history: &History, + recorder: &Recorder, + reason: &'static str, +) { + if snapshots + .try_send(Snapshot::new( + history, + recorder.now(), + recorder.dropped.load(Ordering::Relaxed), + reason, + )) + .is_err() + { + recorder.dropped.fetch_add(1, Ordering::Relaxed); + } +} diff --git a/modules/tracing/src/watchdog.rs b/modules/tracing/src/watchdog.rs new file mode 100644 index 000000000..464eab9ca --- /dev/null +++ b/modules/tracing/src/watchdog.rs @@ -0,0 +1,80 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use crate::recorder::{incident, instant, micros}; + +/// Tick on the thread being monitored; a separate OS thread observes it. +/// Starts armed only after the first tick, excluding application startup. +pub struct Heartbeat { + origin: Instant, + stamp: Arc, + running: Arc, +} + +impl Heartbeat { + #[must_use] + pub fn new(name: &'static str) -> Self { + let origin = Instant::now(); + let stamp = Arc::new(AtomicU64::new(u64::MAX)); + let running = Arc::new(AtomicBool::new(true)); + let watched = Arc::clone(&stamp); + let active = Arc::clone(&running); + let _ = std::thread::Builder::new() + .name("artisan-trace-watchdog".into()) + .spawn(move || { + let mut stalled = false; + while active.load(Ordering::Relaxed) { + let before = Instant::now(); + std::thread::sleep(Duration::from_millis(100)); + if before.elapsed() > Duration::from_secs(1) { + instant( + "watchdog", + "process.paused", + serde_json::json!({"monitor":name}), + ); + stalled = false; + continue; + } + let last = watched.load(Ordering::Relaxed); + if last == u64::MAX { + continue; + } + let lag = micros(origin.elapsed()).saturating_sub(last); + if lag > 500_000 && !stalled { + instant( + "watchdog", + "heartbeat.stalled", + serde_json::json!({"monitor":name, "lag_ms":lag / 1000}), + ); + incident("heartbeat.stalled"); + stalled = true; + } else if lag <= 500_000 && stalled { + instant( + "watchdog", + "heartbeat.resumed", + serde_json::json!({"monitor":name}), + ); + incident("heartbeat.resumed"); + stalled = false; + } + } + }); + Self { + origin, + stamp, + running, + } + } + + pub fn tick(&self) { + self.stamp + .store(micros(self.origin.elapsed()), Ordering::Relaxed); + } +} + +impl Drop for Heartbeat { + fn drop(&mut self) { + self.running.store(false, Ordering::Relaxed); + } +} diff --git a/modules/ui/src/flame.rs b/modules/ui/src/flame.rs new file mode 100644 index 000000000..b3b841ba8 --- /dev/null +++ b/modules/ui/src/flame.rs @@ -0,0 +1,177 @@ +//! Varde's button material (`@utility button-flame` and its `button-green` / +//! `button-red` variants in `varde/modules/common/ui/src/global.css`), +//! ported for native fills. +//! +//! Each CSS face is a three-stop vertical gradient: bright at the top, mid at +//! 48 %, deep at the bottom. GPUI's gradient primitive takes two stops, so +//! the face is two stacked layers that meet at the mid stop, each rounded on +//! its outer corners only. The white top highlight, the dark bottom shade and +//! Varde's dark-mode `--shadow-sm` map onto box shadows. The button rim is +//! left out: fills painted with this material carry no border. + +#![forbid(unsafe_code)] + +use gpui::{ + BoxShadow, Div, Hsla, ParentElement as _, Pixels, Styled as _, div, linear_color_stop, + linear_gradient, px, relative, rgb, rgb_to_hsla, rgba, +}; + +use crate::gradient::VERTICAL_ANGLE_DEGREES; + +/// Where the mid stop sits on the vertical gradient line. +pub const MATERIAL_MID_STOP: f32 = 0.48; + +/// The three stops of one material face, as `0xRRGGBB`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MaterialFace { + /// Top stop. + pub bright: u32, + /// Stop at [`MATERIAL_MID_STOP`]. + pub mid: u32, + /// Bottom stop, also the base under the face. + pub deep: u32, +} + +impl MaterialFace { + /// `button-green`. + pub const GREEN: Self = Self { + bright: 0x0015_803d, + mid: 0x0008_7344, + deep: 0x0006_5f46, + }; + /// `button-flame`, Varde's default action face. + pub const FLAME: Self = Self { + bright: 0x00ee_741f, + mid: 0x00d9_4c12, + deep: 0x00ab_2913, + }; + /// `button-red`. + pub const RED: Self = Self { + bright: 0x00ef_5555, + mid: 0x00cc_3030, + deep: 0x0099_1b1b, + }; + + /// The face at `level` (`0.0..=1.0`) along green → flame → red, each + /// stop blended channel by channel. + #[must_use] + pub fn green_to_red(level: f32) -> Self { + let level = if level.is_finite() { + level.clamp(0.0, 1.0) + } else { + 0.0 + }; + if level <= 0.5 { + Self::GREEN.mix(Self::FLAME, level * 2.0) + } else { + Self::FLAME.mix(Self::RED, (level - 0.5) * 2.0) + } + } + + fn mix(self, other: Self, amount: f32) -> Self { + Self { + bright: mix_hex(self.bright, other.bright, amount), + mid: mix_hex(self.mid, other.mid, amount), + deep: mix_hex(self.deep, other.deep, amount), + } + } +} + +#[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + reason = "each 8-bit channel is blended in f32 and rounded back within 0..=255" +)] +fn mix_hex(from: u32, to: u32, amount: f32) -> u32 { + let channel = |shift: u32| { + let start = ((from >> shift) & 0xff) as f32; + let end = ((to >> shift) & 0xff) as f32; + (start + (end - start) * amount).round().clamp(0.0, 255.0) as u32 + }; + (channel(16) << 16) | (channel(8) << 8) | channel(0) +} + +fn paint(color: u32) -> Hsla { + rgb_to_hsla(rgb(color)) +} + +/// The face's box shadows: `inset 0 1px 0` white at 28 %, +/// `inset 0 -2px 0` `rgb(75 15 0)` at 28 %, and Varde's dark-mode +/// `--shadow-sm` (`0 2px 6px -1px` `#080808` at 28 %). +#[must_use] +pub fn material_shadows() -> Vec { + vec![ + // 0x47 is 28 % alpha. + BoxShadow::new(px(0.0), px(1.0), rgb_to_hsla(rgba(0xffff_ff47))).inset(), + BoxShadow::new(px(0.0), px(-2.0), rgb_to_hsla(rgba(0x4b0f_0047))).inset(), + BoxShadow::new(px(0.0), px(2.0), rgb_to_hsla(rgba(0x0808_0847))) + .blur_radius(px(6.0)) + .spread_radius(px(-1.0)), + ] +} + +/// A block painted with `face` at corner `radius`. The caller sizes and +/// positions it; the gradient layers fill it behind any children. +#[must_use] +pub fn material_face(face: MaterialFace, radius: Pixels) -> Div { + let upper = div() + .absolute() + .left(px(0.0)) + .right(px(0.0)) + .top(px(0.0)) + .h(relative(MATERIAL_MID_STOP)) + .rounded_tl(radius) + .rounded_tr(radius) + .bg(linear_gradient( + VERTICAL_ANGLE_DEGREES, + linear_color_stop(paint(face.bright), 0.0), + linear_color_stop(paint(face.mid), 1.0), + )); + let lower = div() + .absolute() + .left(px(0.0)) + .right(px(0.0)) + .bottom(px(0.0)) + .h(relative(1.0 - MATERIAL_MID_STOP)) + .rounded_bl(radius) + .rounded_br(radius) + .bg(linear_gradient( + VERTICAL_ANGLE_DEGREES, + linear_color_stop(paint(face.mid), 0.0), + linear_color_stop(paint(face.deep), 1.0), + )); + div() + .relative() + .rounded(radius) + .bg(paint(face.deep)) + .shadow(material_shadows()) + .child(upper) + .child(lower) +} + +#[cfg(test)] +mod tests { + use super::{MATERIAL_MID_STOP, MaterialFace, material_shadows}; + + #[test] + fn the_face_keeps_varde_geometry() { + let shadows = material_shadows(); + assert_eq!(shadows.len(), 3); + assert!(shadows[0].inset && shadows[1].inset && !shadows[2].inset); + assert!((shadows[0].color.alpha - 0.28).abs() < 0.01); + assert!((MATERIAL_MID_STOP - 0.48).abs() < f32::EPSILON); + } + + #[test] + fn the_level_runs_green_through_flame_to_red() { + assert_eq!(MaterialFace::green_to_red(0.0), MaterialFace::GREEN); + assert_eq!(MaterialFace::green_to_red(0.5), MaterialFace::FLAME); + assert_eq!(MaterialFace::green_to_red(1.0), MaterialFace::RED); + assert_eq!(MaterialFace::green_to_red(2.0), MaterialFace::RED); + assert_eq!(MaterialFace::green_to_red(f32::NAN), MaterialFace::GREEN); + let quarter = MaterialFace::green_to_red(0.25); + assert_ne!(quarter, MaterialFace::GREEN); + assert_ne!(quarter, MaterialFace::FLAME); + } +} diff --git a/modules/ui/src/lib.rs b/modules/ui/src/lib.rs index 12d47e5e0..466558ef3 100644 --- a/modules/ui/src/lib.rs +++ b/modules/ui/src/lib.rs @@ -15,6 +15,7 @@ pub mod copy_feedback; pub mod dialog; pub mod dropdown_menu; pub mod fade_arc; +pub mod flame; pub mod fonts; pub mod glass; pub mod gradient; @@ -29,6 +30,7 @@ pub mod list_row; pub mod markdown; pub mod markdown_cache; pub mod markdown_renderer; +pub mod markdown_reveal; pub mod motion; pub mod native_select; pub mod popover; diff --git a/modules/ui/src/markdown.rs b/modules/ui/src/markdown.rs index d1634b479..4aa406756 100644 --- a/modules/ui/src/markdown.rs +++ b/modules/ui/src/markdown.rs @@ -740,7 +740,7 @@ impl FormatFrame { /// markers (`- [ ]` / `- [x]`), which `pulldown-cmark` only scans when /// [`Options::ENABLE_TASKLISTS`] is set, and GFM tables. No other GFM /// extension is enabled. -const PARSE_OPTIONS: Options = Options::ENABLE_TASKLISTS.union(Options::ENABLE_TABLES); +pub(crate) const PARSE_OPTIONS: Options = Options::ENABLE_TASKLISTS.union(Options::ENABLE_TABLES); impl DocumentBuilder { /// Walks every offset-tagged event of `source` into blocks. diff --git a/modules/ui/src/markdown_renderer.rs b/modules/ui/src/markdown_renderer.rs index 8e845a560..473dde75b 100644 --- a/modules/ui/src/markdown_renderer.rs +++ b/modules/ui/src/markdown_renderer.rs @@ -56,7 +56,7 @@ #![allow(clippy::module_name_repetitions)] -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::ops::Range; use std::rc::Rc; use std::sync::Arc; @@ -78,6 +78,7 @@ use crate::markdown::{ Block, CodeToken, CodeTokenKind, ListItem, MarkdownDocument, MarkdownEngine, Span, Table, }; use crate::markdown_cache::{MarkdownParseCache, MarkdownParseReport, MarkdownWorkCounters}; +use crate::markdown_reveal::{RevealFade, apply_reveal_fade}; use crate::selectable_text::SelectableText; use crate::theme::{ArtisanTheme, Oklch, ProseTypography, RadiusStep, RadiusTokens}; @@ -207,7 +208,39 @@ impl MarkdownRenderer { titles: &dyn RichLinkTitleSource, ) -> AnyElement { let prepared = self.cache.borrow_mut().prepared(&self.engine, source); - self.render_prepared(prepared, source, theme, selector.into(), tone, titles) + self.render_prepared(prepared, source, theme, selector.into(), tone, titles, None) + .0 + } + + /// Renders one revealing prefix of a streamed body, fading freshly + /// revealed units by `fade`. + /// + /// Returns the element and the body's visible length in the fade's + /// coordinates: leaf text bytes in document order, with each fence, + /// table, and raw HTML block counted as one unit. A caller marks the + /// next revealed segment at the length this returned for the previous + /// prefix. Fading recolors glyphs and blocks only; layout is exactly + /// that of [`Self::render_source_with_tone_and_titles`]. + #[must_use] + pub fn render_revealing_source( + &self, + source: &str, + theme: ArtisanTheme, + selector: impl Into, + tone: MarkdownBodyTone, + titles: &dyn RichLinkTitleSource, + fade: &RevealFade, + ) -> (AnyElement, usize) { + let prepared = self.cache.borrow_mut().prepared(&self.engine, source); + self.render_prepared( + prepared, + source, + theme, + selector.into(), + tone, + titles, + Some(fade), + ) } /// Renders like [`Self::render_source_with_tone_and_titles`] from a body @@ -229,9 +262,14 @@ impl MarkdownRenderer { .cache .borrow_mut() .prepared_shared(&self.engine, source); - self.render_prepared(prepared, source, theme, selector.into(), tone, titles) + self.render_prepared(prepared, source, theme, selector.into(), tone, titles, None) + .0 } + #[expect( + clippy::too_many_arguments, + reason = "the one render seam threads the prepared body, its theme and identity, the tone, title lookup, and optional reveal fade" + )] fn render_prepared( &self, prepared: Option>, @@ -240,13 +278,14 @@ impl MarkdownRenderer { selector: SharedString, tone: MarkdownBodyTone, titles: &dyn RichLinkTitleSource, - ) -> AnyElement { + fade: Option<&RevealFade>, + ) -> (AnyElement, usize) { let Some(prepared) = prepared else { - return plain_source(source, &theme, &selector, tone, self.prose_measure); + return plain_source(source, &theme, &selector, tone, self.prose_measure, fade); }; let blocks = prepared.document().blocks(); if !source.is_empty() && blocks.is_empty() { - return plain_source(source, &theme, &selector, tone, self.prose_measure); + return plain_source(source, &theme, &selector, tone, self.prose_measure, fade); } let ids = prepared.ids(&selector); @@ -256,6 +295,8 @@ impl MarkdownRenderer { titles, ids: &ids, counters: prepared.counters(), + fade, + cursor: Cell::new(0), }; let mut root = markdown_root(render.id(ROOT_SLOT), &theme, tone); let root_blocks = prepared.root(); @@ -270,7 +311,7 @@ impl MarkdownRenderer { measure, )); } - root.into_any_element() + (root.into_any_element(), render.cursor.get()) } } @@ -282,6 +323,11 @@ struct RenderContext<'a> { /// This document's element ids under the caller's selector. ids: &'a [SharedString], counters: &'a MarkdownWorkCounters, + /// The reveal fade of a streaming body, if any. + fade: Option<&'a RevealFade>, + /// Visible units rendered so far, in document order (see + /// [`MarkdownRenderer::render_revealing_source`]). + cursor: Cell, } impl RenderContext<'_> { @@ -290,6 +336,52 @@ impl RenderContext<'_> { fn id(&self, slot: Slot) -> SharedString { self.ids.get(slot).cloned().unwrap_or_default() } + + /// Claims the next `len` visible units and returns their translucent + /// leaf-local spans. + fn claim_leaf(&self, len: usize) -> Vec<(Range, f32)> { + let base = self.cursor.get(); + self.cursor.set(base + len); + self.fade + .map(|fade| fade.leaf_spans(base, len)) + .unwrap_or_default() + } + + /// Claims one atomic unit and returns its opacity. + fn claim_block(&self) -> f32 { + let at = self.cursor.get(); + self.cursor.set(at + 1); + self.fade.map_or(1.0, |fade| fade.alpha_at(at)) + } + + /// A context for the inside of an atomic block: same theme and ids, no + /// fade, and a private cursor. + fn without_fade(&self) -> RenderContext<'_> { + RenderContext { + theme: self.theme, + tone: self.tone, + titles: self.titles, + ids: self.ids, + counters: self.counters, + fade: None, + cursor: Cell::new(0), + } + } + + /// The opacity of whatever renders next, without claiming it. + fn alpha_next(&self) -> f32 { + self.fade + .map_or(1.0, |fade| fade.alpha_at(self.cursor.get())) + } +} + +/// Fades one whole block by `alpha`, untouched at full opacity. +fn faded(element: AnyElement, alpha: f32) -> AnyElement { + if alpha >= 1.0 { + element + } else { + div().opacity(alpha).child(element).into_any_element() + } } /// Read-only lookup for resolved rich-link page titles. @@ -338,7 +430,11 @@ fn plain_source( selector: &str, tone: MarkdownBodyTone, measure: Option, -) -> AnyElement { + fade: Option<&RevealFade>, +) -> (AnyElement, usize) { + let spans = fade + .map(|fade| fade.leaf_spans(0, source.len())) + .unwrap_or_default(); let selector = format!("{selector}-markdown"); let id = SharedString::from(format!("{selector}-plain")); let mut root = body_container(theme, tone); @@ -346,13 +442,15 @@ fn plain_source( root = root.max_w(measure); } root = root.debug_selector(move || selector); - root.child(SelectableText::retained( - id, - source.to_owned(), - *theme, - Vec::new(), - )) - .into_any_element() + let element = root + .child(SelectableText::retained( + id, + source.to_owned(), + *theme, + apply_reveal_fade(&[], &spans), + )) + .into_any_element(); + (element, source.len()) } /// Which body text tone a rendered message carries. @@ -527,23 +625,32 @@ fn render_block( )); } PreparedKind::Code(code) => { - element = element.child(render_code(code, render)); + // Fences, tables, and raw HTML reveal whole, so they fade whole. + let alpha = render.claim_block(); + element = element.child(faded(render_code(code, render), alpha)); } PreparedKind::Html { id, source } => { // Carried verbatim as inert data, exactly as before, but // selectable like every other transcript leaf. - element = element.child(SelectableText::retained( - render.id(*id), - source.clone(), - *theme, - Vec::new(), + let alpha = render.claim_block(); + element = element.child(faded( + SelectableText::retained(render.id(*id), source.clone(), *theme, Vec::new()) + .into_any_element(), + alpha, )); } PreparedKind::List(list) => { element = element.child(render_list(block_items(block), list, render, depth)); } PreparedKind::Table(table) => { - element = element.child(table::render_table(block_table(block), table, render)); + // The table fades as one block; its cells render without their + // own fade and claim no units of their own. + let alpha = render.claim_block(); + let cells = render.without_fade(); + element = element.child(faded( + table::render_table(block_table(block), table, &cells), + alpha, + )); } } element = element.debug_selector(move || selector.to_string()); @@ -601,6 +708,8 @@ fn render_list( .get(position) .map_or(&[][..], |item| item.blocks.as_slice()); let item_selector = render.id(item.selector); + // The marker reveals with its item's first word, so it fades with it. + let marker_alpha = render.alpha_next(); let mut content = div().flex().flex_1().min_w_0().flex_col(); if let Some(empty) = &item.empty { content = content.child(render_leaf(&[], empty, item_selector.clone(), render)); @@ -632,6 +741,9 @@ fn render_list( if prepared.ordered { marker_element = marker_element.font_weight(FontWeight::NORMAL); } + if marker_alpha < 1.0 { + marker_element = marker_element.opacity(marker_alpha); + } row = row.child(marker_element.child(item.marker.clone())); row = row.child(content); row = row.debug_selector(move || item_selector.to_string()); @@ -665,8 +777,9 @@ fn render_leaf( ) -> AnyElement { let theme = render.theme; let (presentation, icons) = leaf.presentation(spans, render.titles, render.counters); + let fade = render.claim_leaf(presentation.text.len()); if presentation.citations.is_empty() { - return selectable_leaf(selector, &presentation, icons, theme); + return selectable_leaf(selector, &presentation, icons, theme, &fade); } // One nowrap row: the prose hugs its content and the pills trail the // sentence on its line, bottom-aligned with the last line, instead of @@ -696,6 +809,7 @@ fn render_leaf( &presentation, icons, theme, + &fade, )), ); } @@ -713,14 +827,21 @@ fn render_leaf( /// The selection element owns link clicks and suppresses drag activation, /// so no separate click handler lives beside it: one element, one behavior. /// Text, resolved runs, overrides, and link data are shared with the cached -/// presentation, so nothing here copies the leaf's content. +/// presentation, so nothing here copies the leaf's content. Only a leaf +/// with translucent reveal spans copies its highlights to overlay them. fn selectable_leaf( id: SharedString, presentation: &LeafPresentation, icons: Vec<(usize, Arc)>, theme: &ArtisanTheme, + fade: &[(Range, f32)], ) -> AnyElement { let (highlights, overrides) = presentation.resolved(theme); + let highlights = if fade.is_empty() { + highlights + } else { + Rc::from(apply_reveal_fade(&highlights, fade)) + }; let element = SelectableText::retained(id, presentation.text.clone(), *theme, Vec::new()) .with_shared_highlights(highlights) .with_shared_text_run_overrides(overrides) diff --git a/modules/ui/src/markdown_reveal.rs b/modules/ui/src/markdown_reveal.rs new file mode 100644 index 000000000..b7a3dff70 --- /dev/null +++ b/modules/ui/src/markdown_reveal.rs @@ -0,0 +1,486 @@ +//! Shift-free streaming reveal policy for Markdown replies. +//! +//! A streamed reply grows by appended text, and rendering every append as-is +//! moves text that is already on screen: a partial word outgrows its line and +//! wraps, an unclosed `**` turns a run bold (wider glyphs, new breaks), and a +//! table or fence reshapes once its closing line arrives. This module decides +//! which prefix of the received source can be shown without any of that ever +//! happening, and steps a revealed prefix through it one visual unit at a +//! time: a whole word, a whole inline construct, or a whole block. +//! +//! GPUI wraps greedily in one forward pass, so a line break never moves once +//! text after it exists. Showing only complete words, and only constructs +//! whose styling can no longer change, keeps every painted glyph where it +//! first appeared. The only movement left is the reply growing at its bottom. +//! +//! [`RevealFade`] carries the fade of freshly revealed units into the +//! renderer. Its offsets address the renderer's visible-text coordinates +//! (leaf text bytes in document order, one unit per atomic block), never +//! source bytes, so they stay valid across prefixes of one body. + +use std::ops::Range; + +use gpui::HighlightStyle; +use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd}; + +use crate::markdown::PARSE_OPTIONS; + +/// Returns the longest prefix of a still-streaming `source` whose rendering +/// can no longer change. +/// +/// Held back, each until it can no longer change shape: +/// +/// - the trailing word, which may still grow past its line; +/// - an unterminated fence or table, which only appears once complete; +/// - in the trailing open paragraph, everything from the first character that +/// could still open an inline construct (`*`, `_`, `` ` ``, `[`, `<`), and +/// any trailing run of lines containing `|`, which may become a table; +/// - a trailing line holding only block markers (`-`, `1.`, `#`, `>`), which +/// would otherwise paint an empty item or heading. +/// +/// A settled body needs none of this: callers reveal it to its full length. +#[must_use] +pub fn stable_reveal_end(source: &str) -> usize { + // Holds read the text that would actually be shown: dropping a trailing + // `dog**` reopens the `**` that closed it. + let mut end = trailing_word_start(source); + for hold in structural_holds(&source[..end]) { + end = end.min(hold); + } + marker_line_cut(source, end) +} + +/// Steps a revealed prefix through one stable source, one visual unit at a +/// time. +/// +/// A unit is a whitespace-delimited word plus its trailing whitespace. A +/// word that reaches into an inline construct (code span, link, emphasis) or +/// a block (fence, table, raw HTML) extends to that construct's end, so those +/// appear whole. A unit never ends on a marker-only line, so a list item or +/// heading appears together with its first word. +#[derive(Clone, Debug)] +pub struct RevealSteps<'a> { + source: &'a str, + atomic: Vec>, +} + +impl<'a> RevealSteps<'a> { + /// Indexes the atomic constructs of `source`, which must already be a + /// stable prefix (see [`stable_reveal_end`]) or a settled body. + #[must_use] + pub fn new(source: &'a str) -> Self { + Self { + source, + atomic: atomic_ranges(source), + } + } + + /// Returns the end of the unit after `from`; `from` itself at the end. + #[must_use] + pub fn next(&self, from: usize) -> usize { + let len = self.source.len(); + if from >= len { + return len; + } + let mut end = from; + loop { + end = self.skip_whitespace(end); + if end >= len { + return len; + } + end = match self.atomic_containing(end) { + Some(range) => range.end, + None => self.skip_word(end), + }; + // A word that runs into a construct (`foo**bar**`, `see [x](y).`) + // carries the whole construct, plus anything glued after it. + while let Some(range) = self.atomic_straddling(end) { + end = self.skip_word(range.end); + } + end = self.skip_whitespace(end); + if end >= len || !is_marker_only_line(self.source, end) { + return end.min(len); + } + } + } + + /// Counts the words still to reveal after `from`, for pacing. + #[must_use] + pub fn pending_words(&self, from: usize) -> usize { + self.source + .get(from..) + .map_or(0, |rest| rest.split_whitespace().count()) + } + + fn skip_whitespace(&self, mut at: usize) -> usize { + while let Some(character) = self.source.get(at..).and_then(|rest| rest.chars().next()) { + if !character.is_whitespace() { + break; + } + at += character.len_utf8(); + } + at + } + + fn skip_word(&self, mut at: usize) -> usize { + while let Some(character) = self.source.get(at..).and_then(|rest| rest.chars().next()) { + if character.is_whitespace() { + break; + } + if let Some(range) = self.atomic_containing(at) + && range.start < at + { + at = range.end; + continue; + } + at += character.len_utf8(); + } + at + } + + /// The outermost construct with `start <= at < end`. + fn atomic_containing(&self, at: usize) -> Option<&Range> { + self.atomic + .iter() + .filter(|range| range.start <= at && at < range.end) + .max_by_key(|range| range.end - range.start) + } + + /// The outermost construct with `start < at < end`. + fn atomic_straddling(&self, at: usize) -> Option<&Range> { + self.atomic + .iter() + .filter(|range| range.start < at && at < range.end) + .max_by_key(|range| range.end - range.start) + } +} + +/// Per-offset opacity of a revealing body, in the renderer's visible-text +/// coordinates. +/// +/// Each mark starts a segment that runs to the next mark; offsets before the +/// first mark, and segments at full opacity, paint normally. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RevealFade { + marks: Vec<(usize, f32)>, +} + +impl RevealFade { + /// Builds a fade from `(offset, alpha)` marks sorted by offset. + #[must_use] + pub fn new(marks: Vec<(usize, f32)>) -> Self { + Self { marks } + } + + /// Whether every offset paints at full opacity. + #[must_use] + pub fn is_opaque(&self) -> bool { + self.marks.iter().all(|(_, alpha)| *alpha >= 1.0) + } + + /// The opacity at one visible offset. + #[must_use] + pub fn alpha_at(&self, offset: usize) -> f32 { + self.marks + .iter() + .rev() + .find(|(start, _)| *start <= offset) + .map_or(1.0, |(_, alpha)| alpha.clamp(0.0, 1.0)) + } + + /// The translucent segments of one leaf spanning `base..base + len`, as + /// leaf-local ranges. + #[must_use] + pub fn leaf_spans(&self, base: usize, len: usize) -> Vec<(Range, f32)> { + let leaf_end = base + len; + let mut spans = Vec::new(); + for (index, (start, alpha)) in self.marks.iter().enumerate() { + let end = self + .marks + .get(index + 1) + .map_or(usize::MAX, |(next, _)| *next); + let start = (*start).max(base); + let end = end.min(leaf_end); + if start < end && *alpha < 1.0 { + spans.push((start - base..end - base, alpha.clamp(0.0, 1.0))); + } + } + spans + } +} + +/// Overlays fade spans onto one leaf's sorted, disjoint highlights. +/// +/// Returns sorted, disjoint ranges: every base style survives, gaining a +/// `fade_out` where a span covers it, and uncovered faded text gets a +/// fade-only style. Fading recolors glyphs only, so layout never changes. +#[must_use] +pub fn apply_reveal_fade( + base: &[(Range, HighlightStyle)], + spans: &[(Range, f32)], +) -> Vec<(Range, HighlightStyle)> { + if spans.is_empty() { + return base.to_vec(); + } + let mut bounds: Vec = base + .iter() + .flat_map(|(range, _)| [range.start, range.end]) + .chain(spans.iter().flat_map(|(range, _)| [range.start, range.end])) + .collect(); + bounds.sort_unstable(); + bounds.dedup(); + let mut merged: Vec<(Range, HighlightStyle)> = Vec::with_capacity(bounds.len()); + for window in bounds.windows(2) { + let &[start, end] = window else { + continue; + }; + let style = base + .iter() + .find(|(range, _)| range.start <= start && end <= range.end) + .map(|(_, style)| *style); + let alpha = spans + .iter() + .find(|(range, _)| range.start <= start && end <= range.end) + .map(|(_, alpha)| *alpha); + let style = match (style, alpha) { + (None, None) => continue, + (Some(style), None) => style, + (style, Some(alpha)) => HighlightStyle { + fade_out: Some(1.0 - alpha), + ..style.unwrap_or_default() + }, + }; + match merged.last_mut() { + Some((range, last)) if range.end == start && *last == style => range.end = end, + _ => merged.push((start..end, style)), + } + } + merged +} + +/// Start of the trailing unterminated word; the length when the source ends +/// in whitespace. +fn trailing_word_start(source: &str) -> usize { + source + .char_indices() + .rev() + .find(|(_, character)| character.is_whitespace()) + .map_or(0, |(index, character)| index + character.len_utf8()) +} + +/// Source offsets before which an unfinished construct begins. +fn structural_holds(source: &str) -> Vec { + let mut holds = Vec::new(); + // Inline constructs never cross a blank line, so only text after the + // last one can still be restyled by a closer that has not arrived. + let open_region = last_blank_line_end(source); + let mut verbatim_depth = 0_usize; + let mut opener_found = false; + for (event, range) in Parser::new_ext(source, PARSE_OPTIONS).into_offset_iter() { + match event { + Event::Start(Tag::CodeBlock(kind)) => { + if matches!(kind, CodeBlockKind::Fenced(_)) + && !fence_is_closed(source, range.clone()) + { + holds.push(line_start(source, range.start)); + } + verbatim_depth += 1; + } + Event::Start(Tag::HtmlBlock) => verbatim_depth += 1, + Event::End(TagEnd::CodeBlock | TagEnd::HtmlBlock) => { + verbatim_depth = verbatim_depth.saturating_sub(1); + } + Event::Start(Tag::Table(_)) if !block_is_terminated(source, range.end) => { + holds.push(line_start(source, range.start)); + } + Event::Text(_) + if !opener_found && verbatim_depth == 0 && range.start >= open_region => + { + if let Some(opener) = first_inline_opener(source, range) { + holds.push(opener); + opener_found = true; + } + } + _ => {} + } + } + holds.extend(trailing_pipe_run(source, open_region)); + holds +} + +/// The first literal character in one text event that a later closer could +/// still turn into a code span, emphasis, link, or autolink. +fn first_inline_opener(source: &str, range: Range) -> Option { + let text = source.get(range.clone())?; + let mut previous: Option = source.get(..range.start)?.chars().next_back(); + for (offset, character) in text.char_indices() { + let after = source + .get(range.start + offset + character.len_utf8()..) + .and_then(|rest| rest.chars().next()); + // An emphasis or tag opener needs a non-space after it ("2 * 3" and + // "a < b" never open), so a lone one does not hold the paragraph. + let can_open = after.is_none_or(|next| !next.is_whitespace()); + let opener = match character { + '`' | '[' => true, + '*' | '<' => can_open, + // Intraword underscores (`snake_case`) can never open either. + '_' => can_open && previous.is_none_or(|before| !before.is_alphanumeric()), + _ => false, + }; + if opener && previous != Some('\\') { + return Some(range.start + offset); + } + previous = Some(character); + } + None +} + +/// Start of the trailing run of lines after `region` that contain a pipe: a +/// delimiter row could still turn them into a table. +fn trailing_pipe_run(source: &str, region: usize) -> Option { + let open = source.get(region..)?; + let mut run_start = None; + let mut line_end = open.trim_end_matches(['\n', '\r']).len(); + loop { + let start = open[..line_end].rfind('\n').map_or(0, |index| index + 1); + if !open[start..line_end].contains('|') { + break; + } + run_start = Some(region + start); + if start == 0 { + break; + } + line_end = start - 1; + } + run_start +} + +/// Offset just after the last blank line, or zero when there is none. +fn last_blank_line_end(source: &str) -> usize { + let mut offset = 0; + let mut previous_blank_end = 0; + let mut at_start = true; + for line in source.split_inclusive('\n') { + let end = offset + line.len(); + if !at_start && line.ends_with('\n') && line.trim().is_empty() { + previous_blank_end = end; + } + at_start = false; + offset = end; + } + previous_blank_end +} + +/// Whether a fenced block's closing fence line has fully arrived. +fn fence_is_closed(source: &str, range: Range) -> bool { + let block = source.get(range.clone()).unwrap_or_default(); + let Some((opening, rest)) = block.split_once('\n') else { + return false; + }; + let opening = opening.trim_start(); + let Some(fence_char) = opening.chars().next() else { + return false; + }; + let fence_len = opening.chars().take_while(|c| *c == fence_char).count(); + let trimmed = rest.trim_end_matches(['\n', '\r']); + let closing = trimmed.rsplit('\n').next().unwrap_or_default().trim(); + let is_fence = closing.chars().count() >= fence_len && closing.chars().all(|c| c == fence_char); + // `rest` empty means only the opening line arrived. + if trimmed.is_empty() || !is_fence { + return false; + } + // The closing line must be terminated: a trailing ``` could still grow + // into an info string or an opening of the next fence. + let closing_end = range.start + block.len() - (rest.len() - trimmed.len()); + source + .get(closing_end..) + .is_some_and(|tail| tail.starts_with('\n')) +} + +/// Whether a block ending at `end` has been closed: by a blank line, or by +/// content on a later line that the parser did not fold into it. +/// +/// The parser may or may not count the block's last newline inside its +/// range, so a newline just before `end` counts too. +fn block_is_terminated(source: &str, end: usize) -> bool { + let mut newlines = usize::from(source.get(..end).is_some_and(|head| head.ends_with('\n'))); + for character in source.get(end..).unwrap_or_default().chars() { + match character { + '\n' => newlines += 1, + ' ' | '\t' | '\r' => {} + _ => return newlines > 0, + } + } + newlines >= 2 +} + +/// Start of the line containing `offset`. +fn line_start(source: &str, offset: usize) -> usize { + source + .get(..offset) + .and_then(|head| head.rfind('\n')) + .map_or(0, |index| index + 1) +} + +/// Cuts `end` back to its line start when the line so far holds only block +/// markers. +fn marker_line_cut(source: &str, end: usize) -> usize { + if is_marker_only_line(source, end) { + line_start(source, end) + } else { + end + } +} + +/// Whether the line containing `end`, up to `end`, is non-empty and holds +/// only list, heading, quote, task, or rule markers. +fn is_marker_only_line(source: &str, end: usize) -> bool { + let start = line_start(source, end); + let Some(line) = source.get(start..end) else { + return false; + }; + let mut tokens = line.split_whitespace().peekable(); + if tokens.peek().is_none() { + return false; + } + tokens.all(is_marker_token) +} + +fn is_marker_token(token: &str) -> bool { + let all = |marker: char| token.chars().all(|c| c == marker); + if all('#') || all('-') || all('*') || all('+') || all('>') || all('=') || all('_') { + return true; + } + if matches!(token, "[" | "]" | "[ ]" | "[x]" | "[X]" | "x]" | "X]") { + return true; + } + let digits = token.trim_end_matches(['.', ')']); + digits.len() < token.len() && !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) +} + +/// Source ranges that must appear whole: blocks whose shape is only final +/// once complete, and inline constructs whose styling spans several words. +fn atomic_ranges(source: &str) -> Vec> { + let mut ranges = Vec::new(); + for (event, range) in Parser::new_ext(source, PARSE_OPTIONS).into_offset_iter() { + match event { + Event::Start( + Tag::CodeBlock(_) + | Tag::Table(_) + | Tag::HtmlBlock + | Tag::Link { .. } + | Tag::Image { .. } + | Tag::Strong + | Tag::Emphasis, + ) + | Event::Code(_) + | Event::InlineHtml(_) => ranges.push(range), + _ => {} + } + } + ranges +} + +#[cfg(test)] +#[path = "markdown_reveal_tests.rs"] +mod tests; diff --git a/modules/ui/src/markdown_reveal_tests.rs b/modules/ui/src/markdown_reveal_tests.rs new file mode 100644 index 000000000..4c8fdd456 --- /dev/null +++ b/modules/ui/src/markdown_reveal_tests.rs @@ -0,0 +1,243 @@ +use gpui::{FontWeight, HighlightStyle}; + +use super::*; +use crate::markdown::{Block, MarkdownEngine, Span}; + +#[test] +fn trailing_partial_word_is_held() { + assert_eq!(stable_reveal_end("Hello wor"), "Hello ".len()); + assert_eq!(stable_reveal_end("Hello world "), "Hello world ".len()); + assert_eq!(stable_reveal_end("Hello"), 0); +} + +#[test] +fn unclosed_inline_constructs_are_held_until_closed() { + assert_eq!(stable_reveal_end("Some **bold te"), "Some ".len()); + assert_eq!(stable_reveal_end("Use `cargo b"), "Use ".len()); + assert_eq!(stable_reveal_end("See [docs](https://exa"), "See ".len()); + assert_eq!( + stable_reveal_end("Some **bold** text "), + "Some **bold** text ".len() + ); +} + +#[test] +fn intraword_underscores_and_escapes_never_hold() { + assert_eq!( + stable_reveal_end("call foo_bar now "), + "call foo_bar now ".len() + ); + assert_eq!(stable_reveal_end("2 \\* 3 is "), "2 \\* 3 is ".len()); +} + +#[test] +fn openers_followed_by_a_space_never_hold() { + let source = "Costs 2 * 3 units and a < b holds "; + assert_eq!(stable_reveal_end(source), source.len()); +} + +#[test] +fn a_closed_paragraph_releases_its_literal_openers() { + let source = "Costs 2 * 3 units.\n\nNext "; + assert_eq!(stable_reveal_end(source), source.len()); +} + +#[test] +fn open_fences_are_held_from_their_line() { + let source = "Intro\n\n```rust\nfn main() {"; + assert_eq!(stable_reveal_end(source), "Intro\n\n".len()); + let unterminated_close = "Intro\n\n```\nx\n```"; + assert_eq!(stable_reveal_end(unterminated_close), "Intro\n\n".len()); + let closed = "Intro\n\n```\nx\n```\n"; + assert_eq!(stable_reveal_end(closed), closed.len()); +} + +#[test] +fn tables_are_held_until_terminated() { + let header_only = "Intro\n\n| a | b |\n"; + assert_eq!(stable_reveal_end(header_only), "Intro\n\n".len()); + let open = "Intro\n\n| a | b |\n|---|---|\n| 1 | 2 |\n"; + assert_eq!(stable_reveal_end(open), "Intro\n\n".len()); + let terminated = "Intro\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n"; + assert_eq!(stable_reveal_end(terminated), terminated.len()); +} + +#[test] +fn marker_only_lines_are_held() { + assert_eq!(stable_reveal_end("Para\n\n- "), "Para\n\n".len()); + assert_eq!(stable_reveal_end("Para\n\n## "), "Para\n\n".len()); + assert_eq!(stable_reveal_end("Para\n\n12. "), "Para\n\n".len()); + assert_eq!(stable_reveal_end("Para\n\n- one "), "Para\n\n- one ".len()); +} + +#[test] +fn steps_reveal_whole_words_and_whole_constructs() { + let source = "Hello **big bold** world "; + let steps = RevealSteps::new(source); + assert_eq!(steps.next(0), "Hello ".len()); + assert_eq!(steps.next("Hello ".len()), "Hello **big bold** ".len()); + assert_eq!(steps.next("Hello **big bold** ".len()), source.len()); + assert_eq!(steps.next(source.len()), source.len()); +} + +#[test] +fn steps_reveal_fences_and_tables_as_one_unit() { + let source = "Intro\n\n```\na b c\n```\n\n| a |\n|---|\n| 1 |\n\nAfter "; + let steps = RevealSteps::new(source); + let fence = "Intro\n\n".len(); + assert_eq!(steps.next(0), fence); + let table = source.find("| a |").expect("table"); + assert_eq!(steps.next(fence), table); + assert_eq!(steps.next(table), source.find("After").expect("after")); +} + +#[test] +fn steps_carry_a_list_marker_with_its_first_word() { + let source = "Para\n\n- item one "; + let steps = RevealSteps::new(source); + let list = "Para\n\n".len(); + assert_eq!(steps.next(0), list); + assert_eq!(steps.next(list), "Para\n\n- item ".len()); +} + +#[test] +fn fade_spans_cover_only_translucent_segments() { + let fade = RevealFade::new(vec![(5, 1.0), (10, 0.5), (14, 0.0)]); + assert!(!fade.is_opaque()); + assert!((fade.alpha_at(3) - 1.0).abs() < f32::EPSILON); + assert!((fade.alpha_at(12) - 0.5).abs() < f32::EPSILON); + assert_eq!(fade.leaf_spans(8, 10), vec![(2..6, 0.5), (6..10, 0.0)]); + assert!(fade.leaf_spans(0, 5).is_empty()); +} + +#[test] +fn fade_overlays_keep_base_styles_disjoint() { + let bold = HighlightStyle { + font_weight: Some(FontWeight::BOLD), + ..HighlightStyle::default() + }; + let merged = apply_reveal_fade(&[(0..6, bold)], &[(4..10, 0.25)]); + assert_eq!(merged.len(), 3); + assert_eq!(merged[0], (0..4, bold)); + assert_eq!(merged[1].0, 4..6); + assert_eq!(merged[1].1.font_weight, Some(FontWeight::BOLD)); + assert_eq!(merged[1].1.fade_out, Some(0.75)); + assert_eq!(merged[2].0, 6..10); + assert_eq!(merged[2].1.font_weight, None); +} + +/// One painted character with everything that decides its glyph metrics +/// and line: the block path it sits in and its inline styling. +#[derive(Clone, Debug, PartialEq)] +struct StyledChar { + path: String, + style: String, + character: char, +} + +fn flatten_spans(spans: &[Span], path: &str, style: &str, out: &mut Vec) { + for span in spans { + match span { + Span::Text(text) | Span::Html(text) => push_chars(text, path, style, out), + Span::Code(text) => push_chars(text, path, &format!("{style}c"), out), + Span::Emphasis(inner) => flatten_spans(inner, path, &format!("{style}e"), out), + Span::Strong(inner) => flatten_spans(inner, path, &format!("{style}s"), out), + Span::Link { label, .. } => flatten_spans(label, path, &format!("{style}l"), out), + } + } +} + +fn push_chars(text: &str, path: &str, style: &str, out: &mut Vec) { + out.extend(text.chars().map(|character| StyledChar { + path: path.to_owned(), + style: style.to_owned(), + character, + })); +} + +fn flatten_blocks(blocks: &[Block], path: &str, out: &mut Vec) { + for (index, block) in blocks.iter().enumerate() { + match block { + Block::Heading { level, spans, .. } => { + flatten_spans(spans, &format!("{path}/{index}h{level}"), "", out); + } + Block::Paragraph { spans, .. } => { + flatten_spans(spans, &format!("{path}/{index}p"), "", out); + } + Block::List { ordered, items, .. } => { + for (item_index, item) in items.iter().enumerate() { + let item_path = format!("{path}/{index}l{ordered}/{item_index}"); + flatten_blocks(&item.blocks, &item_path, out); + } + } + // Atomic blocks paint whole: one token holding all their content. + other => push_chars( + &format!("\u{1}{:?}\u{2}", other.text_content()), + &format!("{path}/{index}atomic"), + "", + out, + ), + } + } +} + +fn painted(engine: &MarkdownEngine, source: &str) -> Vec { + let document = engine.parse_document(source).expect("parse"); + let mut out = Vec::new(); + flatten_blocks(document.blocks(), "", &mut out); + out +} + +/// The guarantee: for every streamed prefix, what the stable cutoff paints +/// is a prefix of what the finished reply paints, so nothing on screen ever +/// changes line, style, or block. +fn assert_never_shifts(final_source: &str) { + let engine = MarkdownEngine::new().expect("engine"); + let finished = painted(&engine, final_source); + for (cut, _) in final_source.char_indices().skip(1) { + let streamed = &final_source[..cut]; + let stable = &streamed[..stable_reveal_end(streamed)]; + let shown = painted(&engine, stable); + assert!( + finished.starts_with(&shown), + "prefix {streamed:?} reveals {stable:?}, which paints differently from the finished reply" + ); + let steps = RevealSteps::new(stable); + let mut revealed = 0; + while revealed < stable.len() { + let next = steps.next(revealed); + assert!(next > revealed, "stepping {stable:?} stalled at {revealed}"); + revealed = next; + let step = painted(&engine, &stable[..revealed]); + assert!( + finished.starts_with(&step), + "stepping {stable:?} shows {:?}, which paints differently from the finished reply", + &stable[..revealed] + ); + } + } +} + +#[test] +fn streamed_prose_never_shifts() { + assert_never_shifts( + "The quick brown fox jumps over **the lazy dog** and keeps `running` far away.\n\n\ + A second paragraph links [the docs](https://example.com/docs) and ends here.\n", + ); +} + +#[test] +fn streamed_lists_and_headings_never_shift() { + assert_never_shifts( + "## Summary\n\nWhat changed:\n\n- First item with *emphasis*\n- Second item\n continues here\n\n\ + 1. Ordered one\n2. Ordered two\n\nDone.\n", + ); +} + +#[test] +fn streamed_fences_and_tables_never_shift() { + assert_never_shifts( + "Run this:\n\n```sh\ncargo test | tee log\n```\n\nResults:\n\n| Suite | Passed |\n|---|---|\n\ + | ui | 12 |\n| core | 40 |\n\nAll green.\n", + ); +} diff --git a/nix/stages.nix b/nix/stages.nix index 46202aba0..d09e640b6 100644 --- a/nix/stages.nix +++ b/nix/stages.nix @@ -27,7 +27,11 @@ let stages = { debug = { profile = "production-debug"; - features = [ "artisan-frontend/debug-tools" ]; + features = [ + "artisan-frontend/debug-tools" + "artisan-frontend/flight-recorder" + "artisan-backend/flight-recorder" + ]; # mold / lld instead of GNU ld (`fastLinker` per platform). fastLink = true; payload = { @@ -229,7 +233,15 @@ let cargoExtraArgs = "--locked --offline ${selection} ${featureArgs settings.features}"; doCheck = false; }; - dependencies = host.deps (common // { pname = "artisan-${platform}-${stage}-dependencies"; }); + # The dependency cache only feeds `cargo build`: crane's default + # `cargo check` pass beforehand costs minutes and nothing reads it. + dependencies = host.deps ( + common + // { + pname = "artisan-${platform}-${stage}-dependencies"; + cargoCheckCommand = "true"; + } + ); profileDirectory = "target/${ lib.optionalString (platform == "windows") "${host.target}/" }${settings.profile}"; @@ -239,6 +251,9 @@ let // { pname = "artisan-${platform}-${stage}"; cargoArtifacts = dependencies; + # `installPhaseCommand` moves the binaries itself; crane's own pass + # would first copy a GB of Debug binaries nothing uses. + doNotPostBuildInstallCargoBinaries = true; # One Cargo invocation builds all four binaries, so shared crates # compile once; the payload holds exactly those four and the identity. # Moving rather than copying them saves copying a GB of Debug @@ -285,7 +300,14 @@ let common // { pname = "artisan-${platform}-runner"; - cargoArtifacts = host.deps (common // { pname = "artisan-${platform}-runner-dependencies"; }); + cargoArtifacts = host.deps ( + common + // { + pname = "artisan-${platform}-runner-dependencies"; + cargoCheckCommand = "true"; + } + ); + doNotPostBuildInstallCargoBinaries = true; installPhaseCommand = '' mkdir -p "$out/bin" install -m755 "${profileDirectory}/${host.exe "dev"}" "$out/bin/${host.exe "dev"}" diff --git a/scripts/trace.py b/scripts/trace.py new file mode 100644 index 000000000..ac7753e82 --- /dev/null +++ b/scripts/trace.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Inspect Artisan flight recordings or merge Editor and Forge for Perfetto.""" +import argparse +import json +from pathlib import Path + + +def read(path): + data = json.loads(Path(path).read_text()) + if not isinstance(data.get('traceEvents'), list): + raise ValueError(f'{path}: missing traceEvents array') + return data + + +def durations(events): + active = {} + result = [] + for event in sorted(events, key=lambda e: e.get('ts', 0)): + phase = event.get('ph') + key = (event.get('pid'), event.get('cat'), event.get('name'), event.get('id')) + if phase == 'b': + active[key] = event + elif phase == 'e' and key in active: + begin = active.pop(key) + result.append((event['ts'] - begin['ts'], begin, + event.get('args', {}).get('unfinished', False))) + elif phase == 'X': + result.append((event.get('dur', 0), event, False)) + return sorted(result, key=lambda row: row[0], reverse=True) + + +def summary(path): + data = read(path) + metadata = data.get('metadata', {}) + print(f"{metadata.get('role', 'unknown')} capture: {metadata.get('reason', 'unknown')}") + print(f"Dropped: {metadata.get('dropped_records', 0)}; evicted: {metadata.get('evicted_records', 0)}") + print('Longest operations:') + for duration, event, unfinished in durations(data['traceEvents'])[:25]: + args = event.get('args', {}) + context = ' '.join(f'{key}={args[key]}' for key in + ('command', 'event', 'expected', 'thread_id', 'request_id', 'generation') + if args.get(key) is not None) + status = ' [unfinished at capture]' if unfinished else '' + print(f" {duration / 1000:10.2f} ms {event.get('cat')}/{event.get('name')}{status} {context}") + gates = [e for e in data['traceEvents'] if e.get('name') == 'navigation.gates'] + if gates: + print('Last navigation gates:') + print(json.dumps(max(gates, key=lambda e: e['ts']).get('args'), indent=2)) + + +def merge(output, paths): + events, processes, seen, boundaries, bindings = [], {}, set(), {}, {} + for path in paths: + data = read(path) + metadata = data.get('metadata', {}) + role = metadata.get('role', str(path)) + for original in data['traceEvents']: + event = dict(original) + # Windows Editor and Linux Forge can have the same OS PID. + identity = (role, event.get('pid', 0), metadata.get('session_start_us')) + event['pid'] = processes.setdefault(identity, len(processes) + 1) + if 'bind_id' in event: + binding = (event['pid'], event['bind_id']) + event['bind_id'] = format(bindings.setdefault(binding, len(bindings) + 1), 'x') + if event.get('ph') in ('b', 'e'): + key = (event['pid'], event.get('cat'), event.get('name'), + event.get('id'), event['ph']) + previous = boundaries.get(key) + if previous is None: + boundaries[key] = event + elif event['ph'] == 'b': + boundaries[key] = min(previous, event, key=lambda e: e['ts']) + else: + # A snapshot closes live spans synthetically. A later + # real completion supersedes every snapshot boundary. + boundaries[key] = max(previous, event, key=lambda e: ( + not e.get('args', {}).get('unfinished', False), e['ts'])) + continue + encoded = json.dumps(event, sort_keys=True) + if encoded not in seen: + seen.add(encoded) + events.append(event) + events.extend(boundaries.values()) + events.sort(key=lambda e: e.get('ts', 0)) + Path(output).write_text(json.dumps({'traceEvents': events, 'displayTimeUnit': 'ms', + 'metadata': {'sources': [str(p) for p in paths]}})) + print(f'Saved {len(events)} events to {output}') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + inspect = commands.add_parser('summary') + inspect.add_argument('trace', type=Path) + combine = commands.add_parser('merge') + combine.add_argument('output', type=Path) + combine.add_argument('traces', nargs='+', type=Path) + args = parser.parse_args() + if args.command == 'summary': + summary(args.trace) + else: + merge(args.output, args.traces) + + +if __name__ == '__main__': + main() diff --git a/tests/backend/engine_owner_claude.rs b/tests/backend/engine_owner_claude.rs index 0659522a1..5579b877a 100644 --- a/tests/backend/engine_owner_claude.rs +++ b/tests/backend/engine_owner_claude.rs @@ -2207,6 +2207,20 @@ fn assistant_and_result_usage_decode_with_honest_shapes() { _ => panic!("expected turn result with usage"), } + // The result names the window the turn ran in: the root model's, the + // largest across the models it used. + let windowed = parse_frame( + r#"{"type":"result","subtype":"success","session_id":"session-1","is_error":false,"usage":{"output_tokens":50},"modelUsage":{"claude-opus-5-5":{"inputTokens":9,"contextWindow":1000000},"claude-haiku-4-5":{"inputTokens":2,"contextWindow":200000}}}"#, + 3, + ) + .expect("result with model usage decodes"); + match windowed { + ClaudeEvent::TurnResult { usage, .. } => { + assert_eq!(usage.expect("totals").window, Some(1_000_000)); + } + _ => panic!("expected turn result with usage"), + } + // The typeless terminal summary carries totals the same way. let typeless = parse_frame( r#"{"is_error":false,"session_id":"session-1","usage":{"output_tokens":7}}"#, diff --git a/tests/backend/project_icon_service.rs b/tests/backend/project_icon_service.rs new file mode 100644 index 000000000..6403399be --- /dev/null +++ b/tests/backend/project_icon_service.rs @@ -0,0 +1,100 @@ +use super::*; +use serde_json::json; + +#[test] +fn repository_artwork_precedes_owner_and_empty_artwork_is_skipped() { + let metadata = json!({"avatar_url":"https://gitlab.com/project.png", "namespace":{"avatar_url":"/owner.png"}}); + assert_eq!( + candidates(&metadata, "gitlab.com"), + vec!["https://gitlab.com/project.png", "/owner.png"] + ); + let metadata = json!({"avatar_url":null, "namespace":{"avatar_url":"/owner.png"}}); + assert_eq!(candidates(&metadata, "gitlab.com"), vec!["/owner.png"]); + assert_eq!(candidates(&json!({}), "gitlab.com"), Vec::<&str>::new()); +} + +#[test] +fn disk_and_unrecognized_host_fallbacks_are_distinct() { + assert_eq!(fallback(None), RecentProjectIcon::default()); + assert_eq!( + fallback(Some("https://github.com/owner/repo")) + .host + .unwrap() + .as_str(), + "github" + ); + assert_eq!( + fallback(Some("https://example.com/owner/repo")) + .host + .unwrap() + .as_str(), + "other" + ); +} + +#[test] +fn icon_decoding_bounds_input_and_normalizes_to_small_png() { + let source = image::DynamicImage::new_rgba8(256, 128); + let mut bytes = Cursor::new(Vec::new()); + source + .write_to(&mut bytes, image::ImageFormat::Png) + .unwrap(); + let png = normalize(bytes.get_ref()).unwrap(); + assert!(png.len() <= RECENT_PROJECT_ICON_MAX_BYTES); + let decoded = image::load_from_memory(&png).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (48, 24)); + assert!(normalize(b"not an image").is_none()); + assert!(normalize(&vec![0; INPUT_LIMIT + 1]).is_none()); +} + +#[test] +fn gitlab_project_path_is_one_encoded_api_segment() { + assert_eq!( + metadata_url( + &Url::parse("https://gitlab.com/group/subgroup/repo").unwrap(), + "group/subgroup/repo" + ) + .unwrap() + .as_str(), + "https://gitlab.com/api/v4/projects/group%2Fsubgroup%2Frepo" + ); +} + +#[test] +fn explicit_project_icon_wins_and_invalid_files_fall_through() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "artisan-project-icon-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir_all(directory.join(".artisan")).unwrap(); + let write_png = |path: &Path, color| { + let image = image::RgbaImage::from_pixel(16, 16, image::Rgba(color)); + image.save(path).unwrap(); + }; + write_png(&directory.join(".artisan/icon.png"), [255, 0, 0, 255]); + write_png(&directory.join("favicon.png"), [0, 255, 0, 255]); + let png = local_icon(&directory).unwrap(); + assert_eq!( + image::load_from_memory(&png) + .unwrap() + .to_rgba8() + .get_pixel(0, 0) + .0, + [255, 0, 0, 255] + ); + std::fs::write(directory.join(".artisan/icon.png"), b"invalid").unwrap(); + let png = local_icon(&directory).unwrap(); + assert_eq!( + image::load_from_memory(&png) + .unwrap() + .to_rgba8() + .get_pixel(0, 0) + .0, + [0, 255, 0, 255] + ); + std::fs::remove_dir_all(directory).unwrap(); +} diff --git a/tests/backend/request_handler.rs b/tests/backend/request_handler.rs index ee5db5ec1..776e26293 100644 --- a/tests/backend/request_handler.rs +++ b/tests/backend/request_handler.rs @@ -5319,7 +5319,7 @@ async fn uploaded_attachments_back_drafts_and_messages_sent_by_reference() { } #[tokio::test] -async fn preferences_follow_navigation_and_the_configuration_the_user_saves() { +async fn preferences_follow_navigation_without_adopting_thread_runtime_configuration() { let (_temporary, storage) = opened_storage("preferences").await; storage .repository() @@ -5399,8 +5399,8 @@ async fn preferences_follow_navigation_and_the_configuration_the_user_saves() { .expect("navigation should record"), ); assert_eq!( - recorded.default_engine_config, - Some(engine_config("preferred")) + recorded.default_engine_config, None, + "a thread configuration save must not change the user's preference" ); let route = recorded.navigation.route().expect("route recorded"); assert_eq!(route.project_id.as_str(), "preferences-project"); @@ -5424,3 +5424,44 @@ async fn preferences_follow_navigation_and_the_configuration_the_user_saves() { assert_eq!(unknown.code, ErrorCode::ProjectUnknown); storage.close().await.expect("storage should close"); } + +#[tokio::test] +async fn agent_model_preference_commands_leave_the_user_choice_intact() { + use artisan_domain::{ + CatalogSelection, ModelFavoriteId, ModelPreferenceSource, SaveModelPreference, + }; + let (_temporary, storage) = opened_storage("model-preference-source").await; + let preferred = engine_config("user-choice"); + let saved = storage + .repository() + .save_model_preference(&preferred, ModelPreferenceSource::User) + .await + .unwrap(); + let handler = RequestHandler::new(storage.repository().clone()); + for source in [ModelPreferenceSource::Agent, ModelPreferenceSource::System] { + let request_id = request("agent-preference"); + let response = handler + .respond( + &request_id, + &ClientRequest::Command(Command::SaveModelPreference(SaveModelPreference { + request_id: request_id.clone(), + selection: CatalogSelection { + model_id: ModelFavoriteId::parse("unavailable-subagent-model").unwrap(), + profile_id: None, + reasoning_effort: None, + speed: None, + context_window: None, + permission: None, + }, + source, + })), + ) + .await + .expect("non-user choices need no catalog and cannot mutate user preferences"); + let ResponsePayload::UserPreferences(preferences) = response.payload else { + panic!("expected preferences"); + }; + assert_eq!(preferences.default_engine_config.as_ref(), Some(&preferred)); + assert_eq!(preferences.revision, saved.revision); + } +} diff --git a/tests/build_info/build_info.rs b/tests/build_info/build_info.rs index 6c5e2bcad..b771caa08 100644 --- a/tests/build_info/build_info.rs +++ b/tests/build_info/build_info.rs @@ -141,12 +141,16 @@ fn only_stable_builds_omit_the_title_marker() { let dev = BuildIdentity::Installed(info(Channel::Dev, Some("1a2b3c4d5e6f"), true)); assert_eq!(dev.title_marker().as_deref(), Some("Dev 1a2b3c4d5e+")); - assert_eq!(dev.badge().as_deref(), Some("Dev 1a2b3c4+")); + assert_eq!(dev.badge().as_deref(), Some("D-1A2B3C4")); let nightly = BuildIdentity::Installed(info(Channel::Nightly, None, false)); assert_eq!(nightly.title_marker().as_deref(), Some("Nightly")); - assert_eq!(nightly.badge().as_deref(), Some("Nightly")); - assert_eq!(stable.badge(), None); + assert_eq!(nightly.badge().as_deref(), Some("N")); + assert_eq!(stable.badge().as_deref(), Some("P-1A2B3C4")); + let nightly = BuildIdentity::Installed(info(Channel::Nightly, Some("aaee7c012345"), false)); + assert_eq!(nightly.badge().as_deref(), Some("N-AAEE7C0")); + let beta = BuildIdentity::Installed(info(Channel::Beta, Some("aaee7c012345"), false)); + assert_eq!(beta.badge().as_deref(), Some("P-AAEE7C0")); assert_eq!( BuildIdentity::Unstaged(artisan_build_info::UnstagedBuild::this_binary()).badge(), None diff --git a/tests/database/message_dispatch_lifecycle.rs b/tests/database/message_dispatch_lifecycle.rs index 1d76ba6fd..5f2fecf95 100644 --- a/tests/database/message_dispatch_lifecycle.rs +++ b/tests/database/message_dispatch_lifecycle.rs @@ -955,3 +955,38 @@ async fn lease_renewal_is_owner_fenced_and_rejects_terminal_rows() { Err(RepositoryError::InvalidDispatchState { state, .. }) if state == "completed" )); } + +#[tokio::test] +async fn lease_renewal_survives_a_wall_clock_that_stepped_back() { + // The WSL host-time sync steps the wall clock backwards; a heartbeat read + // after such a step is earlier than the stamp the last write left. That + // renewal must still extend the lease, and must never move the stamp back. + let (database, repository) = memory_database().await; + seed_foundation(&database).await; + seed_dispatch(&database, "message-1", "request-1", 0, 3, 3).await; + assert_eq!(claim_seeded(&repository, 0x11).await, 1); + let message_id = MessageId::parse("message-1").expect("test message id should parse"); + repository + .renew_message_dispatch_lease( + &message_id, + &lease_owner(0x11), + UnixMillis::from_millis(19), + UnixMillis::from_millis(60), + ) + .await + .expect("first renewal should succeed"); + + let stepped_back = repository + .renew_message_dispatch_lease( + &message_id, + &lease_owner(0x11), + UnixMillis::from_millis(15), + UnixMillis::from_millis(70), + ) + .await + .expect("a renewal after a backwards clock step should succeed"); + assert_eq!(stepped_back.updated_at, UnixMillis::from_millis(19)); + let persisted = dispatch(&database, "message-1").await; + assert_eq!(persisted.lease_expires_at_ms, Some(70)); + assert_eq!(persisted.updated_at_ms, 19, "the stamp never moves backwards"); +} diff --git a/tests/database/user_preferences.rs b/tests/database/user_preferences.rs index f3b3d1990..0810586e5 100644 --- a/tests/database/user_preferences.rs +++ b/tests/database/user_preferences.rs @@ -262,3 +262,44 @@ async fn legacy_preferences_the_forge_cannot_use_are_refused() { assert_eq!(absent.project_order, LegacyImportOutcome::Absent); assert_eq!(absent.preferences.revision, 0); } + +#[tokio::test] +async fn model_preference_survives_repository_recreation_and_ignores_agent_choices() { + use artisan_domain::ModelPreferenceSource; + let (database, repository) = repository().await; + let preferred = config("user-model"); + let saved = repository + .save_model_preference(&preferred, ModelPreferenceSource::User) + .await + .unwrap(); + assert_eq!(saved.default_engine_config.as_ref(), Some(&preferred)); + let reopened = Repository::new(database); + assert_eq!(reopened.read_user_preferences().await.unwrap(), saved); + for source in [ + ModelPreferenceSource::Agent, + ModelPreferenceSource::System, + ModelPreferenceSource::default(), + ] { + assert_eq!( + reopened + .save_model_preference(&config("subagent-model"), source) + .await + .unwrap(), + saved + ); + } + assert_eq!( + reopened + .save_model_preference(&preferred, ModelPreferenceSource::User) + .await + .unwrap(), + saved, + "retrying the same preference does not bump its revision" + ); + let next = reopened + .save_model_preference(&config("next-user-model"), ModelPreferenceSource::User) + .await + .unwrap(); + assert_eq!(next.revision, saved.revision + 1); + assert_eq!(next.default_engine_config, Some(config("next-user-model"))); +} diff --git a/tests/frontend/conversation_state_machine.rs b/tests/frontend/conversation_state_machine.rs index e100e2115..5913b4fcb 100644 --- a/tests/frontend/conversation_state_machine.rs +++ b/tests/frontend/conversation_state_machine.rs @@ -1616,6 +1616,34 @@ fn commentary_phase_preserved_without_collapsing_into_streaming() { assert_eq!(provenance.lifecycle, Some(ConversationLifecycle::Pending)); } +#[test] +fn a_turn_with_prose_never_waits_on_the_provider() { + // Codex's preamble arrives as commentary, which is not a streaming + // reply and no tool fact: the provider has still answered, so the + // freshly derived status must not fall back to waiting on it. + let mut controller = ConversationStateController::new(thread_id()); + let _ = controller.drain_effects(); + controller + .on_delivery(ConversationDeliveryEvent::SnapshotReceived(snapshot( + 1, + vec![make_turn(TURN_A, 0, ConversationLifecycle::Active)], + vec![ + make_user(USER_A, TURN_A, 1, "hi"), + make_assistant( + ASSISTANT_A, + TURN_A, + 2, + "I'll check the sources first.", + AssistantMessagePhase::Commentary, + ), + ], + ))) + .expect("snapshot delivery succeeds"); + let _ = controller.drain_effects(); + let (narration, _) = scene_status(&controller, TURN_A); + assert_ne!(narration, SceneTurnNarration::ProviderWait); +} + #[test] fn commentary_tool_final_flow_groups_folds_and_promotes() { let mut controller = ConversationStateController::new(thread_id()); diff --git a/tests/frontend/recent_thread_groups.rs b/tests/frontend/recent_thread_groups.rs index 96c9efbd6..4cc44d44d 100644 --- a/tests/frontend/recent_thread_groups.rs +++ b/tests/frontend/recent_thread_groups.rs @@ -28,6 +28,7 @@ fn row(id: &str, activity: i64) -> RecentThread { updated_at: UnixMillis::from_millis(0), }, subtitle: DisplayName::parse("owner/repo").expect("subtitle"), + project_icon: Default::default(), } } diff --git a/tests/protocol/recent_threads.rs b/tests/protocol/recent_threads.rs index 9bdf7df12..889f715b2 100644 --- a/tests/protocol/recent_threads.rs +++ b/tests/protocol/recent_threads.rs @@ -38,6 +38,7 @@ fn row(thread: &str, project: &str, subtitle: &str, last_message: Option) - updated_at: UnixMillis::from_millis(20), }, subtitle: DisplayName::parse(subtitle).expect("subtitle"), + project_icon: Default::default(), } } @@ -85,6 +86,22 @@ fn pushed_recent_threads_round_trip() -> Result<(), Box> { ) } +#[test] +fn recent_project_artwork_round_trips_with_its_host() -> Result<(), Box> { + let mut thread = row("thread-icon", "project", "owner/project", Some(900)); + thread.project_icon = artisan_domain::RecentProjectIcon { + host: Some(DisplayName::parse("github")?), + png: vec![137, 80, 78, 71].into(), + }; + round_trip( + WireEnvelopeBody::Response(ServerResponse { + request_id: RequestId::parse("read-icons")?, + payload: ResponsePayload::RecentThreads(RecentThreadListing::new(vec![thread])?), + }), + "project-icon", + ) +} + #[test] fn pushed_project_catalog_round_trips() -> Result<(), Box> { use artisan_domain::{ProjectListing, ProjectSummary, RootPath}; diff --git a/tests/protocol/user_preferences.rs b/tests/protocol/user_preferences.rs index 6cf451909..9310a3c68 100644 --- a/tests/protocol/user_preferences.rs +++ b/tests/protocol/user_preferences.rs @@ -225,3 +225,32 @@ fn pushed_host_state_events_round_trip() -> Result<(), Box> { } Ok(()) } + +#[test] +fn model_preferences_round_trip_with_every_source() -> Result<(), Box> { + use artisan_domain::{ModelPreferenceSource, SaveModelPreference}; + for source in [ + ModelPreferenceSource::User, + ModelPreferenceSource::Agent, + ModelPreferenceSource::System, + ] { + round_trip( + WireEnvelopeBody::Request(ClientRequest::Command(Command::SaveModelPreference( + SaveModelPreference { + request_id: RequestId::parse("model-preference")?, + selection: CatalogSelection { + model_id: ModelFavoriteId::parse("codex-sol")?, + profile_id: None, + reasoning_effort: Some(CatalogOptionId::parse("medium")?), + speed: None, + context_window: None, + permission: Some(CatalogOptionId::parse("unrestricted")?), + }, + source, + }, + ))), + "model-preference", + )?; + } + Ok(()) +} diff --git a/tests/tracing/export.rs b/tests/tracing/export.rs new file mode 100644 index 000000000..7a0d02c5f --- /dev/null +++ b/tests/tracing/export.rs @@ -0,0 +1,148 @@ +use serde_json::json; + +use crate::event::{Event, bounded_args}; +use crate::recorder::{HISTORY_CAPACITY, History}; + +use super::Snapshot; + +fn event(ts: u64, phase: &'static str, id: Option) -> Event { + Event { + ts, + tid: 7, + category: "navigation", + name: "thread_switch", + phase, + id, + args: json!({}), + } +} + +#[test] +fn concurrent_async_spans_keep_distinct_ids_and_unfinished_work_is_visible() { + let mut history = History::new(); + history.push(event(10, "b", Some(1))); + history.push(event(11, "b", Some(2))); + history.push(event(20, "e", Some(1))); + let export = Snapshot::new(&history, 30, 4, "manual").json("editor", 123); + let events = export["traceEvents"].as_array().unwrap(); + let unfinished = events + .iter() + .find(|e| e["id"] == "2" && e["ph"] == "e") + .unwrap(); + assert_eq!(unfinished["args"]["unfinished"], true); + assert_eq!(unfinished["ts"], 30); + assert_eq!(export["metadata"]["dropped_records"], 4); + assert!(events.iter().all(|e| e["pid"] == 123)); +} + +#[test] +fn time_window_and_count_are_bounded_without_losing_unfinished_operations() { + let mut history = History::new(); + history.push(event(1, "b", Some(1))); + for i in 0..HISTORY_CAPACITY + 10 { + history.push(event(130_000_000 + i as u64, "i", None)); + } + assert_eq!(history.events.len(), HISTORY_CAPACITY); + assert!(history.evicted > 0); + let export = Snapshot::new(&history, 140_000_000, 0, "test").json("editor", 1); + let begin = export["traceEvents"] + .as_array() + .unwrap() + .iter() + .find(|e| e["ph"] == "b") + .unwrap(); + assert_eq!(begin["args"]["begin_truncated"], true); +} + +#[test] +fn an_end_whose_begin_was_dropped_is_not_exported_as_an_orphan() { + let mut history = History::new(); + history.push(event(20, "e", Some(9))); + let export = Snapshot::new(&history, 30, 1, "test").json("editor", 1); + assert!( + !export["traceEvents"] + .as_array() + .unwrap() + .iter() + .any(|e| e["ph"] == "e") + ); +} + +#[test] +fn arguments_reject_nested_payloads_and_bound_unicode_strings() { + let bounded = bounded_args( + json!({"text":"ø".repeat(200), "payload":{"secret":"no"}, "items":[1,2], "count":2}), + ); + assert_eq!(bounded["text"].as_str().unwrap().chars().count(), 128); + assert!(bounded.get("payload").is_none()); + assert!(bounded.get("items").is_none()); + assert_eq!(bounded["count"], 2); +} + +#[test] +fn synchronous_measurements_have_microsecond_durations() { + let mut measured = event(10, "X", None); + measured.args = json!({"duration_us":1234}); + assert_eq!(measured.json(1)["dur"], 1234); +} + +#[test] +fn an_idle_stalled_operation_keeps_its_duration_and_thread_name() { + let mut history = History::new(); + history.push(event(1, "b", Some(1))); + let mut metadata = event(2, "M", None); + metadata.name = "thread_name"; + metadata.args = json!({"name":"editor.ui"}); + history.push(metadata); + history.events.clear(); + let export = Snapshot::new(&history, 150_000_000, 0, "test").json("editor", 1); + let events = export["traceEvents"].as_array().unwrap(); + let begin = events.iter().find(|e| e["ph"] == "b").unwrap(); + assert_eq!(begin["ts"], 30_000_000); + assert!( + events + .iter() + .any(|e| e["name"] == "thread_name" && e["args"]["name"] == "editor.ui") + ); +} + +#[test] +fn disk_retention_is_bounded_and_no_partial_file_survives_success() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "artisan-export-test-{}-{nonce}", + std::process::id() + )); + let history = History::new(); + for sequence in 0..20 { + super::write_snapshot( + &directory, + "fixture", + 1, + sequence, + &Snapshot::new(&history, sequence as u64, 0, "test"), + ) + .unwrap(); + } + let files: Vec<_> = std::fs::read_dir(&directory) + .unwrap() + .map(|e| e.unwrap().path()) + .collect(); + assert_eq!(files.len(), 16); + assert!(files.iter().all(|file| file.extension().unwrap() == "json")); + for file in files { + let _: serde_json::Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(file).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + std::fs::remove_dir_all(directory).unwrap(); +} diff --git a/tests/tracing/merge.py b/tests/tracing/merge.py new file mode 100644 index 000000000..80a8460cb --- /dev/null +++ b/tests/tracing/merge.py @@ -0,0 +1,72 @@ +"""Regression checks for merging overlapping flight-recorder captures.""" +import contextlib +import importlib.util +import io +import json +from pathlib import Path +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location('artisan_trace', Path(__file__).resolve().parents[2] / 'scripts/trace.py') +trace = importlib.util.module_from_spec(spec) +spec.loader.exec_module(trace) + + +class MergeTests(unittest.TestCase): + def merge(self, captures): + with tempfile.TemporaryDirectory() as directory: + paths = [] + for index, capture in enumerate(captures): + path = Path(directory) / f'{index}.json' + path.write_text(json.dumps(capture)) + paths.append(path) + output = Path(directory) / 'merged.json' + with contextlib.redirect_stdout(io.StringIO()): + trace.merge(output, paths) + return trace.read(output)['traceEvents'] + + @staticmethod + def capture(events, role='editor', session=1): + return {'metadata': {'role': role, 'session_start_us': session}, 'traceEvents': events} + + @staticmethod + def boundary(phase, ts, unfinished=False): + return {'pid': 7, 'tid': 1, 'cat': 'navigation', 'name': 'open', 'id': '1', + 'ph': phase, 'ts': ts, 'args': {'unfinished': True} if unfinished else {}} + + def test_real_end_supersedes_every_snapshot_and_begin_is_deduplicated(self): + begin = self.boundary('b', 10) + events = self.merge([ + self.capture([begin, self.boundary('e', 30, True)]), + self.capture([begin, self.boundary('e', 50)]), + self.capture([begin, self.boundary('e', 70, True)]), + ]) + self.assertEqual(len(events), 2) + self.assertEqual(trace.durations(events)[0][0], 40) + self.assertFalse(trace.durations(events)[0][2]) + + def test_latest_unfinished_end_and_earliest_begin_survive(self): + events = self.merge([ + self.capture([self.boundary('b', 10), self.boundary('e', 30, True)]), + self.capture([self.boundary('b', 20), self.boundary('e', 50, True)]), + ]) + self.assertEqual(trace.durations(events)[0][0], 40) + self.assertTrue(trace.durations(events)[0][2]) + + def test_roles_and_process_restarts_have_distinct_process_and_flow_ids(self): + endpoint = {'pid': 7, 'tid': 1, 'ph': 'X', 'name': 'handoff', 'ts': 10, + 'dur': 1, 'bind_id': 'a', 'flow_out': True} + events = self.merge([ + self.capture([endpoint]), self.capture([endpoint]), + self.capture([endpoint], role='forge'), + self.capture([endpoint], session=2), + ]) + self.assertEqual(len(events), 3) + self.assertEqual(len({e['pid'] for e in events}), 3) + self.assertEqual(len({e['bind_id'] for e in events}), 3) + for event in events: + int(event['bind_id'], 16) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/tracing/recorder.rs b/tests/tracing/recorder.rs new file mode 100644 index 000000000..741922c7d --- /dev/null +++ b/tests/tracing/recorder.rs @@ -0,0 +1,192 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde_json::json; + +use super::*; + +#[test] +fn saturation_drops_records_instead_of_waiting_for_the_consumer() { + let (events, _receiver) = mpsc::sync_channel(1); + let (controls, _) = mpsc::sync_channel(1); + let recorder = Recorder { + origin: Instant::now(), + epoch_us: 0, + events, + controls, + dropped: AtomicU64::new(0), + running: AtomicBool::new(true), + }; + for _ in 0..100 { + recorder.record(Event { + ts: 0, + tid: 1, + category: "test", + name: "saturation", + phase: "i", + id: None, + args: json!({}), + }); + } + assert_eq!(recorder.dropped.load(Ordering::Relaxed), 99); +} + +#[test] +fn automatic_stall_manual_capture_and_shutdown_produce_readable_files() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "artisan-tracing-test-{}-{nonce}", + std::process::id() + )); + let session = start_at("fixture", directory.clone()).expect("session"); + name_thread("fixture.ui"); + let queued = span("transport.queue", "fixture.queue", json!({})); + std::thread::spawn(move || { + name_thread("fixture.transport"); + queued.handoff(); + drop(queued); + let _execution = span("transport.command", "fixture.command", json!({})); + let began = Instant::now(); + std::thread::sleep(Duration::from_millis(10)); + completed("ui.task", "fixture.poll", began, Instant::now(), json!({})); + counter("transport", "fixture.depth", json!({"queued":1})); + }) + .join() + .unwrap(); + let operation = span( + "navigation", + "fixture.wait", + json!({"request_id":"fixture-id"}), + ); + let heartbeat = crate::Heartbeat::new("fixture.ui"); + heartbeat.tick(); + std::thread::sleep(Duration::from_millis(750)); + heartbeat.tick(); + // Let the automatic capture keep its post-incident tail. + std::thread::sleep(Duration::from_millis(2300)); + assert!(save()); + std::thread::sleep(Duration::from_millis(200)); + drop(heartbeat); + drop(operation); + drop(session); + let captures: Vec = std::fs::read_dir(&directory) + .unwrap() + .map(|entry| { + serde_json::from_slice(&std::fs::read(entry.unwrap().path()).unwrap()).unwrap() + }) + .collect(); + assert!( + captures + .iter() + .any(|c| c["metadata"]["reason"] == "heartbeat.stalled") + ); + assert!(captures.iter().any(|c| c["metadata"]["reason"] == "manual")); + assert!( + captures + .iter() + .any(|c| c["metadata"]["reason"] == "shutdown") + ); + assert!(captures.iter().any(|c| { + c["traceEvents"] + .as_array() + .unwrap() + .iter() + .any(|e| e["name"] == "heartbeat.stalled") + })); + assert!(captures.iter().any(|c| { + c["traceEvents"] + .as_array() + .unwrap() + .iter() + .any(|e| e["name"] == "fixture.wait" && e["args"]["unfinished"] == true) + })); + let events = captures + .iter() + .flat_map(|c| c["traceEvents"].as_array().unwrap()); + let events: Vec<_> = events.collect(); + assert!( + events + .iter() + .any(|e| e["flow_out"] == true && e["ph"] == "X" && e["dur"] == 1) + ); + assert!( + events + .iter() + .any(|e| e["flow_in"] == true && e["bind_id"].is_string()) + ); + assert!( + events + .iter() + .any(|e| e["name"] == "fixture.poll" && e["dur"].as_u64().unwrap_or(0) >= 10_000) + ); + assert!( + events + .iter() + .any(|e| e["name"] == "fixture.depth" && e["ph"] == "C") + ); + if std::env::var_os("ARTISAN_TRACE_KEEP_FIXTURE").is_some() { + println!("Fixture traces: {}", directory.display()); + } else { + std::fs::remove_dir_all(directory).unwrap(); + } +} + +#[test] +fn idle_history_expires_and_stalls_only_report_once_per_operation() { + let mut history = History::new(); + history.push(Event { + ts: 1, + tid: 1, + category: "navigation", + name: "pending", + phase: "b", + id: Some(1), + args: json!({}), + }); + assert_eq!(history.slow_operation(6_000_000), Some("operation.stalled")); + assert_eq!(history.slow_operation(7_000_000), None); + history.expire(130_000_000); + assert!(history.events.is_empty()); + assert_eq!(history.open.len(), 1); +} + +#[test] +fn a_repeating_incident_saves_once_while_new_reasons_still_save() { + // A failure that repeats every few seconds must not rotate earlier, + // distinct incidents out of the snapshot folder. + let mut pending = None; + let burst_start = 1_000_000; + let history = [("application.failure", burst_start)]; + let later = burst_start + COOLDOWN_US + 1; + schedule( + &mut pending, + Some(burst_start), + &history, + later, + "application.failure", + ); + assert_eq!(pending, None, "the repeat is suppressed"); + schedule( + &mut pending, + Some(burst_start), + &history, + later, + "heartbeat.stalled", + ); + assert_eq!(pending.map(|(_, reason)| reason), Some("heartbeat.stalled")); + let mut pending = None; + let much_later = burst_start + REPEAT_COOLDOWN_US; + schedule( + &mut pending, + Some(burst_start), + &history, + much_later, + "application.failure", + ); + assert!( + pending.is_some(), + "the reason saves again after its cooldown" + ); +}