From 668819098cb77886ef50c38ae6503008f066b5bf Mon Sep 17 00:00:00 2001 From: ss Date: Fri, 2 Oct 2026 22:16:52 +0200 Subject: [PATCH 01/40] tracing: capture client activity and hangs for Perfetto --- Cargo.lock | 10 + Cargo.toml | 1 + docs/runbooks/flight-recorder.md | 114 +++++ modules/backend/Cargo.toml | 4 + modules/backend/src/event_loop_watchdog.rs | 8 + modules/backend/src/lib.rs | 4 + modules/backend/src/request_handler.rs | 4 + modules/frontend/Cargo.toml | 2 + modules/frontend/src/native_application.rs | 18 +- .../src/native_application/app_entry.rs | 14 + .../src/native_application/flight_recorder.rs | 94 ++++ .../src/native_application/history_paging.rs | 4 + .../src/native_application/impl_failure.rs | 6 + .../native_application/impl_host_mounting.rs | 10 + .../src/native_application/impl_lifecycle.rs | 2 + .../src/native_application/impl_pickers.rs | 8 + .../native_application/impl_recent_threads.rs | 16 + .../src/native_application/impl_render.rs | 2 + .../native_application/impl_route_surface.rs | 2 + .../native_application/impl_service_events.rs | 21 + .../frontend/src/native_application/state.rs | 2 + .../native_application/tests/navigation.rs | 2 + .../workspace/tests/crash.rs | 2 + .../frontend/src/native_transport_service.rs | 9 + .../connection_holds.rs | 15 +- .../flight_recorder.rs | 190 ++++++++ .../project_intake.rs | 6 + .../src/native_transport_service/remote.rs | 2 + .../request_delivery.rs | 36 ++ .../service_lifecycle.rs | 27 +- .../native_transport_service/subscriptions.rs | 2 + modules/tracing/Cargo.toml | 17 + modules/tracing/src/event.rs | 84 ++++ modules/tracing/src/export.rs | 162 +++++++ modules/tracing/src/lib.rs | 106 +++++ modules/tracing/src/recorder.rs | 442 ++++++++++++++++++ modules/tracing/src/watchdog.rs | 80 ++++ nix/stages.nix | 6 +- scripts/trace.py | 106 +++++ tests/tracing/export.rs | 148 ++++++ tests/tracing/merge.py | 72 +++ tests/tracing/recorder.rs | 153 ++++++ 42 files changed, 1997 insertions(+), 16 deletions(-) create mode 100644 docs/runbooks/flight-recorder.md create mode 100644 modules/frontend/src/native_application/flight_recorder.rs create mode 100644 modules/frontend/src/native_transport_service/flight_recorder.rs create mode 100644 modules/tracing/Cargo.toml create mode 100644 modules/tracing/src/event.rs create mode 100644 modules/tracing/src/export.rs create mode 100644 modules/tracing/src/lib.rs create mode 100644 modules/tracing/src/recorder.rs create mode 100644 modules/tracing/src/watchdog.rs create mode 100644 scripts/trace.py create mode 100644 tests/tracing/export.rs create mode 100644 tests/tracing/merge.py create mode 100644 tests/tracing/recorder.rs diff --git a/Cargo.lock b/Cargo.lock index b08dfabf4..67845efd4 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" diff --git a/Cargo.toml b/Cargo.toml index b0bda1ac2..db7598c29 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", 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/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/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/lib.rs b/modules/backend/src/lib.rs index a35469b68..68c36d573 100644 --- a/modules/backend/src/lib.rs +++ b/modules/backend/src/lib.rs @@ -118,6 +118,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/request_handler.rs b/modules/backend/src/request_handler.rs index 60c4436e2..e6953c090 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) 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/native_application.rs b/modules/frontend/src/native_application.rs index a01d31414..bb3b76f05 100644 --- a/modules/frontend/src/native_application.rs +++ b/modules/frontend/src/native_application.rs @@ -219,6 +219,8 @@ mod impl_engine_installs; #[path = "native_application/app_entry.rs"] mod app_entry; +#[cfg(feature = "flight-recorder")] +mod flight_recorder; mod frame_capture; #[path = "native_application/impl_route_surface.rs"] @@ -234,15 +236,13 @@ 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, - 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, + 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_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, }; @@ -251,6 +251,8 @@ 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, 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..070132c58 --- /dev/null +++ b/modules/frontend/src/native_application/flight_recorder.rs @@ -0,0 +1,94 @@ +//! 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::*; + +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 + ); + artisan_tracing::instant!("state", "navigation.gates", + "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() + ); + } +} 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_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..0999327d3 100644 --- a/modules/frontend/src/native_application/impl_lifecycle.rs +++ b/modules/frontend/src/native_application/impl_lifecycle.rs @@ -313,6 +313,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_recent_threads.rs b/modules/frontend/src/native_application/impl_recent_threads.rs index d4bcaf734..2542d480f 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) { diff --git a/modules/frontend/src/native_application/impl_render.rs b/modules/frontend/src/native_application/impl_render.rs index 847873b40..2b1b05042 100644 --- a/modules/frontend/src/native_application/impl_render.rs +++ b/modules/frontend/src/native_application/impl_render.rs @@ -7,6 +7,8 @@ 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_profile_actions(); // The sidebar fills its shell slot and renders only when notified, diff --git a/modules/frontend/src/native_application/impl_route_surface.rs b/modules/frontend/src/native_application/impl_route_surface.rs index bb2a2053e..c9fe1b975 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() { 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/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/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_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..d4409c9bb 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 { 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..62fc8907a 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,17 @@ impl ServiceRuntime { &mut self, attempt: Result<(ClientSession, ResponsePayload), RequestAttemptError>, ) -> Result { + #[cfg(feature = "flight-recorder")] + match &attempt { + Ok(_) => artisan_tracing::instant!("transport.request", "request.succeeded"), + Err( + RequestAttemptError::Retained { failure, .. } + | 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 +371,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 +529,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 +725,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..a89b9b68d 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, @@ -541,6 +560,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/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..ef7d55133 --- /dev/null +++ b/modules/tracing/src/recorder.rs @@ -0,0 +1,442 @@ +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; + +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. +/// 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( + || std::env::temp_dir().join("artisan-traces"), + PathBuf::from, + ); + start_at(role, directory) +} + +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; + 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, now, reason), + } + } + if let Some(reason) = history.slow_operation(now) { + schedule(&mut pending, last_incident, now, reason); + } + if let Some((due, reason)) = pending + && now >= due + { + send_snapshot(&snapshots, &history, recorder, reason); + pending = None; + last_incident = Some(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, + now: u64, + reason: &'static str, +) { + if pending.is_none() && 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/nix/stages.nix b/nix/stages.nix index 46202aba0..4b462cba0 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 = { 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/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..3d3afde1f --- /dev/null +++ b/tests/tracing/recorder.rs @@ -0,0 +1,153 @@ +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); +} From cce1587ea7c55990c9cf42ca96c36477a92b4cfc Mon Sep 17 00:00:00 2001 From: ss Date: Fri, 2 Oct 2026 22:36:49 +0200 Subject: [PATCH 02/40] sidebar: show cached project icons below thread titles --- docs/runbooks/project-icons.md | 36 ++ modules/backend/src/lib.rs | 1 + modules/backend/src/project_icon_service.rs | 212 +++++++ modules/backend/src/project_subtitles.rs | 84 ++- .../src/request_handler/recent_threads.rs | 6 + modules/domain/src/lib.rs | 3 +- modules/domain/src/recent_threads.rs | 16 +- modules/frontend/src/native_application.rs | 1 + .../native_application/impl_recent_threads.rs | 1 + .../impl_sidebar_threads.rs | 15 +- .../src/native_application/project_icon.rs | 83 +++ .../tests/render_boundaries.rs | 1 + .../tests/sidebar_recents.rs | 1 + modules/protocol/schema/artisan.capnp | 5 + modules/protocol/src/artisan_capnp.rs | 516 ++++++++++-------- modules/protocol/src/codec/recent_threads.rs | 30 +- tests/backend/project_icon_service.rs | 100 ++++ tests/frontend/recent_thread_groups.rs | 1 + tests/protocol/recent_threads.rs | 17 + 19 files changed, 905 insertions(+), 224 deletions(-) create mode 100644 docs/runbooks/project-icons.md create mode 100644 modules/backend/src/project_icon_service.rs create mode 100644 modules/frontend/src/native_application/project_icon.rs create mode 100644 tests/backend/project_icon_service.rs 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/backend/src/lib.rs b/modules/backend/src/lib.rs index 68c36d573..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; 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/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/domain/src/lib.rs b/modules/domain/src/lib.rs index 33168c60c..d9bce298d 100644 --- a/modules/domain/src/lib.rs +++ b/modules/domain/src/lib.rs @@ -242,7 +242,8 @@ 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; 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/frontend/src/native_application.rs b/modules/frontend/src/native_application.rs index bb3b76f05..307facc99 100644 --- a/modules/frontend/src/native_application.rs +++ b/modules/frontend/src/native_application.rs @@ -222,6 +222,7 @@ 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; diff --git a/modules/frontend/src/native_application/impl_recent_threads.rs b/modules/frontend/src/native_application/impl_recent_threads.rs index 2542d480f..ab0d36cb1 100644 --- a/modules/frontend/src/native_application/impl_recent_threads.rs +++ b/modules/frontend/src/native_application/impl_recent_threads.rs @@ -393,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_sidebar_threads.rs b/modules/frontend/src/native_application/impl_sidebar_threads.rs index 1e62a5158..ef4f08528 100644 --- a/modules/frontend/src/native_application/impl_sidebar_threads.rs +++ b/modules/frontend/src/native_application/impl_sidebar_threads.rs @@ -82,6 +82,7 @@ impl SidebarThreadIndicator { #[derive(Default)] pub(super) struct SidebarThreadsState { focus: HashMap, + project_icons: super::project_icon::ProjectIconCache, hover: Rc>, bounds: Rc>>>, selection: SidebarSelectionFade, @@ -253,6 +254,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 @@ -367,6 +369,11 @@ impl NativeApplication { ); let title = SharedString::from(thread.title.as_str().to_owned()); let subtitle = SharedString::from(row.subtitle.as_str().to_owned()); + let project_icon = self.sidebar_threads.project_icons.render( + &thread.project_id, + &row.project_icon, + &self.theme, + ); let open = self.sidebar_threads.selection.target.as_ref() == Some(&thread.thread_id); let indicator = SidebarThreadIndicator::of(thread, open); let description = match indicator { @@ -435,11 +442,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 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/tests/render_boundaries.rs b/modules/frontend/src/native_application/tests/render_boundaries.rs index f8e1452ce..455ae6b40 100644 --- a/modules/frontend/src/native_application/tests/render_boundaries.rs +++ b/modules/frontend/src/native_application/tests/render_boundaries.rs @@ -127,6 +127,7 @@ fn a_recent_threads_push_renders_the_sidebar(cx: &mut TestAppContext) { 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..b8d454114 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(), } } diff --git a/modules/protocol/schema/artisan.capnp b/modules/protocol/schema/artisan.capnp index 690f9af88..a48332a58 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 diff --git a/modules/protocol/src/artisan_capnp.rs b/modules/protocol/src/artisan_capnp.rs index 520b3ccc6..aa537173d 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), @@ -20553,7 +20635,7 @@ pub mod request { ::capnp::word(96, 188, 173, 59, 139, 232, 73, 225), ::capnp::word(1, 0, 7, 0, 0, 0, 52, 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, 204, 133, 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), @@ -23182,7 +23264,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(252, 134, 0, 0, 68, 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 +24395,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(210, 155, 0, 0, 18, 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 +24621,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(92, 156, 0, 0, 144, 156, 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 +24715,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(146, 156, 0, 0, 228, 156, 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 +24969,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(230, 156, 0, 0, 98, 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 +25282,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(100, 157, 0, 0, 22, 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 +25686,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(24, 159, 0, 0, 97, 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 +25912,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(36, 161, 0, 0, 229, 161, 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 +26628,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(7, 163, 0, 0, 128, 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 +27016,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(222, 181, 0, 0, 45, 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 +27093,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(90, 182, 0, 0, 178, 182, 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 +27174,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(219, 182, 0, 0, 47, 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 +27250,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(84, 183, 0, 0, 141, 183, 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 +27318,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(188, 183, 0, 0, 249, 183, 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 +27386,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(50, 184, 0, 0, 111, 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 +27458,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(169, 184, 0, 0, 2, 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 +27534,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(51, 185, 0, 0, 115, 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 +27605,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(165, 185, 0, 0, 241, 185, 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 +27677,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(34, 186, 0, 0, 98, 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 +27748,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(128, 186, 0, 0, 210, 186, 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 +27822,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(5, 187, 0, 0, 70, 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 +27891,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(123, 187, 0, 0, 190, 187, 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 +27963,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(235, 187, 0, 0, 49, 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 +28040,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(91, 188, 0, 0, 193, 188, 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 +28129,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(246, 188, 0, 0, 115, 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 +28217,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(172, 189, 0, 0, 245, 189, 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 +28291,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(41, 190, 0, 0, 113, 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 +28369,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(168, 190, 0, 0, 24, 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 +28454,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(84, 191, 0, 0, 153, 191, 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 +28740,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(209, 191, 0, 0, 123, 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 +29128,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(165, 192, 0, 0, 85, 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 +29533,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(153, 193, 0, 0, 153, 194, 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 +30176,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(197, 194, 0, 0, 141, 195, 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 +30549,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(208, 195, 0, 0, 173, 196, 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 +30922,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(233, 196, 0, 0, 4, 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 +31812,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(53, 198, 0, 0, 167, 199, 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 +32756,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(240, 199, 0, 0, 87, 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 +33912,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(24, 203, 0, 0, 152, 203, 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 +34287,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(12, 204, 0, 0, 23, 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 +34867,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(70, 205, 0, 0, 215, 205, 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 +35267,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(155, 206, 0, 0, 43, 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 +35928,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(80, 209, 0, 0, 184, 209, 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 +36230,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(222, 209, 0, 0, 154, 210, 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 +36581,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(198, 210, 0, 0, 53, 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 +37204,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(113, 212, 0, 0, 32, 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 +37520,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(83, 213, 0, 0, 195, 213, 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 +37812,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(1, 214, 0, 0, 134, 214, 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 +38216,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(184, 214, 0, 0, 46, 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 +38581,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(80, 216, 0, 0, 203, 216, 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 +38861,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(238, 216, 0, 0, 111, 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 +39142,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(149, 217, 0, 0, 10, 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 +39412,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(45, 218, 0, 0, 198, 218, 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 +39728,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(231, 218, 0, 0, 21, 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 +40839,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(41, 221, 0, 0, 234, 221, 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 +41144,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(254, 221, 0, 0, 253, 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), @@ -41886,7 +41968,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(19, 223, 0, 0, 105, 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 +42966,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(206, 224, 0, 0, 239, 226, 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 +43400,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(40, 227, 0, 0, 235, 227, 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 +43841,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(190, 228, 0, 0, 225, 229, 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 +44585,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(120, 230, 0, 0, 158, 233, 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 +46282,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(224, 233, 0, 0, 0, 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 +46901,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(55, 235, 0, 0, 29, 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 +47488,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(79, 236, 0, 0, 217, 236, 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 +47813,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(9, 237, 0, 0, 52, 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 +49001,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(189, 238, 0, 0, 235, 242, 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 +49645,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(101, 244, 0, 0, 239, 244, 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 +49952,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(54, 245, 0, 0, 27, 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 +50352,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(118, 252, 0, 0, 110, 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 +50626,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(183, 254, 0, 0, 251, 0, 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 +51051,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(73, 1, 1, 0, 230, 4, 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 +51276,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(119, 6, 1, 0, 67, 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 +51612,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(151, 7, 1, 0, 82, 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 +52064,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(68, 14, 1, 0, 0, 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 +52407,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(127, 19, 1, 0, 129, 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 +52739,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(193, 22, 1, 0, 222, 25, 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 +53034,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(29, 26, 1, 0, 185, 28, 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 +53331,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(241, 28, 1, 0, 141, 31, 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 +53768,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(144, 32, 1, 0, 173, 35, 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 +54136,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(136, 37, 1, 0, 99, 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 +54388,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(186, 40, 1, 0, 47, 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 +54616,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(102, 41, 1, 0, 76, 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 +55125,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(13, 45, 1, 0, 29, 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 +55673,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(102, 46, 1, 0, 17, 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 +56427,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(88, 50, 1, 0, 212, 50, 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 +56651,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(255, 50, 1, 0, 73, 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 +56928,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(158, 53, 1, 0, 162, 54, 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 +57161,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(238, 54, 1, 0, 100, 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 +57455,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(139, 55, 1, 0, 120, 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 +57778,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(89, 58, 1, 0, 114, 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 +58596,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(91, 64, 1, 0, 173, 64, 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 +58903,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(170, 65, 1, 0, 92, 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 +59498,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(40, 67, 1, 0, 198, 67, 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 +59925,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(140, 68, 1, 0, 171, 70, 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 +60668,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(49, 71, 1, 0, 222, 71, 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 +61014,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(118, 72, 1, 0, 215, 72, 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 +61285,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(105, 73, 1, 0, 188, 73, 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 +61517,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(49, 77, 1, 0, 74, 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 +61887,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(139, 78, 1, 0, 224, 78, 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 +61964,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(41, 79, 1, 0, 131, 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 +62039,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(16, 80, 1, 0, 85, 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 +62314,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(69, 81, 1, 0, 233, 83, 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 +62782,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(235, 84, 1, 0, 182, 89, 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 +63398,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(79, 90, 1, 0, 75, 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 +63689,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(200, 94, 1, 0, 60, 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 +64022,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(231, 95, 1, 0, 62, 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 +64171,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(71, 98, 1, 0, 176, 98, 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 +64476,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(122, 99, 1, 0, 83, 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 +64907,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(6, 101, 1, 0, 228, 101, 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 +65106,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(101, 103, 1, 0, 192, 103, 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 +65315,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(194, 103, 1, 0, 142, 104, 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 +65540,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(5, 105, 1, 0, 58, 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 +65777,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(60, 105, 1, 0, 168, 105, 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 +66107,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(238, 105, 1, 0, 183, 106, 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 +66398,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(28, 108, 1, 0, 103, 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 +66649,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(172, 108, 1, 0, 241, 108, 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 +66900,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(43, 109, 1, 0, 113, 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 +67220,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(115, 109, 1, 0, 216, 110, 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 +67525,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(26, 111, 1, 0, 103, 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 +67780,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(251, 111, 1, 0, 122, 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 +67899,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(124, 112, 1, 0, 205, 112, 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 +68121,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(207, 112, 1, 0, 102, 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 +68390,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(247, 113, 1, 0, 55, 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 +68640,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(187, 114, 1, 0, 17, 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 +68967,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(86, 115, 1, 0, 9, 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 +69122,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(62, 116, 1, 0, 168, 116, 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 +69563,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(51, 117, 1, 0, 5, 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 +69875,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(7, 120, 1, 0, 79, 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 +70063,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(126, 120, 1, 0, 199, 120, 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 +70313,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(253, 120, 1, 0, 110, 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 +70597,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(177, 121, 1, 0, 11, 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 +70900,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(67, 122, 1, 0, 198, 122, 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 +71167,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(67, 124, 1, 0, 145, 124, 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 +71402,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(238, 124, 1, 0, 56, 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 +71680,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(91, 126, 1, 0, 5, 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 +71810,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(47, 127, 1, 0, 116, 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 +72062,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(240, 128, 1, 0, 118, 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 +72207,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(218, 129, 1, 0, 66, 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 +72419,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(181, 131, 1, 0, 117, 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 +72999,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(215, 132, 1, 0, 118, 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 +73307,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(228, 133, 1, 0, 22, 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 +73824,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(83, 135, 1, 0, 226, 135, 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 +74110,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(117, 136, 1, 0, 20, 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 +74412,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(82, 137, 1, 0, 162, 137, 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 +74724,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(207, 137, 1, 0, 125, 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 +75538,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(10, 141, 1, 0, 99, 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 +75805,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(211, 141, 1, 0, 46, 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), 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/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/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}; From 7eedc9dafd4b2f8cbb0f00062c23d615ef3b8234 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 15:52:35 +0200 Subject: [PATCH 03/40] style: reflow two backend calls on the pinned toolchain Formatting only; no behaviour change. Co-Authored-By: Claude Opus 5.5 --- .../src/engine_owner/operation/claude.rs | 19 ++++++++----------- .../src/engine_owner/socket/codex_session.rs | 11 ++++++++--- 2 files changed, 16 insertions(+), 14 deletions(-) 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. From a1ed8015cc765a068ea0b421d3ba428a4d520ea1 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 15:52:35 +0200 Subject: [PATCH 04/40] models: grey out engines whose CLI is signed out Grok Build's listing now recognises the 1.0.4x "Available models:" header and its `(default)` marker, and a signed-out CLI is reported as such instead of as an engine with no models. The Forge drops unauthenticated engines from the runnable set, the catalog policy can tell whether an engine reported any enabled model, and the picker greys out and disables the tabs of engines that cannot offer one, preferring a usable tab. Co-Authored-By: Claude Opus 5.5 --- modules/backend/src/model_discovery/grok.rs | 85 +++++++++++++++--- modules/backend/src/model_discovery/mod.rs | 14 ++- modules/backend/src/native_model_catalog.rs | 30 +++++-- .../backend/src/native_model_catalog/tests.rs | 30 +++++++ modules/catalog/rust/policy.rs | 11 +++ .../src/native_application/impl_catalog.rs | 22 +++++ .../src/native_application/impl_render.rs | 1 + modules/frontend/src/native_model_selector.rs | 2 + .../src/native_model_selector/interaction.rs | 7 ++ .../src/native_model_selector/render/menu.rs | 19 +++- .../src/native_model_selector/state.rs | 87 +++++++++++++++---- .../src/native_model_selector/tests.rs | 39 +++++++++ 12 files changed, 309 insertions(+), 38 deletions(-) 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..5e980210e 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 { @@ -177,6 +180,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), @@ -215,6 +226,7 @@ pub(crate) async fn discovery_bundle() -> Arc { models, probed_engines, missing_engines, + unauthenticated_engines, }); let ttl = if retry_needed { Duration::from_secs(10) 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/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/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_render.rs b/modules/frontend/src/native_application/impl_render.rs index 2b1b05042..caf32437c 100644 --- a/modules/frontend/src/native_application/impl_render.rs +++ b/modules/frontend/src/native_application/impl_render.rs @@ -10,6 +10,7 @@ impl Render for NativeApplication { #[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. diff --git a/modules/frontend/src/native_model_selector.rs b/modules/frontend/src/native_model_selector.rs index b79ac821a..b0eb01314 100644 --- a/modules/frontend/src/native_model_selector.rs +++ b/modules/frontend/src/native_model_selector.rs @@ -224,6 +224,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/menu.rs b/modules/frontend/src/native_model_selector/render/menu.rs index 2a83e3513..527c0ce32 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() 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); +} From 685fd197784c016ad714fb2c2a9076c811bdf4c7 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 15:52:35 +0200 Subject: [PATCH 05/40] approvals: settle answered approval cards from their receipts Approval observations carry their recorded decision into the scene, and the gate remembers a decision the engine acknowledged, so a card reads as allowed or denied as soon as the receipt lands instead of staying in flight until the resolution arrives. Gates are keyed by the engine's approval identity, the name both the gesture and its receipt carry. An approval or question pauses a work section rather than splitting it. Co-Authored-By: Claude Opus 5.5 --- .../conversation_observation_projection.rs | 71 +++++++++++++++++-- .../frontend/src/conversation_scene/blocks.rs | 9 ++- .../frontend/src/conversation_scene/build.rs | 5 ++ .../src/conversation_state_machine/types.rs | 6 +- .../src/conversation_surface/answer_state.rs | 17 +++++ .../conversation_surface/render_sections.rs | 54 +++++++++++++- .../tests/answer_receipts.rs | 56 ++++++++++++++- .../tests/work_group_header.rs | 57 +++++++++++++++ .../src/conversation_surface/turn_row.rs | 8 ++- 9 files changed, 270 insertions(+), 13 deletions(-) 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..20904780f 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, } diff --git a/modules/frontend/src/conversation_scene/build.rs b/modules/frontend/src/conversation_scene/build.rs index e4bc4624f..9192ea5ba 100644 --- a/modules/frontend/src/conversation_scene/build.rs +++ b/modules/frontend/src/conversation_scene/build.rs @@ -1023,6 +1023,7 @@ impl ConversationScene { SceneItemKind::Approval { prompt, approval_id, + decision, } => { blocks.push(TurnBlock::Approval(ApprovalBlock { id: item.id, @@ -1032,6 +1033,7 @@ impl ConversationScene { .and_then(|provenance| provenance.run_id.clone()), approval_id, prompt, + decision, disclosure: item.disclosure, })); } @@ -1119,6 +1121,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 { 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/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/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/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..859989776 100644 --- a/modules/frontend/src/conversation_surface/tests/work_group_header.rs +++ b/modules/frontend/src/conversation_surface/tests/work_group_header.rs @@ -526,3 +526,60 @@ 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)); +} diff --git a/modules/frontend/src/conversation_surface/turn_row.rs b/modules/frontend/src/conversation_surface/turn_row.rs index a7ace5e28..2b311e0e6 100644 --- a/modules/frontend/src/conversation_surface/turn_row.rs +++ b/modules/frontend/src/conversation_surface/turn_row.rs @@ -34,10 +34,11 @@ use super::*; /// 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, } @@ -320,15 +321,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 +339,7 @@ impl TurnRowView { key: key.to_owned(), in_flight, pending_decision, + answered_decision, failure: failure.map(str::to_owned), }; if index < self.approvals.len() { From 5634666e8a79b8bc69984ff461ea3197c821a01a Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 15:52:35 +0200 Subject: [PATCH 06/40] titlebar: show the thread's context window as a meter The thread route paints a compact context meter at the conversation end of the titlebar, just left of the inspector rule or the caption controls, showing the same validated reading as the composer's ring. The inspector rule now continues up through the titlebar like the sidebar's, and the header's title keeps clear of the meter. A shared material face (`flame`) backs the meter's details card. Co-Authored-By: Claude Opus 5.5 --- modules/frontend/src/desktop_shell.rs | 122 +++++++++++- .../src/native_application/impl_render.rs | 20 ++ modules/frontend/src/native_context_usage.rs | 109 ++++++++++- modules/frontend/src/parity_visual_proof.rs | 1 + modules/ui/src/flame.rs | 177 ++++++++++++++++++ modules/ui/src/lib.rs | 1 + 6 files changed, 422 insertions(+), 8 deletions(-) create mode 100644 modules/ui/src/flame.rs 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/native_application/impl_render.rs b/modules/frontend/src/native_application/impl_render.rs index caf32437c..0d003a0d8 100644 --- a/modules/frontend/src/native_application/impl_render.rs +++ b/modules/frontend/src/native_application/impl_render.rs @@ -30,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() @@ -45,6 +64,7 @@ impl Render for NativeApplication { sidebar, body, inspector_width, + context_meter, window.is_maximized(), ); div() 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/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/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..425d2c649 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; From e5cf606797dbae152a38d78865c87c3ba6ce7c41 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 15:52:35 +0200 Subject: [PATCH 07/40] ui: shift-free streaming reveal policy for markdown `stable_reveal_end` finds the longest prefix of a streaming body whose rendering can no longer change: it holds back the trailing word, an unclosed inline construct, unterminated fences and tables, a trailing run of pipe lines, and marker-only lines. GPUI wraps greedily in one pass, so painting only that prefix never moves a glyph already on screen; the tests check it for every prefix of sample replies. `RevealSteps` walks a prefix one word, inline construct, or block at a time, and the renderer fades fresh segments through `RevealFade` without touching layout. Co-Authored-By: Claude Opus 5.5 --- modules/ui/src/lib.rs | 1 + modules/ui/src/markdown.rs | 2 +- modules/ui/src/markdown_renderer.rs | 169 +++++++-- modules/ui/src/markdown_reveal.rs | 479 ++++++++++++++++++++++++ modules/ui/src/markdown_reveal_tests.rs | 237 ++++++++++++ 5 files changed, 863 insertions(+), 25 deletions(-) create mode 100644 modules/ui/src/markdown_reveal.rs create mode 100644 modules/ui/src/markdown_reveal_tests.rs diff --git a/modules/ui/src/lib.rs b/modules/ui/src/lib.rs index 425d2c649..466558ef3 100644 --- a/modules/ui/src/lib.rs +++ b/modules/ui/src/lib.rs @@ -30,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..119fdbebc --- /dev/null +++ b/modules/ui/src/markdown_reveal.rs @@ -0,0 +1,479 @@ +//! 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 opener = match character { + '*' | '`' | '[' | '<' => true, + // Intraword underscores (`snake_case`) can never 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..29b85c015 --- /dev/null +++ b/modules/ui/src/markdown_reveal_tests.rs @@ -0,0 +1,237 @@ +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 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", + ); +} From 3a164b99c9a9e5b0b2601ad07042512817843e7b Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 15:52:35 +0200 Subject: [PATCH 08/40] conversation: keep counting "Working for" while the reply streams A streaming reply suppresses the turn's status block, which carried the only clock basis, so the section header fell back to a bare "Working" while the summary streamed. The basis now also lives on the turn scene, and the section title counts from it until the turn ends. Co-Authored-By: Claude Opus 5.5 --- .../frontend/src/conversation_scene/blocks.rs | 6 +++ .../frontend/src/conversation_scene/build.rs | 1 + .../src/conversation_surface/status_copy.rs | 18 ++++---- .../tests/work_group_header.rs | 44 +++++++++++++++++++ 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/modules/frontend/src/conversation_scene/blocks.rs b/modules/frontend/src/conversation_scene/blocks.rs index 20904780f..2a03bdf69 100644 --- a/modules/frontend/src/conversation_scene/blocks.rs +++ b/modules/frontend/src/conversation_scene/blocks.rs @@ -660,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 9192ea5ba..484a311c9 100644 --- a/modules/frontend/src/conversation_scene/build.rs +++ b/modules/frontend/src/conversation_scene/build.rs @@ -1212,6 +1212,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_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/work_group_header.rs b/modules/frontend/src/conversation_surface/tests/work_group_header.rs index 859989776..767c5f18d 100644 --- a/modules/frontend/src/conversation_surface/tests/work_group_header.rs +++ b/modules/frontend/src/conversation_surface/tests/work_group_header.rs @@ -583,3 +583,47 @@ fn an_approval_pauses_the_section_without_splitting_it() { 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"); +} + From dc19f2b403259520f921fb2fd0fd7f5d8206b54d Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 15:52:35 +0200 Subject: [PATCH 09/40] conversation: stream prose in segments and animate live work Replies and the prose inside a turn's work now stream in: each row shows only the stable prefix of its body (see `markdown_reveal`), revealing everything that became stable at most every 150 ms as one segment that fades in at a single opacity. Fences and tables appear only complete, and nothing on screen ever moves. While a turn is live its work moves too: tool chains, chain steps, and the status line enter with the texts-reveal recipe, the status line leaves by fading while its height closes, and chain headers and the elapsed header count like an odometer, appended clauses rising in. The work section folds the moment the reply's first segment shows. Settled history and a row's first render paint at rest. Co-Authored-By: Claude Opus 5.5 --- docs/reference-scene-interface.md | 14 +- modules/frontend/src/conversation_surface.rs | 41 +- .../src/conversation_surface/render_blocks.rs | 191 +++++- .../render_work_group_header.rs | 14 +- .../src/conversation_surface/reply_reveal.rs | 216 +++++++ .../tests/work_group_header.rs | 81 +++ .../conversation_surface/transcript_window.rs | 84 ++- .../src/conversation_surface/turn_row.rs | 75 ++- .../src/conversation_surface/work_motion.rs | 568 ++++++++++++++++++ 9 files changed, 1223 insertions(+), 61 deletions(-) create mode 100644 modules/frontend/src/conversation_surface/reply_reveal.rs create mode 100644 modules/frontend/src/conversation_surface/work_motion.rs 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/modules/frontend/src/conversation_surface.rs b/modules/frontend/src/conversation_surface.rs index 0afb07467..67ed0049d 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/). @@ -707,7 +712,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 +758,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 +807,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 { @@ -850,3 +869,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/render_blocks.rs b/modules/frontend/src/conversation_surface/render_blocks.rs index e136291ce..054dd9a6f 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, 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 @@ -41,6 +66,64 @@ pub(super) struct WorkGroupPlacement { } 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 mut motion = self.motion.borrow_mut(); + motion.status_painted(copy.as_deref().unwrap_or_default(), height); + let entrance = motion.entrance("status"); + drop(motion); + let measured = Rc::clone(&self.status_height); + let status = div() + .w_full() + .min_w_0() + .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)) + } + + /// 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) + .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 +163,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 +249,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 +326,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 +363,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 +384,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 +461,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 +492,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 +506,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 +521,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 +555,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 +565,33 @@ 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)); // 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( + let row = self.render_detail_row( rows[row_index].1, 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)); 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 +639,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 +804,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!( @@ -777,14 +908,10 @@ 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)); let id = row.scene_id(); row_identities.push((Some(id.clone()), item_id_for_scene_id(id))); } @@ -868,12 +995,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_work_group_header.rs b/modules/frontend/src/conversation_surface/render_work_group_header.rs index 9fa138167..3f704c50d 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(), }; 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..b351d7dff --- /dev/null +++ b/modules/frontend/src/conversation_surface/reply_reveal.rs @@ -0,0 +1,216 @@ +//! 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 in segments: at most every [`SEGMENT_INTERVAL`], +//! everything that became stable since the last segment is revealed at +//! once and fades in as one piece, one opacity across the whole segment. +//! Revealing word by word with long overlapping fades read as a +//! left-to-right wipe across letters; a segment instead appears together. +//! 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 segment's fade-in (`--duration-medium`): long enough to read as a +/// fade, short enough that at most a couple of segments are mid-fade. +const UNIT_FADE: Duration = MotionDuration::Medium.as_duration(); + +/// 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(); + +/// A reply first seen with more words pending than this shows them at once: +/// replaying text that already arrived would read as a replay, not a stream. +const SNAP_BACKLOG_WORDS: usize = 96; + +/// 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 segment, by visible offset, oldest first. + marks: Vec<(usize, Instant)>, + /// Earliest instant the next pacing tick may reveal more. + next_tick: Instant, + /// The fade resolved by the latest [`Self::advance`]. + pub(super) fade: RevealFade, +} + +impl ReplyReveal { + /// Starts revealing prose first seen while it streams. + pub(super) fn begin(body: &str, now: Instant) -> Self { + let stable = &body[..stable_reveal_end(body)]; + let backlog = RevealSteps::new(stable).pending_words(0); + let snap = backlog > SNAP_BACKLOG_WORDS; + Self { + revealed: if snap { + stable.to_owned() + } else { + String::new() + }, + visible_len: 0, + resync: snap, + marks: Vec::new(), + next_tick: now, + fade: RevealFade::default(), + } + } + + /// 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.resync = true; + } + let start = self.revealed.len(); + if start < stable { + match motion { + MotionPolicy::Reduced => body[..stable].clone_into(&mut self.revealed), + MotionPolicy::Full if now >= self.next_tick => { + self.revealed.push_str(&body[start..stable]); + self.next_tick = now + SEGMENT_INTERVAL; + } + MotionPolicy::Full => {} + } + } + if self.revealed.len() > start && !self.resync && motion == MotionPolicy::Full { + self.marks.push((self.visible_len, now)); + } + self.fade = self.resolve_fade(now); + self.revealed.len() < stable || !self.marks.is_empty() + } + + /// Records the visible length the renderer reported for `revealed`. + pub(super) fn rendered(&mut self, visible_len: usize) { + 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() + } + + /// 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 = MotionCurve::EaseInOut.sample(progress) as f32; + eased + }; + let opaque = self + .marks + .iter() + .take_while(|(_, started)| alpha(*started) >= 1.0) + .count(); + self.marks.drain(..opaque); + RevealFade::new( + self.marks + .iter() + .map(|(offset, started)| (*offset, alpha(*started))) + .collect(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reveals_each_stable_stretch_as_one_segment_with_one_opacity() { + let start = Instant::now(); + let mut reveal = ReplyReveal::begin("Hello brave new", start); + assert_eq!(reveal.revealed(), ""); + assert!(reveal.advance("Hello brave new", true, MotionPolicy::Full, start)); + // Every complete word shows at once; the trailing `new` may grow. + assert_eq!(reveal.revealed(), "Hello brave "); + reveal.rendered(11); + let mid = start + UNIT_FADE / 2; + reveal.advance("Hello brave new", true, MotionPolicy::Full, mid); + // One segment, one opacity: the first and last letters match. + assert!(reveal.fade.alpha_at(0) < 1.0); + assert!((reveal.fade.alpha_at(0) - reveal.fade.alpha_at(10)).abs() < f32::EPSILON); + + // Text stable within the interval waits for the next segment. + let soon = start + SEGMENT_INTERVAL / 2; + reveal.advance("Hello brave new world ", true, MotionPolicy::Full, soon); + assert_eq!(reveal.revealed(), "Hello brave "); + let next = start + SEGMENT_INTERVAL; + reveal.advance("Hello brave new world ", true, MotionPolicy::Full, next); + assert_eq!(reveal.revealed(), "Hello brave new world "); + reveal.rendered(21); + + let done = next + UNIT_FADE * 2; + assert!(!reveal.advance("Hello brave new world ", false, MotionPolicy::Full, done)); + assert!(reveal.is_complete("Hello brave new world ")); + } + + #[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 "); + } + + #[test] + fn a_long_reply_first_seen_mid_stream_shows_at_once() { + let body = "word ".repeat(SNAP_BACKLOG_WORDS + 1); + let reveal = ReplyReveal::begin(&body, Instant::now()); + assert_eq!(reveal.revealed(), body); + } +} 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 767c5f18d..6c02832c5 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", @@ -627,3 +628,83 @@ fn a_streaming_reply_keeps_the_section_counting_until_the_turn_ends() { 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_reply_shows_a_whole_word(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 lone partial word is not on screen yet: the work stays open. + let (_waiting, cx_waiting) = cx.add_window_view(|_, surface_cx| { + ConversationSurface::new(replying_section_scene("All"), ThemeMode::Dark, surface_cx) + }); + cx_waiting.simulate_resize(size(px(720.0), px(720.0))); + settle(cx_waiting); + assert!( + cx_waiting.debug_bounds(STEER_LEAD_CHAIN).is_some(), + "the work stays open until a whole word of the reply shows" + ); + + // Once a whole word shows, the work folds while the turn is still live, + // and the header keeps its words. + let (_replying, cx) = cx.add_window_view(|_, surface_cx| { + ConversationSurface::new( + replying_section_scene("All tests pass."), + 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 as soon as the reply shows" + ); +} diff --git a/modules/frontend/src/conversation_surface/transcript_window.rs b/modules/frontend/src/conversation_surface/transcript_window.rs index e4d346728..a6724f816 100644 --- a/modules/frontend/src/conversation_surface/transcript_window.rs +++ b/modules/frontend/src/conversation_surface/transcript_window.rs @@ -219,28 +219,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 +231,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. diff --git a/modules/frontend/src/conversation_surface/turn_row.rs b/modules/frontend/src/conversation_surface/turn_row.rs index 2b311e0e6..a7d99d0b4 100644 --- a/modules/frontend/src/conversation_surface/turn_row.rs +++ b/modules/frontend/src/conversation_surface/turn_row.rs @@ -26,9 +26,10 @@ //! 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::*; /// One approval row's gate snapshot, the only answer state a row paints. @@ -165,6 +166,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, @@ -248,6 +251,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, @@ -395,6 +403,64 @@ 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 has visibly begun. + /// + /// A reveal starts only for a reply first seen while it streams, so + /// settled history never animates; 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 + .reveal_shows(&reply.id) + .unwrap_or(!reply.body.is_empty()) + } + /// Hands every unresolved rich-link destination to the surface. /// /// The row renders after the surface's render tail has flushed, so the @@ -418,6 +484,9 @@ 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); // 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()); @@ -439,6 +508,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..086b818c7 --- /dev/null +++ b/modules/frontend/src/conversation_surface/work_motion.rs @@ -0,0 +1,568 @@ +//! 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::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; + +use gpui::Pixels; + +use super::reply_reveal::ReplyReveal; +use super::*; + +/// Row entrance: `--duration-very-slow` (texts reveal). +const ENTRANCE: Duration = Duration::from_millis(500); +/// Row entrance rise: `--distance-medium` (texts reveal). +const ENTRANCE_RISE_PX: f32 = 12.0; +/// Row entrance blur: `--blur-medium` (texts reveal). +const ENTRANCE_BLUR_PX: f32 = 3.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; +/// 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, + /// When each work row first painted while the turn was live. + entrances: 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, + /// 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. + pub(super) fn begin_frame(&mut self, live: bool, motion: MotionPolicy) { + 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. A reveal starts only for prose first + /// seen while it streams and ends once its last segment has faded in; + /// 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.reveals.contains_key(id) && streaming { + self.reveals + .insert(id.clone(), ReplyReveal::begin(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())) + } + + /// Whether the reveal of `id`, if any, shows at least one segment. + pub(super) fn reveal_shows(&self, id: &SceneId) -> Option { + self.reveals + .get(id) + .map(|reveal| !reveal.revealed().is_empty()) + } + + /// 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 progress of one work row, `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 = Instant::now().saturating_duration_since(started?); + if elapsed >= ENTRANCE || self.pace == Pace::Settled { + return None; + } + self.animating = true; + Some(eased(MotionCurve::SmoothOut, elapsed, ENTRANCE)) + } + + /// The words of a changing phrase with their motion this frame. + /// + /// Returns `None` when the phrase paints as plain text: outside a live + /// turn, under reduced motion, and once every word is at rest. + 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); + } + let elapsed = now.saturating_duration_since(state.changed?); + let longest = ROLL + ROLL_STAGGER * 4; + if !animate || elapsed >= longest { + state.changed = None; + return None; + } + self.animating = true; + Some( + state + .current + .iter() + .enumerate() + .map(|(index, word)| PhraseWord { + text: word.clone(), + motion: word_motion(state.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 +} + +/// Wraps a work row in its entrance: fading in while it rises into place. +/// Relative offset only, so layout never moves. +pub(super) fn with_entrance(element: AnyElement, progress: Option) -> AnyElement { + match progress { + Some(progress) => div() + .w_full() + .min_w_0() + .relative() + .top(px(ENTRANCE_RISE_PX * (1.0 - progress))) + .opacity(progress) + .filter(vec![Filter::Blur(px(ENTRANCE_BLUR_PX * (1.0 - progress)))]) + .child(element) + .into_any_element(), + None => element, + } +} + +/// Paints a changing phrase word by word on one line of `line_height`. +pub(super) fn phrase_element(words: &[PhraseWord], line_height: Pixels) -> AnyElement { + let mut row = div() + .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 { + 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 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_phrase_is_still_at_first_paint_and_moves_on_change() { + let mut motion = WorkMotion::default(); + motion.begin_frame(true, MotionPolicy::Full); + assert!(motion.phrase("chain", "Ran a command").is_none()); + motion.end_frame(); + motion.begin_frame(true, MotionPolicy::Full); + 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); + assert!(motion.entrance("old-row").is_none()); + motion.end_frame(); + motion.begin_frame(true, MotionPolicy::Full); + 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); + assert!(motion.entrance("later-row").is_none()); + } +} From ee81080edcb05b800e86829504f29b61e68fa029 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 15:52:35 +0200 Subject: [PATCH 10/40] sidebar: show unread threads and fold the rest into Working and History The sidebar reads as an inbox: only threads with something unseen (a finished or failed run, an open question or approval) show outright. Running threads collapse into a Working group and read idle ones into History, which keeps its age groups inside. Group headers use the age label style with a count and a chevron; opening one flips the chevron and fades its rows in. An unread thread stays at the top while open. Co-Authored-By: Claude Opus 5.5 --- .../impl_sidebar_threads.rs | 251 +++++++++++++++++- .../tests/render_boundaries.rs | 2 + modules/frontend/src/recent_thread_groups.rs | 136 +++++++++- 3 files changed, 372 insertions(+), 17 deletions(-) diff --git a/modules/frontend/src/native_application/impl_sidebar_threads.rs b/modules/frontend/src/native_application/impl_sidebar_threads.rs index ef4f08528..14fe82309 100644 --- a/modules/frontend/src/native_application/impl_sidebar_threads.rs +++ b/modules/frontend/src/native_application/impl_sidebar_threads.rs @@ -1,14 +1,16 @@ -//! 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, next_regrouping, section_recent_threads}; use artisan_domain::{ RecentThread, RecentThreadListing, ThreadAttention, ThreadSummary, UnixMillis, }; @@ -28,6 +30,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)] @@ -104,6 +161,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. @@ -286,7 +345,7 @@ impl NativeApplication { .top_0() .left_0() .size_full(); - let mut container = div() + let container = div() .id("artisan-sidebar-threads") .relative() .flex_1() @@ -313,12 +372,186 @@ 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, &listing, now, window, cx) + } + + /// 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
, + listing: &RecentThreadListing, + now: UnixMillis, + window: &mut Window, + cx: &mut Context, + ) -> Stateful
{ + let sections = section_recent_threads(listing, now); + 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<'_>, diff --git a/modules/frontend/src/native_application/tests/render_boundaries.rs b/modules/frontend/src/native_application/tests/render_boundaries.rs index 455ae6b40..f13f812e9 100644 --- a/modules/frontend/src/native_application/tests/render_boundaries.rs +++ b/modules/frontend/src/native_application/tests/render_boundaries.rs @@ -121,6 +121,8 @@ 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, )); 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); + } +} From b21660c59eb5833db8127920357d38254064de9c Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 15:52:35 +0200 Subject: [PATCH 11/40] inspector: close the context section with a grid rule A one-device-pixel rule runs edge to edge under the Context rows, the column inset below them, with the shell's crosshair where it meets the inspector's left rule, so the column reads as a grid. Co-Authored-By: Claude Opus 5.5 --- modules/frontend/src/thread_screen.rs | 41 +++++++++++++++++++- modules/frontend/src/thread_screen/render.rs | 40 ++++++++++++++++++- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/modules/frontend/src/thread_screen.rs b/modules/frontend/src/thread_screen.rs index aa972ad2c..d7f45e622 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,8 @@ 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"; /// Stable debug selector for the composer dock. pub const THREAD_SCREEN_COMPOSER_SELECTOR: &str = "artisan-thread-screen-composer-dock"; @@ -1076,6 +1079,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. diff --git a/modules/frontend/src/thread_screen/render.rs b/modules/frontend/src/thread_screen/render.rs index 7eb7745c7..d3634d9b1 100644 --- a/modules/frontend/src/thread_screen/render.rs +++ b/modules/frontend/src/thread_screen/render.rs @@ -489,14 +489,20 @@ 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 the Context section across the column's full width, so + /// the inspector reads as a grid with the titlebar rule above it; its + /// 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)); + .gap(section_gap) + .child(self.render_context_section(theme)) + .child(Self::render_context_rule(width, rule, section_gap)); if let Some(agents) = self.render_agents_section(theme) { sections = sections.child(agents); } @@ -529,6 +535,36 @@ impl ThreadScreen { .child(sections) } + /// The full-width rule under the Context 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_context_rule(width: f32, rule: Pixels, section_gap: Pixels) -> 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(|| THREAD_SCREEN_CONTEXT_RULE_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 From 06be98b2c84ceca5e865c897ef74fd0a4816dbeb Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 15:52:35 +0200 Subject: [PATCH 12/40] deps: pin gpui with paint-time element transforms Moves the gpui-ce pin to 8d8c0734, which adds `Styled::transform`: translate and uniform scale about an origin, applied at paint and hit testing without touching layout. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 34 +++++++++++++++++----------------- Cargo.toml | 4 ++-- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 67845efd4..547fc862a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3127,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", @@ -3201,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", @@ -3225,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", @@ -3235,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", @@ -3245,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", @@ -3293,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", @@ -3339,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", @@ -3350,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", @@ -3364,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", @@ -3377,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", ] @@ -3385,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", @@ -3401,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", @@ -3411,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", @@ -3422,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", @@ -3432,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", @@ -3456,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", @@ -3482,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 db7598c29..bb6ab1465 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,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"] } From aaee7c02cde6ca952e30ac73d453a61016cb7c87 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 15:52:35 +0200 Subject: [PATCH 13/40] profile: size the menu to its selector and pop it in like a dropdown The profile menu takes its selector's exact width and follows the transitions-dev dropdown: it grows from 97% about its bottom-left corner while fading in over 250 ms, and settles to 99% while fading out over 150 ms. The picker menu animation gains an entrance and separate open and close durations; the model picker keeps its 100 ms slide. Co-Authored-By: Claude Opus 5.5 --- modules/frontend/src/native_application.rs | 18 ++--- .../native_application/impl_profile_menu.rs | 27 +++++-- .../src/native_application/selectors.rs | 8 ++ modules/frontend/src/native_model_selector.rs | 3 +- .../src/native_model_selector/render.rs | 4 +- .../src/native_model_selector/render/menu.rs | 79 +++++++++++++++++-- 6 files changed, 112 insertions(+), 27 deletions(-) diff --git a/modules/frontend/src/native_application.rs b/modules/frontend/src/native_application.rs index 307facc99..90b8987ea 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, @@ -240,12 +240,12 @@ pub use app_entry::run; use selectors::{ 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_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, + 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, }; #[cfg(test)] use selectors::{ 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/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_model_selector.rs b/modules/frontend/src/native_model_selector.rs index b0eb01314..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; 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 527c0ce32..2ce017692 100644 --- a/modules/frontend/src/native_model_selector/render/menu.rs +++ b/modules/frontend/src/native_model_selector/render/menu.rs @@ -373,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() @@ -391,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() From f725d31e15ef1039bb665b6f61888979f60ed383 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 16:47:25 +0200 Subject: [PATCH 14/40] ui: use Founders Grotesk Bold for editor header --- modules/assets/fonts/FONTS.md | 12 ++++- .../fonts/test-founders-grotesk-bold.otf | Bin 0 -> 13928 bytes .../assets/licenses/founders-grotesk-test.txt | 3 ++ modules/assets/src/fonts.rs | 43 ++++++++++-------- .../src/native_application/impl_surfaces.rs | 8 ++-- 5 files changed, 42 insertions(+), 24 deletions(-) create mode 100644 modules/assets/fonts/test-founders-grotesk-bold.otf create mode 100644 modules/assets/licenses/founders-grotesk-test.txt 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 0000000000000000000000000000000000000000..12d6fa5d6f31993070d3f8db990a2f9fd873f50d GIT binary patch literal 13928 zcmc&*34D}AvhSHmLnkB?l3{=V!f>C45FmkYh5H6ML|_3)U?2f#M4|s=BJW zzMd~5$BrE-BE@3i5UG6!4Dfte``C0L!uttPcYMDwg9i#B>_Q|>MX3hfGIGp;`P(K5 z(RiH@uKNd$xwX&kpLSmqqH`;Zw;4I6ePY_N^dE$T=y9g6QgLQ{!CPoJ9VWOVM57wn&+%xmu0)ftRl$0=s2BEPuwFwn3p*C9 zM^>swiM?Uxg8fd>MQ-bxlRG~zb7n??Cov(hlV|9h%q-8?`MF-tfSkG6)AQ!XdwS2A z;~8xr@;#%y`QE%j@AUYw-u!|9gf}nWGcYfwz?(n2PtKg_iSY>uow}I??Pd|caB%cy zGu+J}UK814hQ|RsGbh^hz4`H1X>VvAW*?JN1i8}ZXA~6V=BKo8KN~uZpOzD! zy&cnnDaZiijLbP+!*BlF^jY3%1)hQ&^n1Z|elD7sh2Cp$HXtXvz%wj! znm2oz*VB7up4Xe@%`Rw_-}>t5+Rq7q<+q2w=7?M|U*w5QF;irS0(e-WNWk3*wV`4T z`m<0Q3z%HtMQs2u=89}F9XRtvJbHQqY7WZL~W}uvhJAj>JL4fY%VtwMS^$xS?v(FMw7;-%cz_(i}owv-O5GYx-XxISDD3oMm@{5<#kwu<`_b#c{+IwR()@n^*Jy|P5Bc|5 zcoAp?u4K^#doGfLK+}K9I+|kNa4H5?rZ3n5b1a+ zGE5KB*$u*$|1?_i;ON&I)NLHglVU`e%@)>Ubb4ZZr;07B)wI^wwi1#(>GM5RLTzBz zvi7xn6FvjGR)~r6t-~?!M z`vHgD(a4eJSnqfSyN!=R46oqVK8csOhb_zvj7^eld5)?27qEY-Hg7JQpO?2|sD*TvwRfIdU&u~&`C^J}ba{}2S6+^A>y;qEy-4n5 zr{w!lYLtUXYK+R}`Zo7UlE?K3sauhB?jml$;q2n-Tt9Xncjd|4PbY9+*G1ot+}llm z8@<2x2*kFpeJQr^VcsFLGPkQ{0#LaI)hme&0=c95>Q& zJd70IN*CB!!X1=Oen>9q?;X;bR6O1K0bNpb>CgvfNc|`M$B?!C6(_Hj#oVD8N9$Wb zd>Cnv043!FfA#ohu4miFJ$V`Tb#OMMuPxychKUT9uA-(q?-nmNcuLetLt(fQqJ$e`wH;ByvWk%N)EBVLNW}D zG|HYRVRPD)8t!EJ8Kr4M2TuLz`ohZ*76HdTn6BvzAvtp(qHkG|?O_Nw#8AIvAj)b> z>Ul@T2vWl-`{m`IE8S(Od_Z?U8_zIHZbLsFC+?xh*IYVZt)PCn5xMolSZrR->!rR8 z-cYC?1~(6vb7M`+W8~{cB=^-TrE@0M%eSa1cfLX+f=B9OD1D2hlV_|i^o`_^P(p$) z8f^Mzkej7%q%^{ojVzZ(uO*pS=8^Z3jQ4@S69FA?f+1u1Lso90vo6 zWAx%?002(uzOX;fE&bz-cir-#^py*j-+BLh zOQw%2%XHL|X%A%bavhGhWa`HAImO}gle=)^y<6`7R^9Wlcju5t)rqx-kI-pI5S?;D zCqsgdf)aE?$zI8lMH)sTm$Ke|Bag?t8jiI*IOcuOzj(s)IqX3p;m=Xt!C z>-p+%9Q-yxH|B0oWP-k#Q(WIX#rcSQ?bz7`AzUG_vMNR7QgmVD2fFS?0A!40FsXdbCpe%#Qe^AU# zeQ^6%U;vAZWB#Ss4d>&SPl<=Y^J)ei{-UI|+2cEs>O@%w=zHkyqSRP$Dys>C!?QRR0vicDA z_r?dHO_a-g^(7opIv<2`*=!xp`nEY33+Luz{1brc%Ce_|D#tFX-_idB1-JBVGZZ}1 zIdsW?HD$r~aAfS0oWQlXqiZ+6&LiA3YDVT{Qn@t$aoVV8>&B87A6D;`NbdMI7-G#% z2Xq7sqaO7ccvowL$|drl9Zzn2fz*q%Zmuzkd!&vjlx-FapFy1!^*TJ|6seEsz~*OO zQ;&~-Ip=j!duaQk8=p|FdGxhhv~0hY2jR{n490DmT!`n!7L`xFeuRb6ackw` zvAMaENKK^79rU@P&!5||pVVI3e8(eW700cSi!Y^~f=-lfOwDd4-B>vbA&G`Vb51}O z(C5LN+T^qN* zMrt2DleuD=a_V&mcc(b)b5^#uxs&5(&!1xVH?f30Cyb-CjeCB} z-P>?$*Pg{bFTd%g^~?Tn_d2y<)ut7HAS|p?PNa#mb7v^;#_^Auq%DRzjG}gmCT{Y+ zbcb?%mioK-IkRZ0BJZY|FD+88FH$Eg&zM8g6(zGhk6jSCIdT01#17@!n@XRIKiC3f zhLg;t#U=E(qBRdbw0f1=diVC_uy*jlvGn&h>07Kss{Tq}bkn+$wNHW!BEx2S>>iq@ zD0l3_R8k{p${N~kp>$!uO)JXFH4awvG`*zg#iyQzZZ^|Xd0@tw4#oZj-k544V-h#z zBnON?$4#XhUV-uNr**Sw1eR%uv5ZsX{7TRq&X~Yc01H?qB>WsSEV(UXvRd>OQ)f{OuNVJk=7{h}ziw z;!Bci>A{kRAa0H3O_GOkU)0dLS;vF7zh5n&$^W1)$15hgGrk@49w#ri6sol1T2?%X zgI4QWemKup%{}k6^cT7t-Tlt!&V`&eKs%%t&Ox3_rGI$kpwWFvRo&-EDt+>mLuTZW zsv~AqW`3okP2?Hp&7Tlj*9tbUu&xs~SXtNZE7sO^;tKwp!zzU@2$t;Np(civZrXF0 z)Eo5lWE!MsP~PM*q()Qz9x7G*sd+nS*y^lq4)bn7t=o`p2!m?$pKTci4=4EHdt_J$ z#kx@;x8fKCuxh#*$CgLqFRl@dtHxp^i0%!yy*l#~(5rKuSnN7WCxW`hdyO&F!Yd~X zh0HH*7&4L2TLBY9eHSdMSjwJP#muWBroIi~xGxO?%M{DvQplCrq^NZwnA9X zVboDk!r=@S3)Fi{ zO+K1~1vpACKK|U(%C(a&%G||smlu(mWw!SJK%_{Ptc!4+7hz{N!FuQM-?_+51B-4SZ&+EdgN}gY-2q+c=OnB* zerGZxGL_Q)t(#so`~9HuXk+4rvp-(4bSo2`V(;^9aOLu4WNs;lMaVm)YiAq%tjWjP z@Uww@ECpWa#H%OYs#60DOvz90PpN)1tHsgI7IQSGSrZc_i8MA|0wl9km;;*r0%PWB%}Wuj*{PKyiH?7 z_hV$T!(q3+3q?SG*1=nJCpWJ#Mu>S^VBZEmtxVd1w^bfeHeL=z2yUb8qN;ROVQlrF( zZuIFE>3&5$Bs(5Fx{rRqI&}^r-N_@kv272pckDs# z$LkHj#!{nnZZ77Vd^Pzd+a?~wGkKuH_jl_}(2E^80xw7k(`t!qPZVr9|FBE>1o$Y)-qC$mfnFmm>+wmp^Zqi=$iozk~3 zpa$ner~sRBIM?T!VmEN|bKG|nM&w^hLRoJ~ZY zZr-~7@g8vmj=ku_(?D|y5w9DP9t*`x`cBv6{?OG0WUd!Z z;w%yxj_mp+#?=VAZEp5VsDAb7Kfd!JV7}|1kv+b;eFygKH)#7T!in7PX3xR~c`MC+ zj`k{NH`rqyx6}2wUMx4_5v~vTm@m(b*SSVhb-jCan&J^A+C0Fwt-^R$PBNPW*OO&) zjh@>{=WK*aoJEG?j3XMkcu(DyQ@9IQ?m;2UNM=nH`2?B zc5i)pkCB7)YWEwo;|`f9@H)tCQa5hK+su5wTSC*`&h*oNQai4xE#>Q` z)T}FMC|^gOOvX6&p4T>PeE~XO+CF`lfw}lMle$lsJqsE77@EC_Z=oUp!gu!{&= zPqaE&7;gO9vkJfHEW&f6*lGQ}EcT1nZSmr$C>3YL&o*Uq+FGG)zC3V<>LNzOiaH`r z+$7qFw)jP`qe#Z@DJl3>u!ra=df|tI-uQjdA?o@e!2A+D z5;9HP?{qpYu2f@o<>OTSc=dJP*o~Qh;`Oo5c z7JsKhP&Y39Z-YPU0<}x1fLE(*2-$>xC)QG6Rib~31pdSRT>+Y)aM*twT%E@}rB>^d ze=k}AK7%udV1K#vp9J0|>kpiQDzHK8tc4W;?$1IanB!l)jNJcQ*Z(H%e<#ns#e!}$ zZ*Yag|AFk?Z^X+ljr+F;Mk{Rhm!N;!*uPWueXYB7uykgPWm?+ZcMDh^F&ai5mwAP9g49S zIb2b%GJT~6b~uR}l44J+*f}Eb)dDV1Hf4m-YG4*{9)THuMcfL&n5W?j&$B_SXsZvq zRRtSC2`W0SS+iE;lK*gEurmJD;5U{W99?7Ys-Sx9^}ewkU{!QbSLe-+Y-aJl7UVD* zTDXM$qtb3vxqh`Ap&tJVU>*10YuaW8vvUTu7H3NI9S^Oae*@|_xU4ld>u&5^VtoT^ zZ+2fZ!)dDwth=@A0X_or-@pfqpIG>Y!{7z(0<;5j7@9#l!^jQ2)kJhlqx$OoHaM3x z6K1yNwzLh8xHhMO)q@ZHzMQ*t$aGFYw;PO4;V$*B!C6Jc@(8vc13q{xva-|Xyt_U?D^Dang zqRBdFU@s)xj5UUw_^X`#!RJC=7s9=|thC~WA6d>tEi+;`tH7yTujsvAt(>y7v}~*g zF$90-D|@it`nLLdG_LBuZk}u?hoJqJ!FjoML%o$ttU`o;GoViS*IAZ*jqDW^&B_Hl zqZC~44Oo(Y1Mp0AfUYa%tV|vCps%1-#aBY~0?>wbWnElB6ReLH{)T)^%QWf8P`S$O=ZvO2AVdp_RG%?cBI{3V<_J8{Xl`yVYqjIaN1akdq z@Ebn>RMBI=mO^OOy8ohm0$aknFCk*UN@1&J&%^4&sx7+4OHI5(Utq^Kwi%oeUVj?c z8d1X9DX*m^zc0XxrPY9^AFjw5LVOyE*TOCzzg{I|rvY9od1ioKd5pOp{x$kT>mQQx z8X*6VT_G)*hyfXmU!S}-r{!|oklT=!urruwV^+L;jjJO62Abv8T#j?yh+1`yDlI$C zJn&x`=&K>3aDGx#G=#Js(G+I^%|#2G?X<$#Nh6$m*l~^$3pf`>YN746=+wpuP+hAQ z2Z%VFMb#G#faL(4FyMJaW1NsQ0Yo#4UTf4^T3^k!#<#s~@E3vaZg0kyxb2bgBw+py zIN@oFzos}}3Kvd`t7`aWw+1{!1^KXH&T!o3FHqWptw7uKMSusKCIPEHWULMR){xKq zMS*H-@K6WO)|fRKeK+Bb27Pm;RSz;YfQ)W%Qxh{c122uCi-zFb1DTpz^4K6r1bSOl zIg6`nPKL0)=8NL;%f1lb4gV1zB%6D_NU%RCYS~w!?2fxR?)_-LAQEg5sCUHw$SgtG z7XSOw808w=FJpXEu)GW96to`{aTsf7FTv-?%R%>P-1p%gEgIURMXbG>Xovs7sbx>W zZ4!?`L|xl7QNtcCT=s^-X&(k2VuahCh = 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/frontend/src/native_application/impl_surfaces.rs b/modules/frontend/src/native_application/impl_surfaces.rs index f46e53d71..3fd2f332b 100644 --- a/modules/frontend/src/native_application/impl_surfaces.rs +++ b/modules/frontend/src/native_application/impl_surfaces.rs @@ -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"), ); From 429f43a5c8516dcb9c2e4644555886190518d8fc Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 16:50:10 +0200 Subject: [PATCH 15/40] conversation: keep counting labels at a steady width Two things made counting labels resize horizontally. Spline Sans digits are proportional ("1" is a third narrower than "8"), so every tick changed the width; the header, chain clauses, the status line, and the odometer now use tabular figures (`tnum`, one 1156-unit advance for all digits). And a live phrase dropped back to plain text between changes, so the timer swapped two layouts that measure differently every second; it now keeps its word-by-word layout for as long as the turn is live. Co-Authored-By: Claude Opus 5.5 --- .../src/conversation_surface/render_blocks.rs | 7 ++- .../render_work_group_header.rs | 3 ++ .../src/conversation_surface/work_motion.rs | 53 +++++++++++++++---- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/modules/frontend/src/conversation_surface/render_blocks.rs b/modules/frontend/src/conversation_surface/render_blocks.rs index 054dd9a6f..b3775216b 100644 --- a/modules/frontend/src/conversation_surface/render_blocks.rs +++ b/modules/frontend/src/conversation_surface/render_blocks.rs @@ -6,7 +6,7 @@ //! builders read only the row's synced inputs and reach the surface through //! its weak handle. -use super::work_motion::{phrase_element, with_entrance}; +use super::work_motion::{phrase_element, tabular_figures, with_entrance}; use super::*; #[path = "render_work_group_header.rs"] @@ -85,9 +85,12 @@ impl TurnRowView { let entrance = motion.entrance("status"); drop(motion); 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() { @@ -112,6 +115,7 @@ impl TurnRowView { .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() @@ -863,6 +867,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( 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 3f704c50d..815528cb5 100644 --- a/modules/frontend/src/conversation_surface/render_work_group_header.rs +++ b/modules/frontend/src/conversation_surface/render_work_group_header.rs @@ -91,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/work_motion.rs b/modules/frontend/src/conversation_surface/work_motion.rs index 086b818c7..a723dc99a 100644 --- a/modules/frontend/src/conversation_surface/work_motion.rs +++ b/modules/frontend/src/conversation_surface/work_motion.rs @@ -21,9 +21,10 @@ //! nothing below it jumps. use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use std::time::{Duration, Instant}; -use gpui::Pixels; +use gpui::{FontFeatures, Pixels}; use super::reply_reveal::ReplyReveal; use super::*; @@ -215,8 +216,11 @@ impl WorkMotion { /// The words of a changing phrase with their motion this frame. /// - /// Returns `None` when the phrase paints as plain text: outside a live - /// turn, under reduced motion, and once every word is at rest. + /// 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(); @@ -233,13 +237,25 @@ impl WorkMotion { state.previous = std::mem::replace(&mut state.current, words); state.changed = animate.then_some(now); } - let elapsed = now.saturating_duration_since(state.changed?); - let longest = ROLL + ROLL_STAGGER * 4; - if !animate || elapsed >= longest { + if !animate { state.changed = None; return None; } - self.animating = true; + 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 @@ -247,7 +263,7 @@ impl WorkMotion { .enumerate() .map(|(index, word)| PhraseWord { text: word.clone(), - motion: word_motion(state.previous.get(index).map(String::as_str), word), + motion: word_motion(previous.get(index).map(String::as_str), word), elapsed, }) .collect(), @@ -362,9 +378,18 @@ pub(super) fn with_entrance(element: AnyElement, progress: Option) -> AnyEl } } -/// Paints a changing phrase word by word on one line of `line_height`. +/// 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() @@ -540,12 +565,20 @@ mod tests { } #[test] - fn a_phrase_is_still_at_first_paint_and_moves_on_change() { + fn a_live_phrase_keeps_one_layout_and_moves_on_change() { let mut motion = WorkMotion::default(); motion.begin_frame(true, MotionPolicy::Full); + // 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); + // 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); 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, .. })); From 2328682aec1f4310ab1cfc4dbc5dcbdd58640139 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 17:00:30 +0200 Subject: [PATCH 16/40] conversation: finish work prose once anything follows it Prose in a turn's work counted as streaming for as long as its message was live, and a message can stay live until its turn ends, so the reveal held back the last word of every finished sentence and the work read cut off. Prose followed by another row, or by the reply, is now finished and reveals to its end. Co-Authored-By: Claude Opus 5.5 --- modules/frontend/src/conversation_surface.rs | 9 +++ .../src/conversation_surface/render_blocks.rs | 7 ++- .../conversation_surface/tests/work_groups.rs | 59 +++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/modules/frontend/src/conversation_surface.rs b/modules/frontend/src/conversation_surface.rs index 67ed0049d..6ee243994 100644 --- a/modules/frontend/src/conversation_surface.rs +++ b/modules/frontend/src/conversation_surface.rs @@ -862,6 +862,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 } } diff --git a/modules/frontend/src/conversation_surface/render_blocks.rs b/modules/frontend/src/conversation_surface/render_blocks.rs index b3775216b..df85cbfd2 100644 --- a/modules/frontend/src/conversation_surface/render_blocks.rs +++ b/modules/frontend/src/conversation_surface/render_blocks.rs @@ -583,8 +583,13 @@ impl TurnRowView { child_identities.push((None, None)); chain_index += 1; } else { + // 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( - rows[row_index].1, + detail, rows[row_index].0, selector, theme, 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" + ); +} From f7a34122814c5bd0f5352d58aa835f7ce009e833 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 17:00:30 +0200 Subject: [PATCH 17/40] conversation: sweep segments in and stream prose that lands whole A revealed segment now splits into short runs whose fades start a frame apart, so it sweeps in left to right and settles instead of spawning; the sweep stays within 160 ms, far inside the fade, so it never reads as letters streaming. Prose that appears while its row is on screen streams in even when it lands whole, as a reply does when the turn ends, and a backlog drains in capped segments instead of appearing at once. A lone `*`, `_`, or `<` followed by a space no longer holds back the rest of its paragraph. Co-Authored-By: Claude Opus 5.5 --- .../src/conversation_surface/reply_reveal.rs | 149 ++++++++++++------ .../src/conversation_surface/work_motion.rs | 26 ++- modules/ui/src/markdown_reveal.rs | 13 +- modules/ui/src/markdown_reveal_tests.rs | 6 + 4 files changed, 139 insertions(+), 55 deletions(-) diff --git a/modules/frontend/src/conversation_surface/reply_reveal.rs b/modules/frontend/src/conversation_surface/reply_reveal.rs index b351d7dff..42afb9ea3 100644 --- a/modules/frontend/src/conversation_surface/reply_reveal.rs +++ b/modules/frontend/src/conversation_surface/reply_reveal.rs @@ -6,10 +6,14 @@ //! whole fences and tables. //! //! The prefix advances in segments: at most every [`SEGMENT_INTERVAL`], -//! everything that became stable since the last segment is revealed at -//! once and fades in as one piece, one opacity across the whole segment. -//! Revealing word by word with long overlapping fades read as a -//! left-to-right wipe across letters; a segment instead appears together. +//! what became stable since the last segment is revealed together. A +//! segment fades in mostly as one piece, but its short runs start a frame +//! apart from left to right, so it sweeps into place instead of spawning; +//! the sweep is capped at [`SWEEP_SPAN`], well inside the fade, so it never +//! reads as letters streaming. A large backlog (a reply arriving in one +//! piece, or its remainder when the turn ends) is drained in capped +//! segments instead of appearing at once. +//! //! 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. @@ -19,18 +23,27 @@ use std::time::{Duration, Instant}; use artisan_ui::markdown_reveal::{RevealFade, RevealSteps, stable_reveal_end}; use artisan_ui::motion::{MotionCurve, MotionDuration, MotionPolicy}; -/// One segment's fade-in (`--duration-medium`): long enough to read as a -/// fade, short enough that at most a couple of segments are mid-fade. +/// One run's fade-in (`--duration-medium`): long enough to read as a fade, +/// short enough that at most a couple of segments are mid-fade. const UNIT_FADE: Duration = MotionDuration::Medium.as_duration(); +/// Visible characters per run of a segment's sweep. +const SWEEP_RUN: usize = 6; + +/// Delay between neighbouring runs: one display frame. +const SWEEP_STAGGER: Duration = Duration::from_millis(16); + +/// The longest a segment's sweep may take from its first run to its last. +const SWEEP_SPAN: Duration = Duration::from_millis(160); + +/// The fewest words one segment reveals while draining a backlog; larger +/// backlogs drain in about six segments. +const DRAIN_MIN_WORDS: usize = 12; + /// 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(); -/// A reply first seen with more words pending than this shows them at once: -/// replaying text that already arrived would read as a replay, not a stream. -const SNAP_BACKLOG_WORDS: usize = 96; - /// 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. @@ -40,8 +53,11 @@ pub(super) struct ReplyReveal { /// 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 segment, by visible offset, oldest first. + /// 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 fade resolved by the latest [`Self::advance`]. @@ -49,25 +65,29 @@ pub(super) struct ReplyReveal { } impl ReplyReveal { - /// Starts revealing prose first seen while it streams. - pub(super) fn begin(body: &str, now: Instant) -> Self { - let stable = &body[..stable_reveal_end(body)]; - let backlog = RevealSteps::new(stable).pending_words(0); - let snap = backlog > SNAP_BACKLOG_WORDS; + /// Starts revealing prose that arrived while its row was on screen. + pub(super) fn begin(now: Instant) -> Self { Self { - revealed: if snap { - stable.to_owned() - } else { - String::new() - }, + revealed: String::new(), visible_len: 0, - resync: snap, + resync: false, marks: Vec::new(), + pending: None, next_tick: 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 @@ -95,6 +115,7 @@ impl ReplyReveal { // 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(); @@ -102,28 +123,50 @@ impl ReplyReveal { match motion { MotionPolicy::Reduced => body[..stable].clone_into(&mut self.revealed), MotionPolicy::Full if now >= self.next_tick => { - self.revealed.push_str(&body[start..stable]); + // A normal stream reveals everything stable; a backlog + // drains in capped segments rather than all at once. + let steps = RevealSteps::new(&body[..stable]); + let cap = DRAIN_MIN_WORDS.max(steps.pending_words(start).div_ceil(6)); + let mut end = start; + for _ in 0..cap { + end = steps.next(end); + if end >= stable { + break; + } + } + self.revealed.push_str(&body[start..end]); self.next_tick = now + SEGMENT_INTERVAL; } MotionPolicy::Full => {} } } if self.revealed.len() > start && !self.resync && motion == MotionPolicy::Full { - self.marks.push((self.visible_len, now)); + self.pending = Some((self.visible_len, now)); } self.fade = self.resolve_fade(now); - self.revealed.len() < stable || !self.marks.is_empty() + self.revealed.len() < stable || !self.marks.is_empty() || self.pending.is_some() } - /// Records the visible length the renderer reported for `revealed`. + /// 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_STAGGER.min(SWEEP_SPAN / u32::try_from(runs).unwrap_or(u32::MAX)); + for run in 0..runs { + let delay = stagger * u32::try_from(run).unwrap_or(u32::MAX); + self.marks.push((start + run * SWEEP_RUN, began + 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.revealed.len() == body.len() && self.marks.is_empty() && self.pending.is_none() } /// Resolves each segment's opacity and drops the fully opaque oldest @@ -136,7 +179,7 @@ impl ReplyReveal { 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 = MotionCurve::EaseInOut.sample(progress) as f32; + let eased = MotionCurve::SmoothOut.sample(progress) as f32; eased }; let opaque = self @@ -145,10 +188,13 @@ impl ReplyReveal { .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(), ) } @@ -159,19 +205,20 @@ mod tests { use super::*; #[test] - fn reveals_each_stable_stretch_as_one_segment_with_one_opacity() { + fn a_segment_sweeps_in_left_to_right_and_settles() { let start = Instant::now(); - let mut reveal = ReplyReveal::begin("Hello brave new", start); - assert_eq!(reveal.revealed(), ""); + let mut reveal = ReplyReveal::begin(start); assert!(reveal.advance("Hello brave new", true, MotionPolicy::Full, start)); // Every complete word shows at once; the trailing `new` may grow. assert_eq!(reveal.revealed(), "Hello brave "); + // Revealed this frame: transparent until its runs are known. + assert!(reveal.fade.alpha_at(0) <= f32::EPSILON); reveal.rendered(11); - let mid = start + UNIT_FADE / 2; + let mid = start + UNIT_FADE / 3; reveal.advance("Hello brave new", true, MotionPolicy::Full, mid); - // One segment, one opacity: the first and last letters match. - assert!(reveal.fade.alpha_at(0) < 1.0); - assert!((reveal.fade.alpha_at(0) - reveal.fade.alpha_at(10)).abs() < f32::EPSILON); + let (left, right) = (reveal.fade.alpha_at(0), reveal.fade.alpha_at(10)); + assert!(right < left, "the left run leads the right one"); + assert!(left < 1.0 && right > 0.0, "one segment, fading together"); // Text stable within the interval waits for the next segment. let soon = start + SEGMENT_INTERVAL / 2; @@ -182,15 +229,34 @@ mod tests { assert_eq!(reveal.revealed(), "Hello brave new world "); reveal.rendered(21); - let done = next + UNIT_FADE * 2; + let done = next + SWEEP_SPAN + UNIT_FADE * 2; assert!(!reveal.advance("Hello brave new world ", false, MotionPolicy::Full, done)); assert!(reveal.is_complete("Hello brave new world ")); } + #[test] + fn a_backlog_drains_in_capped_segments_instead_of_snapping() { + // A reply that arrives whole, as at the end of a turn. + let body = "word ".repeat(120); + let start = Instant::now(); + let mut reveal = ReplyReveal::begin(start); + reveal.advance(&body, false, MotionPolicy::Full, start); + let first = reveal.revealed().split_whitespace().count(); + assert_eq!(first, 20, "a sixth of the backlog per segment"); + reveal.rendered(first * 4); + let mut at = start; + for _ in 0..12 { + at += SEGMENT_INTERVAL; + reveal.advance(&body, false, MotionPolicy::Full, at); + reveal.rendered(reveal.revealed().len()); + } + assert_eq!(reveal.revealed(), body, "the whole backlog drains"); + } + #[test] fn reduced_motion_shows_the_stable_prefix_without_fading() { let start = Instant::now(); - let mut reveal = ReplyReveal::begin("", start); + 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); @@ -200,17 +266,10 @@ mod tests { #[test] fn a_replaced_body_snaps_without_replaying() { let start = Instant::now(); - let mut reveal = ReplyReveal::begin("", start); + 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 "); } - - #[test] - fn a_long_reply_first_seen_mid_stream_shows_at_once() { - let body = "word ".repeat(SNAP_BACKLOG_WORDS + 1); - let reveal = ReplyReveal::begin(&body, Instant::now()); - assert_eq!(reveal.revealed(), body); - } } diff --git a/modules/frontend/src/conversation_surface/work_motion.rs b/modules/frontend/src/conversation_surface/work_motion.rs index a723dc99a..f1c89397d 100644 --- a/modules/frontend/src/conversation_surface/work_motion.rs +++ b/modules/frontend/src/conversation_surface/work_motion.rs @@ -55,8 +55,12 @@ pub(super) struct WorkMotion { 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, @@ -154,10 +158,14 @@ impl WorkMotion { /// 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. A reveal starts only for prose first - /// seen while it streams and ends once its last segment has faded in; - /// the same id keeps its reveal when the scene moves the prose between - /// the reply slot and the work. + /// `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, @@ -170,9 +178,13 @@ impl WorkMotion { MotionPolicy::Full }; let now = Instant::now(); - if !self.reveals.contains_key(id) && streaming { - self.reveals - .insert(id.clone(), ReplyReveal::begin(body, 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()); diff --git a/modules/ui/src/markdown_reveal.rs b/modules/ui/src/markdown_reveal.rs index 119fdbebc..b7a3dff70 100644 --- a/modules/ui/src/markdown_reveal.rs +++ b/modules/ui/src/markdown_reveal.rs @@ -314,10 +314,17 @@ 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, - // Intraword underscores (`snake_case`) can never open. - '_' => previous.is_none_or(|before| !before.is_alphanumeric()), + '`' | '[' => 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('\\') { diff --git a/modules/ui/src/markdown_reveal_tests.rs b/modules/ui/src/markdown_reveal_tests.rs index 29b85c015..4c8fdd456 100644 --- a/modules/ui/src/markdown_reveal_tests.rs +++ b/modules/ui/src/markdown_reveal_tests.rs @@ -30,6 +30,12 @@ fn intraword_underscores_and_escapes_never_hold() { 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 "; From 56c10fc9f43032a9f7900da60aaf1d784f2fccbc Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 17:00:30 +0200 Subject: [PATCH 18/40] conversation: open entering work rows instead of blurring them in Entering tool chains, chain steps, prose, and the status line no longer blur and rise 12 px over their neighbours while the space below them jumps open. The row's height now opens from nothing over the fast motion token, so the rows below glide down, and its content fades in a beat later while settling 4 px into place. The blur ran the row through a filter layer that softened its text. Co-Authored-By: Claude Opus 5.5 --- .../src/conversation_surface/render_blocks.rs | 9 +- .../src/conversation_surface/work_motion.rs | 110 ++++++++++++++---- 2 files changed, 90 insertions(+), 29 deletions(-) diff --git a/modules/frontend/src/conversation_surface/render_blocks.rs b/modules/frontend/src/conversation_surface/render_blocks.rs index df85cbfd2..c934ad7fa 100644 --- a/modules/frontend/src/conversation_surface/render_blocks.rs +++ b/modules/frontend/src/conversation_surface/render_blocks.rs @@ -98,7 +98,7 @@ impl TurnRowView { } }) .into_any_element(); - Some(with_entrance(status, entrance)) + Some(with_entrance(status, entrance, theme.spacing.steps(6.0))) } /// A status line that left this frame, fading out while its height and @@ -576,7 +576,7 @@ impl TurnRowView { .motion .borrow_mut() .entrance(&format!("chain:{}", chain_rows[0].1.scene_id().as_str())); - items = items.child(with_entrance(chain, entrance)); + 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. @@ -600,7 +600,7 @@ impl TurnRowView { .motion .borrow_mut() .entrance(rows[row_index].1.scene_id().as_str()); - items = items.child(with_entrance(row, entrance)); + 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; @@ -921,7 +921,8 @@ impl TurnRowView { 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)); + 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))); } diff --git a/modules/frontend/src/conversation_surface/work_motion.rs b/modules/frontend/src/conversation_surface/work_motion.rs index f1c89397d..9ddaf29ff 100644 --- a/modules/frontend/src/conversation_surface/work_motion.rs +++ b/modules/frontend/src/conversation_surface/work_motion.rs @@ -20,7 +20,9 @@ //! - 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}; @@ -30,11 +32,15 @@ use super::reply_reveal::ReplyReveal; use super::*; /// Row entrance: `--duration-very-slow` (texts reveal). -const ENTRANCE: Duration = Duration::from_millis(500); -/// Row entrance rise: `--distance-medium` (texts reveal). -const ENTRANCE_RISE_PX: f32 = 12.0; -/// Row entrance blur: `--blur-medium` (texts reveal). -const ENTRANCE_BLUR_PX: f32 = 3.0; +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`). @@ -211,19 +217,36 @@ impl WorkMotion { } } - /// The entrance progress of one work row, `None` at rest. - pub(super) fn entrance(&mut self, key: &str) -> Option { + /// 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 = Instant::now().saturating_duration_since(started?); - if elapsed >= ENTRANCE || self.pace == Pace::Settled { + 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(eased(MotionCurve::SmoothOut, elapsed, ENTRANCE)) + 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. @@ -373,21 +396,58 @@ fn eased(curve: MotionCurve, elapsed: Duration, duration: Duration) -> f32 { value } -/// Wraps a work row in its entrance: fading in while it rises into place. -/// Relative offset only, so layout never moves. -pub(super) fn with_entrance(element: AnyElement, progress: Option) -> AnyElement { - match progress { - Some(progress) => div() - .w_full() - .min_w_0() - .relative() - .top(px(ENTRANCE_RISE_PX * (1.0 - progress))) - .opacity(progress) - .filter(vec![Filter::Blur(px(ENTRANCE_BLUR_PX * (1.0 - progress)))]) - .child(element) - .into_any_element(), - None => element, - } +/// 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 From b4a091762f183efcde70ef5c014ac0a07d52d6e6 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 17:17:01 +0200 Subject: [PATCH 19/40] conversation: reveal prose at a steady capped pace Segments used to take whatever had become stable since the last tick, so the reveal sped up and stalled with the transport's bursts. A word budget now accrues at a rate that eases (0.5 s time constant) toward clearing the backlog within 1.5 s, held between 300 and 900 words per minute, and banks at most six words so a pause never ends in a burst. A fence or table costs at most six words. Replies that land whole drain at the same pace. Co-Authored-By: Claude Opus 5.5 --- .../src/conversation_surface/reply_reveal.rs | 180 ++++++++++++------ 1 file changed, 126 insertions(+), 54 deletions(-) diff --git a/modules/frontend/src/conversation_surface/reply_reveal.rs b/modules/frontend/src/conversation_surface/reply_reveal.rs index 42afb9ea3..1f8eee253 100644 --- a/modules/frontend/src/conversation_surface/reply_reveal.rs +++ b/modules/frontend/src/conversation_surface/reply_reveal.rs @@ -5,14 +5,16 @@ //! move ([`stable_reveal_end`]): whole words, whole inline constructs, and //! whole fences and tables. //! -//! The prefix advances in segments: at most every [`SEGMENT_INTERVAL`], -//! what became stable since the last segment is revealed together. A +//! 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 fades in mostly as one piece, but its short runs start a frame //! apart from left to right, so it sweeps into place instead of spawning; //! the sweep is capped at [`SWEEP_SPAN`], well inside the fade, so it never -//! reads as letters streaming. A large backlog (a reply arriving in one -//! piece, or its remainder when the turn ends) is drained in capped -//! segments instead of appearing at once. +//! 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 @@ -36,9 +38,29 @@ const SWEEP_STAGGER: Duration = Duration::from_millis(16); /// The longest a segment's sweep may take from its first run to its last. const SWEEP_SPAN: Duration = Duration::from_millis(160); -/// The fewest words one segment reveals while draining a backlog; larger -/// backlogs drain in about six segments. -const DRAIN_MIN_WORDS: usize = 12; +/// 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. @@ -60,6 +82,12 @@ pub(super) struct ReplyReveal { 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, } @@ -74,6 +102,10 @@ impl ReplyReveal { 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(), } } @@ -122,22 +154,22 @@ impl ReplyReveal { if start < stable { match motion { MotionPolicy::Reduced => body[..stable].clone_into(&mut self.revealed), - MotionPolicy::Full if now >= self.next_tick => { - // A normal stream reveals everything stable; a backlog - // drains in capped segments rather than all at once. + MotionPolicy::Full => { let steps = RevealSteps::new(&body[..stable]); - let cap = DRAIN_MIN_WORDS.max(steps.pending_words(start).div_ceil(6)); - let mut end = start; - for _ in 0..cap { - end = steps.next(end); - if end >= stable { - break; + 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; } - self.revealed.push_str(&body[start..end]); - self.next_tick = now + SEGMENT_INTERVAL; } - MotionPolicy::Full => {} } } if self.revealed.len() > start && !self.resync && motion == MotionPolicy::Full { @@ -147,6 +179,20 @@ impl ReplyReveal { 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) { @@ -200,57 +246,83 @@ impl ReplyReveal { } } +/// 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_and_settles() { + fn a_segment_sweeps_in_left_to_right() { let start = Instant::now(); let mut reveal = ReplyReveal::begin(start); - assert!(reveal.advance("Hello brave new", true, MotionPolicy::Full, start)); - // Every complete word shows at once; the trailing `new` may grow. - assert_eq!(reveal.revealed(), "Hello brave "); - // Revealed this frame: transparent until its runs are known. - assert!(reveal.fade.alpha_at(0) <= f32::EPSILON); - reveal.rendered(11); + // 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.advance("Hello brave new", true, MotionPolicy::Full, mid); - let (left, right) = (reveal.fade.alpha_at(0), reveal.fade.alpha_at(10)); + 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"); - - // Text stable within the interval waits for the next segment. - let soon = start + SEGMENT_INTERVAL / 2; - reveal.advance("Hello brave new world ", true, MotionPolicy::Full, soon); - assert_eq!(reveal.revealed(), "Hello brave "); - let next = start + SEGMENT_INTERVAL; - reveal.advance("Hello brave new world ", true, MotionPolicy::Full, next); - assert_eq!(reveal.revealed(), "Hello brave new world "); - reveal.rendered(21); - - let done = next + SWEEP_SPAN + UNIT_FADE * 2; - assert!(!reveal.advance("Hello brave new world ", false, MotionPolicy::Full, done)); - assert!(reveal.is_complete("Hello brave new world ")); } #[test] - fn a_backlog_drains_in_capped_segments_instead_of_snapping() { - // A reply that arrives whole, as at the end of a turn. + 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); - reveal.advance(&body, false, MotionPolicy::Full, start); - let first = reveal.revealed().split_whitespace().count(); - assert_eq!(first, 20, "a sixth of the backlog per segment"); - reveal.rendered(first * 4); + 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; - for _ in 0..12 { - at += SEGMENT_INTERVAL; - reveal.advance(&body, false, MotionPolicy::Full, at); - reveal.rendered(reveal.revealed().len()); - } - assert_eq!(reveal.revealed(), body, "the whole backlog drains"); + 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] From 01b0913814ec3a549c71cfc446563ea0e9d54506 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 17:17:40 +0200 Subject: [PATCH 20/40] ui: stack uppercase build badge beneath editor wordmark --- modules/build_info/src/lib.rs | 36 ++++++++++--------- .../src/native_application/impl_surfaces.rs | 5 +-- tests/build_info/build_info.rs | 10 ++++-- 3 files changed, 30 insertions(+), 21 deletions(-) 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/frontend/src/native_application/impl_surfaces.rs b/modules/frontend/src/native_application/impl_surfaces.rs index 3fd2f332b..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( @@ -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/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 From e17d444d360a21c9f3d38e15a7aa562d15564c2a Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 17:49:49 +0200 Subject: [PATCH 21/40] forge: persist explicit user model preferences with source --- modules/backend/src/request_handler.rs | 13 +- .../src/request_handler/model_selection.rs | 6 +- .../project_draft_submission.rs | 31 +- .../src/request_handler/user_preferences.rs | 64 +- .../src/repository/user_preferences.rs | 26 +- modules/domain/src/commands.rs | 6 +- modules/domain/src/lib.rs | 6 +- modules/domain/src/user_preferences.rs | 27 +- modules/frontend/src/native_application.rs | 1 + .../src/native_application/impl_lifecycle.rs | 1 + .../src/native_application/preferences.rs | 25 + .../tests/new_thread_send.rs | 94 ++ .../frontend/src/native_composer_models.rs | 41 +- .../src/native_preferences_transport.rs | 29 +- .../connection_holds.rs | 4 +- modules/protocol/schema/artisan.capnp | 15 + modules/protocol/src/artisan_capnp.rs | 991 ++++++++++++------ modules/protocol/src/codec/envelope.rs | 7 +- .../protocol/src/codec/user_preferences.rs | 31 +- tests/backend/request_handler.rs | 47 +- tests/database/user_preferences.rs | 41 + tests/protocol/user_preferences.rs | 29 + 22 files changed, 1144 insertions(+), 391 deletions(-) diff --git a/modules/backend/src/request_handler.rs b/modules/backend/src/request_handler.rs index e6953c090..3169fc3bf 100644 --- a/modules/backend/src/request_handler.rs +++ b/modules/backend/src/request_handler.rs @@ -1082,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 @@ -1114,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/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/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 d9bce298d..ad3c8cc34 100644 --- a/modules/domain/src/lib.rs +++ b/modules/domain/src/lib.rs @@ -249,7 +249,7 @@ pub use recent_threads::{ 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/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/src/native_application.rs b/modules/frontend/src/native_application.rs index 90b8987ea..7f9e6b764 100644 --- a/modules/frontend/src/native_application.rs +++ b/modules/frontend/src/native_application.rs @@ -363,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/impl_lifecycle.rs b/modules/frontend/src/native_application/impl_lifecycle.rs index 0999327d3..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, 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/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_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_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/connection_holds.rs b/modules/frontend/src/native_transport_service/connection_holds.rs index d4409c9bb..7389e1210 100644 --- a/modules/frontend/src/native_transport_service/connection_holds.rs +++ b/modules/frontend/src/native_transport_service/connection_holds.rs @@ -298,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/protocol/schema/artisan.capnp b/modules/protocol/schema/artisan.capnp index a48332a58..0af67d115 100644 --- a/modules/protocol/schema/artisan.capnp +++ b/modules/protocol/schema/artisan.capnp @@ -927,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; } } @@ -3027,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 aa537173d..e36643211 100644 --- a/modules/protocol/src/artisan_capnp.rs +++ b/modules/protocol/src/artisan_capnp.rs @@ -19025,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(()); @@ -19318,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 => { @@ -19580,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)) } } @@ -20352,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 => { @@ -20614,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)) } } @@ -20628,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(209, 106, 0, 0, 204, 133, 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(228, 7, 0, 0, 3, 0, 1, 0), - ::capnp::word(240, 7, 0, 0, 2, 0, 1, 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(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), @@ -21522,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 { @@ -21577,6 +21624,7 @@ pub mod request { 49 => ::introspect(), 50 => ::introspect(), 51 => ::introspect(), + 52 => ::introspect(), _ => ::capnp::introspect::panic_invalid_field_index(index), } } @@ -21591,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), @@ -21648,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 { @@ -23264,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(252, 134, 0, 0, 68, 155, 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), @@ -24395,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(210, 155, 0, 0, 18, 156, 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), @@ -24621,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(92, 156, 0, 0, 144, 156, 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), @@ -24715,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(146, 156, 0, 0, 228, 156, 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), @@ -24969,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(230, 156, 0, 0, 98, 157, 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), @@ -25282,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(100, 157, 0, 0, 22, 159, 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), @@ -25686,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(24, 159, 0, 0, 97, 159, 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), @@ -25912,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(36, 161, 0, 0, 229, 161, 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), @@ -26628,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(7, 163, 0, 0, 128, 178, 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), @@ -27016,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(222, 181, 0, 0, 45, 182, 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), @@ -27093,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(90, 182, 0, 0, 178, 182, 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), @@ -27174,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(219, 182, 0, 0, 47, 183, 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), @@ -27250,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(84, 183, 0, 0, 141, 183, 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), @@ -27318,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(188, 183, 0, 0, 249, 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), @@ -27386,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(50, 184, 0, 0, 111, 184, 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), @@ -27458,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(169, 184, 0, 0, 2, 185, 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), @@ -27534,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(51, 185, 0, 0, 115, 185, 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), @@ -27605,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(165, 185, 0, 0, 241, 185, 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), @@ -27677,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(34, 186, 0, 0, 98, 186, 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), @@ -27748,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(128, 186, 0, 0, 210, 186, 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), @@ -27822,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(5, 187, 0, 0, 70, 187, 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), @@ -27891,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(123, 187, 0, 0, 190, 187, 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), @@ -27963,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(235, 187, 0, 0, 49, 188, 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), @@ -28040,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(91, 188, 0, 0, 193, 188, 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), @@ -28129,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(246, 188, 0, 0, 115, 189, 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), @@ -28217,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(172, 189, 0, 0, 245, 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), @@ -28291,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(41, 190, 0, 0, 113, 190, 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), @@ -28369,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(168, 190, 0, 0, 24, 191, 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), @@ -28454,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(84, 191, 0, 0, 153, 191, 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), @@ -28740,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(209, 191, 0, 0, 123, 192, 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), @@ -29128,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(165, 192, 0, 0, 85, 193, 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), @@ -29533,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(153, 193, 0, 0, 153, 194, 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), @@ -30176,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(197, 194, 0, 0, 141, 195, 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), @@ -30549,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(208, 195, 0, 0, 173, 196, 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), @@ -30922,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(233, 196, 0, 0, 4, 198, 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), @@ -31812,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(53, 198, 0, 0, 167, 199, 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), @@ -32756,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(240, 199, 0, 0, 87, 202, 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), @@ -33912,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(24, 203, 0, 0, 152, 203, 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), @@ -34287,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(12, 204, 0, 0, 23, 205, 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), @@ -34867,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(70, 205, 0, 0, 215, 205, 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), @@ -35267,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(155, 206, 0, 0, 43, 209, 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), @@ -35928,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(80, 209, 0, 0, 184, 209, 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), @@ -36230,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(222, 209, 0, 0, 154, 210, 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), @@ -36581,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(198, 210, 0, 0, 53, 212, 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), @@ -37204,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(113, 212, 0, 0, 32, 213, 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), @@ -37520,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(83, 213, 0, 0, 195, 213, 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), @@ -37812,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(1, 214, 0, 0, 134, 214, 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), @@ -38216,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(184, 214, 0, 0, 46, 216, 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), @@ -38581,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(80, 216, 0, 0, 203, 216, 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), @@ -38861,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(238, 216, 0, 0, 111, 217, 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), @@ -39142,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(149, 217, 0, 0, 10, 218, 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), @@ -39412,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(45, 218, 0, 0, 198, 218, 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), @@ -39728,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(231, 218, 0, 0, 21, 221, 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), @@ -40839,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(41, 221, 0, 0, 234, 221, 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), @@ -41144,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(254, 221, 0, 0, 253, 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), @@ -41968,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(19, 223, 0, 0, 105, 224, 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), @@ -42966,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(206, 224, 0, 0, 239, 226, 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), @@ -43400,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(40, 227, 0, 0, 235, 227, 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), @@ -43841,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(190, 228, 0, 0, 225, 229, 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), @@ -44585,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(120, 230, 0, 0, 158, 233, 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), @@ -46282,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(224, 233, 0, 0, 0, 235, 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), @@ -46901,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(55, 235, 0, 0, 29, 236, 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), @@ -47488,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(79, 236, 0, 0, 217, 236, 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), @@ -47813,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(9, 237, 0, 0, 52, 238, 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), @@ -49001,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(189, 238, 0, 0, 235, 242, 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), @@ -49645,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(101, 244, 0, 0, 239, 244, 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), @@ -49952,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(54, 245, 0, 0, 27, 247, 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), @@ -50352,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(118, 252, 0, 0, 110, 254, 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), @@ -50626,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(183, 254, 0, 0, 251, 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), @@ -51051,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(73, 1, 1, 0, 230, 4, 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), @@ -51276,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(119, 6, 1, 0, 67, 7, 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), @@ -51612,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(151, 7, 1, 0, 82, 12, 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), @@ -52064,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(68, 14, 1, 0, 0, 17, 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), @@ -52407,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(127, 19, 1, 0, 129, 22, 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), @@ -52739,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(193, 22, 1, 0, 222, 25, 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), @@ -53034,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(29, 26, 1, 0, 185, 28, 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), @@ -53331,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(241, 28, 1, 0, 141, 31, 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), @@ -53768,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(144, 32, 1, 0, 173, 35, 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), @@ -54136,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(136, 37, 1, 0, 99, 39, 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), @@ -54388,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(186, 40, 1, 0, 47, 41, 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), @@ -54616,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(102, 41, 1, 0, 76, 44, 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), @@ -55125,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(13, 45, 1, 0, 29, 46, 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), @@ -55673,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(102, 46, 1, 0, 17, 50, 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), @@ -56427,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(88, 50, 1, 0, 212, 50, 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), @@ -56651,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(255, 50, 1, 0, 73, 52, 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), @@ -56928,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(158, 53, 1, 0, 162, 54, 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), @@ -57161,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(238, 54, 1, 0, 100, 55, 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), @@ -57455,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(139, 55, 1, 0, 120, 57, 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), @@ -57778,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(89, 58, 1, 0, 114, 63, 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), @@ -58596,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(91, 64, 1, 0, 173, 64, 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), @@ -58903,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(170, 65, 1, 0, 92, 66, 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), @@ -59498,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(40, 67, 1, 0, 198, 67, 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), @@ -59925,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(140, 68, 1, 0, 171, 70, 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), @@ -60668,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(49, 71, 1, 0, 222, 71, 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), @@ -61014,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(118, 72, 1, 0, 215, 72, 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), @@ -61285,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(105, 73, 1, 0, 188, 73, 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), @@ -61517,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(49, 77, 1, 0, 74, 78, 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), @@ -61887,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(139, 78, 1, 0, 224, 78, 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), @@ -61964,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(41, 79, 1, 0, 131, 79, 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), @@ -62039,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(16, 80, 1, 0, 85, 80, 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), @@ -62314,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(69, 81, 1, 0, 233, 83, 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), @@ -62782,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(235, 84, 1, 0, 182, 89, 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), @@ -63398,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(79, 90, 1, 0, 75, 91, 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), @@ -63689,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(200, 94, 1, 0, 60, 95, 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), @@ -64022,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(231, 95, 1, 0, 62, 97, 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), @@ -64171,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(71, 98, 1, 0, 176, 98, 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), @@ -64476,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(122, 99, 1, 0, 83, 100, 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), @@ -64907,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(6, 101, 1, 0, 228, 101, 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), @@ -65106,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(101, 103, 1, 0, 192, 103, 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), @@ -65315,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(194, 103, 1, 0, 142, 104, 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), @@ -65540,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(5, 105, 1, 0, 58, 105, 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), @@ -65777,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(60, 105, 1, 0, 168, 105, 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), @@ -66107,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(238, 105, 1, 0, 183, 106, 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), @@ -66398,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(28, 108, 1, 0, 103, 108, 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), @@ -66649,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(172, 108, 1, 0, 241, 108, 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), @@ -66900,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(43, 109, 1, 0, 113, 109, 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), @@ -67220,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(115, 109, 1, 0, 216, 110, 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), @@ -67525,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(26, 111, 1, 0, 103, 111, 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), @@ -67780,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(251, 111, 1, 0, 122, 112, 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), @@ -67899,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(124, 112, 1, 0, 205, 112, 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), @@ -68121,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(207, 112, 1, 0, 102, 113, 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), @@ -68390,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(247, 113, 1, 0, 55, 114, 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), @@ -68640,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(187, 114, 1, 0, 17, 115, 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), @@ -68967,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(86, 115, 1, 0, 9, 116, 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), @@ -69122,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(62, 116, 1, 0, 168, 116, 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), @@ -69563,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(51, 117, 1, 0, 5, 120, 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), @@ -69875,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(7, 120, 1, 0, 79, 120, 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), @@ -70063,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(126, 120, 1, 0, 199, 120, 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), @@ -70313,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(253, 120, 1, 0, 110, 121, 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), @@ -70597,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(177, 121, 1, 0, 11, 122, 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), @@ -70900,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(67, 122, 1, 0, 198, 122, 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), @@ -71167,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(67, 124, 1, 0, 145, 124, 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), @@ -71402,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(238, 124, 1, 0, 56, 125, 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), @@ -71680,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(91, 126, 1, 0, 5, 127, 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), @@ -71810,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(47, 127, 1, 0, 116, 128, 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), @@ -72062,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(240, 128, 1, 0, 118, 129, 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), @@ -72207,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(218, 129, 1, 0, 66, 131, 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), @@ -72419,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(181, 131, 1, 0, 117, 132, 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), @@ -72999,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(215, 132, 1, 0, 118, 133, 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), @@ -73307,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(228, 133, 1, 0, 22, 135, 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), @@ -73824,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(83, 135, 1, 0, 226, 135, 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), @@ -74110,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(117, 136, 1, 0, 20, 137, 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), @@ -74412,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(82, 137, 1, 0, 162, 137, 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), @@ -74724,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(207, 137, 1, 0, 125, 140, 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), @@ -75538,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(10, 141, 1, 0, 99, 141, 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), @@ -75805,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(211, 141, 1, 0, 46, 142, 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), @@ -75889,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/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/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/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/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(()) +} From 690a7bf83f024e31d66f32ef2d5029ff7e492789 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 18:14:26 +0200 Subject: [PATCH 22/40] ui: hide last-day sidebar heading while retaining its group --- .../src/native_application/impl_sidebar_threads.rs | 6 ++++-- .../src/native_application/tests/sidebar_recents.rs | 10 ++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/modules/frontend/src/native_application/impl_sidebar_threads.rs b/modules/frontend/src/native_application/impl_sidebar_threads.rs index 14fe82309..9ad992b4a 100644 --- a/modules/frontend/src/native_application/impl_sidebar_threads.rs +++ b/modules/frontend/src/native_application/impl_sidebar_threads.rs @@ -566,11 +566,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)); } diff --git a/modules/frontend/src/native_application/tests/sidebar_recents.rs b/modules/frontend/src/native_application/tests/sidebar_recents.rs index b8d454114..dd0061187 100644 --- a/modules/frontend/src/native_application/tests/sidebar_recents.rs +++ b/modules/frontend/src/native_application/tests/sidebar_recents.rs @@ -106,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) From dde17d425b9b0f6a5d4a13060859b879fa17f32e Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 18:25:25 +0200 Subject: [PATCH 23/40] forge: answer catalogs from cached discovery while it refreshes Every catalog read, and every send (which resolves its model against the catalog), awaited a full probe of the engine CLIs whenever the discovery cache was cold or stale: up to 15 s for OpenCode, and every 10 s while any installed engine's probe failed. Traces showed catalog reads of 7-16 s and a 7.5 s send, with the Editor's other commands queued behind them. Discovery now serves the cached bundle at once and refreshes it in one background probe that never holds the cache lock; only a cold Forge waits, and the Forge starts that first probe at startup. Co-Authored-By: Claude Opus 5.5 --- modules/backend/src/forge_runtime/startup.rs | 1 + modules/backend/src/model_discovery/mod.rs | 77 +++++++++++++++++--- 2 files changed, 69 insertions(+), 9 deletions(-) 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/model_discovery/mod.rs b/modules/backend/src/model_discovery/mod.rs index 5e980210e..833efc72b 100644 --- a/modules/backend/src/model_discovery/mod.rs +++ b/modules/backend/src/model_discovery/mod.rs @@ -149,21 +149,80 @@ 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 +/// 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); @@ -209,7 +268,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 @@ -233,7 +292,7 @@ pub(crate) async fn discovery_bundle() -> Arc { } else { DISCOVERY_TTL }; - *guard = Some((Instant::now(), ttl, Arc::clone(&bundle))); + *cache().lock().await = Some((Instant::now(), ttl, Arc::clone(&bundle))); bundle } From 02805c10935136efa112cd8a24955280afe794d8 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 18:25:25 +0200 Subject: [PATCH 24/40] database: keep dispatch leases renewing across clock steps Commits stamp a running dispatch with max(now, previous stamp), but lease renewal compared that stamp with the raw wall clock and failed as a broken invariant whenever the clock had stepped back. On WSL, where the host-time sync steps the guest clock, every heartbeat of a running turn failed for minutes after the host was locked. Renewal no longer moves the stamp backwards and accepts a stamp later than its own clock reading. Co-Authored-By: Claude Opus 5.5 --- .../src/repository/message_dispatch/lease.rs | 15 ++++---- tests/database/message_dispatch_lifecycle.rs | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) 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/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"); +} From e26c297fa2c0fceeab959e62c72f1d1ad6a50edb Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 18:25:25 +0200 Subject: [PATCH 25/40] transport: report unreadable credentials as Forge offline The Editor reads the dev Forge's credentials from the WSL home. While WSL hung, those reads failed and every retry reported an authentication failure ("the saved connection credential is no longer usable"), so the window flickered between that screen and connected. An I/O failure reading credentials now reports the host as unavailable (Forge offline); invalid credential material still reports authentication. Co-Authored-By: Claude Opus 5.5 --- .../src/native_transport_service/diagnostics.rs | 8 ++++++++ .../native_transport_service/service_lifecycle.rs | 15 +++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) 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/service_lifecycle.rs b/modules/frontend/src/native_transport_service/service_lifecycle.rs index a89b9b68d..b56142948 100644 --- a/modules/frontend/src/native_transport_service/service_lifecycle.rs +++ b/modules/frontend/src/native_transport_service/service_lifecycle.rs @@ -446,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()); From c24b57f432b612208c3cc19aab971fb5b39b1b36 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 18:25:25 +0200 Subject: [PATCH 26/40] tracing: keep incident evidence through storms and WSL restarts Three things lost the traces that would have explained today's stall: - a reason repeating every few seconds saved a snapshot each time and rotated earlier, distinct incidents out of the folder; a reason now saves at most once per five minutes; - the Forge wrote to /tmp, which a WSL restart empties; Linux traces now default to $XDG_STATE_HOME/artisan/traces (else ~/.local/state); - navigation gates were recorded on every service event, crowding the bounded history; they are recorded only when they change. A refusal the Forge answered (a link title it could not fetch) is still recorded but no longer counts as an incident. Co-Authored-By: Claude Opus 5.5 --- .../src/native_application/flight_recorder.rs | 54 +++++++++++++------ .../request_delivery.rs | 12 +++-- modules/tracing/src/recorder.rs | 51 +++++++++++++++--- tests/tracing/recorder.rs | 39 ++++++++++++++ 4 files changed, 129 insertions(+), 27 deletions(-) diff --git a/modules/frontend/src/native_application/flight_recorder.rs b/modules/frontend/src/native_application/flight_recorder.rs index 070132c58..c83d62a64 100644 --- a/modules/frontend/src/native_application/flight_recorder.rs +++ b/modules/frontend/src/native_application/flight_recorder.rs @@ -10,6 +10,12 @@ use gpui::{ 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; @@ -75,20 +81,38 @@ impl NativeApplication { "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 ); - artisan_tracing::instant!("state", "navigation.gates", - "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() - ); + 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_transport_service/request_delivery.rs b/modules/frontend/src/native_transport_service/request_delivery.rs index 62fc8907a..5bee4d01f 100644 --- a/modules/frontend/src/native_transport_service/request_delivery.rs +++ b/modules/frontend/src/native_transport_service/request_delivery.rs @@ -294,10 +294,14 @@ impl ServiceRuntime { #[cfg(feature = "flight-recorder")] match &attempt { Ok(_) => artisan_tracing::instant!("transport.request", "request.succeeded"), - Err( - RequestAttemptError::Retained { failure, .. } - | RequestAttemptError::Terminal { failure, .. }, - ) => { + // 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"); } diff --git a/modules/tracing/src/recorder.rs b/modules/tracing/src/recorder.rs index ef7d55133..b6985aecd 100644 --- a/modules/tracing/src/recorder.rs +++ b/modules/tracing/src/recorder.rs @@ -18,6 +18,10 @@ 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); @@ -67,19 +71,38 @@ impl Drop for Session { } /// Start once per process. Debug builds record by default; set -/// `ARTISAN_TRACE=0` to disable, or `ARTISAN_TRACE_DIR` to choose the directory. +/// `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( - || std::env::temp_dir().join("artisan-traces"), - PathBuf::from, - ); + 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); @@ -366,6 +389,7 @@ fn collect( 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); @@ -379,11 +403,13 @@ fn collect( 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, now, reason), + 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, now, reason); + schedule(&mut pending, last_incident, &last_by_reason, now, reason); } if let Some((due, reason)) = pending && now >= due @@ -391,6 +417,8 @@ fn collect( 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() { @@ -414,10 +442,17 @@ mod tests; fn schedule( pending: &mut Option<(u64, &'static str)>, last: Option, + last_by_reason: &[(&'static str, u64)], now: u64, reason: &'static str, ) { - if pending.is_none() && last.is_none_or(|last| now.saturating_sub(last) >= COOLDOWN_US) { + 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)); } } diff --git a/tests/tracing/recorder.rs b/tests/tracing/recorder.rs index 3d3afde1f..741922c7d 100644 --- a/tests/tracing/recorder.rs +++ b/tests/tracing/recorder.rs @@ -151,3 +151,42 @@ fn idle_history_expires_and_stalls_only_report_once_per_operation() { 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" + ); +} From 6289d26da1edc3d504168c91299c6129459f5688 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 18:25:25 +0200 Subject: [PATCH 27/40] nix: skip the unused check pass and binary copy in stage builds Each stage's dependency cache ran crane's `cargo check` before `cargo build`; nothing reads those artifacts, and it cost about 2.5 minutes of the 13-minute rebuild after a lockfile or gpui change. Payload and runner builds also copied about 1 GB of binaries that `installPhaseCommand` already moves (about 30 s per payload). Co-Authored-By: Claude Opus 5.5 --- nix/stages.nix | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/nix/stages.nix b/nix/stages.nix index 4b462cba0..d09e640b6 100644 --- a/nix/stages.nix +++ b/nix/stages.nix @@ -233,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}"; @@ -243,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 @@ -289,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"}" From bf188115a6d25e29dd1a305f0487c23f672a70f9 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 19:09:39 +0200 Subject: [PATCH 28/40] conversation: rest numbers in the columns they roll in A counting number rested as one text run and rolled as one column per character. The font's tracking spaces characters within a run but not across columns, so each tick widened the label by a few pixels for the roll and snapped it back. Numbers now rest in the same columns. Co-Authored-By: Claude Opus 5.5 --- .../src/conversation_surface/work_motion.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/modules/frontend/src/conversation_surface/work_motion.rs b/modules/frontend/src/conversation_surface/work_motion.rs index 9ddaf29ff..c3192c168 100644 --- a/modules/frontend/src/conversation_surface/work_motion.rs +++ b/modules/frontend/src/conversation_surface/work_motion.rs @@ -480,6 +480,13 @@ pub(super) fn phrase_element(words: &[PhraseWord], line_height: Pixels) -> AnyEl 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()) @@ -499,6 +506,15 @@ fn word_element(word: &PhraseWord, line_height: Pixels) -> AnyElement { } } +/// 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); From dd15bf78fb6e5e8b9232ff66d52a302df940d684 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 19:36:21 +0200 Subject: [PATCH 29/40] sidebar: clear hover pills whose row did not render A sidebar row hidden in a collapsed group, or moved into one, still has its thread in the listing, so the pill stayed where the row used to be. The sidebar now clears its hover against the rows this render builds; the turn navigator and the home project menu do the same against the rows they list, before the pill reads its target. Co-Authored-By: Claude Opus 5.5 --- .../conversation_surface/render_navigator.rs | 5 +++ modules/frontend/src/home_project_picker.rs | 3 ++ .../impl_sidebar_threads.rs | 36 +++++++++++++------ 3 files changed, 34 insertions(+), 10 deletions(-) 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/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/impl_sidebar_threads.rs b/modules/frontend/src/native_application/impl_sidebar_threads.rs index 9ad992b4a..b00b30689 100644 --- a/modules/frontend/src/native_application/impl_sidebar_threads.rs +++ b/modules/frontend/src/native_application/impl_sidebar_threads.rs @@ -10,7 +10,9 @@ use super::*; use crate::desktop_shell::{DESKTOP_COLUMN_ROW_INSET_PX, desktop_section_label}; -use crate::recent_thread_groups::{RecentThreadGroup, next_regrouping, section_recent_threads}; +use crate::recent_thread_groups::{ + RecentThreadGroup, SidebarSections, next_regrouping, section_recent_threads, +}; use artisan_domain::{ RecentThread, RecentThreadListing, ThreadAttention, ThreadSummary, UnixMillis, }; @@ -323,14 +325,14 @@ impl NativeApplication { .collect::>(); self.animate_sidebar_selection(&ids, window, cx); 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( |_, _, _| {}, @@ -372,7 +374,23 @@ impl NativeApplication { px(6.0), cx.reduce_motion(), )); - self.desktop_sidebar_sections(container, &listing, now, window, cx) + self.desktop_sidebar_sections(container, §ions, window, cx) + } + + /// 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, @@ -380,12 +398,10 @@ impl NativeApplication { fn desktop_sidebar_sections( &mut self, mut container: Stateful
, - listing: &RecentThreadListing, - now: UnixMillis, + sections: &SidebarSections<'_>, window: &mut Window, cx: &mut Context, ) -> Stateful
{ - let sections = section_recent_threads(listing, now); if !sections.unread.is_empty() { let mut unread = div() .w_full() From c30557348a87b10ebc3ade53a464e1f943a08a34 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 19:36:21 +0200 Subject: [PATCH 30/40] sidebar: keep the open thread's unread outcome until the reader leaves The Forge marks a thread read the moment it opens, and the row hid its dot while open, so opening a finished thread pulled it out of the unread rows under the pointer. The sidebar now holds the outcome the open thread had, or gained while open, until the reader leaves it; a new run there acts on it and releases it. Co-Authored-By: Claude Opus 5.5 --- .../impl_sidebar_threads.rs | 94 ++++++++++++++----- 1 file changed, 70 insertions(+), 24 deletions(-) diff --git a/modules/frontend/src/native_application/impl_sidebar_threads.rs b/modules/frontend/src/native_application/impl_sidebar_threads.rs index b00b30689..6c628c82e 100644 --- a/modules/frontend/src/native_application/impl_sidebar_threads.rs +++ b/modules/frontend/src/native_application/impl_sidebar_threads.rs @@ -104,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, } } @@ -146,6 +146,11 @@ pub(super) struct SidebarThreadsState { 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. @@ -324,6 +329,7 @@ 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)); // The pill reads its target while this render builds it, so stale // targets clear before the pill, against the rows that will paint. @@ -377,6 +383,52 @@ impl NativeApplication { 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. @@ -625,8 +677,7 @@ impl NativeApplication { &row.project_icon, &self.theme, ); - let open = self.sidebar_threads.selection.target.as_ref() == Some(&thread.thread_id); - let indicator = SidebarThreadIndicator::of(thread, open); + let indicator = SidebarThreadIndicator::of(thread); let description = match indicator { Some(indicator) => SharedString::from(format!("{subtitle}, {}", indicator.label())), None => subtitle.clone(), @@ -775,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) ); } From 92789792335408abd7caf4c6ded7021613e60dd5 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 19:36:21 +0200 Subject: [PATCH 31/40] conversation: fold the work only once the reply is clearly the answer Claude's narration before a tool call counts as the reply while it streams, then moves back into the work when the call starts. Folding on its first word folded the whole section and its working label away, streamed a few words, and reopened it, and the moved prose entered again from zero height. The work now folds once the reply is final, has stopped streaming, or has shown 40 words, and prose already shown as the reply keeps its place when it moves into the work. Co-Authored-By: Claude Opus 5.5 --- .../tests/work_group_header.rs | 35 ++++++++++--------- .../src/conversation_surface/turn_row.rs | 27 ++++++++++---- .../src/conversation_surface/work_motion.rs | 13 +++++-- 3 files changed, 49 insertions(+), 26 deletions(-) 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 6c02832c5..c9451d338 100644 --- a/modules/frontend/src/conversation_surface/tests/work_group_header.rs +++ b/modules/frontend/src/conversation_surface/tests/work_group_header.rs @@ -673,29 +673,32 @@ fn replying_section_scene(body: &str) -> ConversationScene { } #[gpui::test] -fn a_live_section_folds_once_its_reply_shows_a_whole_word(cx: &mut TestAppContext) { +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 lone partial word is not on screen yet: the work stays open. - let (_waiting, cx_waiting) = cx.add_window_view(|_, surface_cx| { - ConversationSurface::new(replying_section_scene("All"), ThemeMode::Dark, surface_cx) + // 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_waiting.simulate_resize(size(px(720.0), px(720.0))); - settle(cx_waiting); + cx_narrating.simulate_resize(size(px(720.0), px(720.0))); + settle(cx_narrating); assert!( - cx_waiting.debug_bounds(STEER_LEAD_CHAIN).is_some(), - "the work stays open until a whole word of the reply shows" + cx_narrating.debug_bounds(STEER_LEAD_CHAIN).is_some(), + "the work stays open while the reply could still be narration" ); - // Once a whole word shows, the work folds while the turn is still live, - // and the header keeps its words. + // 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("All tests pass."), - ThemeMode::Dark, - surface_cx, - ) + ConversationSurface::new(replying_section_scene(&answer), ThemeMode::Dark, surface_cx) }); cx.simulate_resize(size(px(720.0), px(720.0))); settle(cx); @@ -705,6 +708,6 @@ fn a_live_section_folds_once_its_reply_shows_a_whole_word(cx: &mut TestAppContex ); assert!( cx.debug_bounds(STEER_LEAD_CHAIN).is_none(), - "the work folds as soon as the reply shows" + "the work folds once the reply is clearly the answer" ); } diff --git a/modules/frontend/src/conversation_surface/turn_row.rs b/modules/frontend/src/conversation_surface/turn_row.rs index a7d99d0b4..9041a6fc2 100644 --- a/modules/frontend/src/conversation_surface/turn_row.rs +++ b/modules/frontend/src/conversation_surface/turn_row.rs @@ -31,6 +31,11 @@ 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)] @@ -437,11 +442,16 @@ impl TurnRowView { } /// Advances this turn's reply reveal for the frame and reports whether - /// the reply has visibly begun. + /// the reply is clearly the answer, so the work above it may fold. /// - /// A reveal starts only for a reply first seen while it streams, so - /// settled history never animates; the reply's own render reads the - /// same reveal for its prefix (see [`WorkMotion::reveal`]). + /// 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), @@ -456,9 +466,12 @@ impl TurnRowView { .is_some_and(lifecycle_is_live); let mut motion = self.motion.borrow_mut(); let _ = motion.reveal(&reply.id, &reply.body, streaming); - motion - .reveal_shows(&reply.id) - .unwrap_or(!reply.body.is_empty()) + 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. diff --git a/modules/frontend/src/conversation_surface/work_motion.rs b/modules/frontend/src/conversation_surface/work_motion.rs index c3192c168..33cd39030 100644 --- a/modules/frontend/src/conversation_surface/work_motion.rs +++ b/modules/frontend/src/conversation_surface/work_motion.rs @@ -203,11 +203,18 @@ impl WorkMotion { Some((reveal.revealed().to_owned(), reveal.fade.clone())) } - /// Whether the reveal of `id`, if any, shows at least one segment. - pub(super) fn reveal_shows(&self, id: &SceneId) -> Option { + /// 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().is_empty()) + .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); } /// Records the visible length the renderer reported for `id`. From cf397f1a693fab3b56eff43adabda804ca61f623 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 20:00:03 +0200 Subject: [PATCH 32/40] conversation: glide the reveal's fade front through the text Each segment swept its 6-character runs 16 ms apart and then waited for the next segment, so the fade arrived in small bursts and read as stiff. Runs are now 3 characters, spread across the whole segment interval, and each segment's sweep picks up from the previous one's front, so one soft front glides through the text. The fade is 400 ms ease-out, so a gentle gradient trails the front. Co-Authored-By: Claude Opus 5.5 --- .../src/conversation_surface/reply_reveal.rs | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/modules/frontend/src/conversation_surface/reply_reveal.rs b/modules/frontend/src/conversation_surface/reply_reveal.rs index 1f8eee253..e3e398ed4 100644 --- a/modules/frontend/src/conversation_surface/reply_reveal.rs +++ b/modules/frontend/src/conversation_surface/reply_reveal.rs @@ -9,12 +9,13 @@ //! 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 fades in mostly as one piece, but its short runs start a frame -//! apart from left to right, so it sweeps into place instead of spawning; -//! the sweep is capped at [`SWEEP_SPAN`], well inside the fade, 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. +//! [`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 @@ -25,18 +26,21 @@ 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-medium`): long enough to read as a fade, -/// short enough that at most a couple of segments are mid-fade. -const UNIT_FADE: Duration = MotionDuration::Medium.as_duration(); +/// 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); -/// Visible characters per run of a segment's sweep. -const SWEEP_RUN: usize = 6; +/// The fade's curve: a gentle start, so the front has no hard edge. +const UNIT_FADE_CURVE: MotionCurve = MotionCurve::EaseOut; -/// Delay between neighbouring runs: one display frame. -const SWEEP_STAGGER: Duration = Duration::from_millis(16); +/// 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; -/// The longest a segment's sweep may take from its first run to its last. -const SWEEP_SPAN: Duration = Duration::from_millis(160); +/// 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 @@ -200,10 +204,17 @@ impl ReplyReveal { && visible_len > start { let runs = (visible_len - start).div_ceil(SWEEP_RUN); - let stagger = SWEEP_STAGGER.min(SWEEP_SPAN / u32::try_from(runs).unwrap_or(u32::MAX)); + 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, began + delay)); + self.marks.push((start + run * SWEEP_RUN, front + delay)); } } self.visible_len = visible_len; @@ -225,7 +236,7 @@ impl ReplyReveal { 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 = MotionCurve::SmoothOut.sample(progress) as f32; + let eased = UNIT_FADE_CURVE.sample(progress) as f32; eased }; let opaque = self From 709f020caad6e2a8d4fa3123f4358a1fd7bf12a1 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 20:00:03 +0200 Subject: [PATCH 33/40] conversation: show what arrived while nobody watched at rest A minimized or hidden window stops painting while scenes keep arriving, so its next paint treated everything that landed meanwhile as new: four narration paragraphs streamed in at once on refocus. The surface now records when its oldest unpainted scene arrived; a paint more than 500 ms later is a catch-up, in which rows end their running reveals and paint at rest like a first render. Prose still arriving shows what landed and streams only the rest. Co-Authored-By: Claude Opus 5.5 --- modules/frontend/src/conversation_surface.rs | 4 +++ .../src/conversation_surface/interactions.rs | 1 + .../conversation_surface/transcript_window.rs | 13 +++++++++ .../src/conversation_surface/turn_row.rs | 15 ++++++++-- .../src/conversation_surface/work_motion.rs | 29 ++++++++++++++----- 5 files changed, 52 insertions(+), 10 deletions(-) diff --git a/modules/frontend/src/conversation_surface.rs b/modules/frontend/src/conversation_surface.rs index 6ee243994..f45a3a6fb 100644 --- a/modules/frontend/src/conversation_surface.rs +++ b/modules/frontend/src/conversation_surface.rs @@ -205,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, 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/transcript_window.rs b/modules/frontend/src/conversation_surface/transcript_window.rs index a6724f816..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. @@ -668,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 @@ -684,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, @@ -749,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 9041a6fc2..3802cd1e4 100644 --- a/modules/frontend/src/conversation_surface/turn_row.rs +++ b/modules/frontend/src/conversation_surface/turn_row.rs @@ -90,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, @@ -158,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, @@ -242,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, @@ -279,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; @@ -497,9 +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); + 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()); diff --git a/modules/frontend/src/conversation_surface/work_motion.rs b/modules/frontend/src/conversation_surface/work_motion.rs index 33cd39030..a70a7f518 100644 --- a/modules/frontend/src/conversation_surface/work_motion.rs +++ b/modules/frontend/src/conversation_surface/work_motion.rs @@ -134,7 +134,22 @@ pub(super) struct PhraseWord { impl WorkMotion { /// Starts one render of the row. - pub(super) fn begin_frame(&mut self, live: bool, motion: MotionPolicy) { + /// + /// 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, @@ -662,18 +677,18 @@ mod tests { #[test] fn a_live_phrase_keeps_one_layout_and_moves_on_change() { let mut motion = WorkMotion::default(); - motion.begin_frame(true, MotionPolicy::Full); + 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); + 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); + 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, .. })); @@ -683,14 +698,14 @@ mod tests { #[test] fn rows_on_screen_at_first_render_never_enter() { let mut motion = WorkMotion::default(); - motion.begin_frame(true, MotionPolicy::Full); + motion.begin_frame(true, MotionPolicy::Full, false); assert!(motion.entrance("old-row").is_none()); motion.end_frame(); - motion.begin_frame(true, MotionPolicy::Full); + 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); + motion.begin_frame(false, MotionPolicy::Full, false); assert!(motion.entrance("later-row").is_none()); } } From 379bc33f61e4e287fa044d62a3a307ae26af1a5d Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 20:00:03 +0200 Subject: [PATCH 34/40] conversation: show thinking traces whole and swap them in Claude's reasoning streams, so the status line's thinking summary rewrote itself as each trace arrived. The scene now takes a trace for the summary only once it is whole: no longer live, or followed by any later item. A new summary replacing the last one swaps in with the text swap recipe, fading in as it rises 4 px; the counting verb never swaps. Co-Authored-By: Claude Opus 5.5 --- .../frontend/src/conversation_scene/build.rs | 12 ++++++- .../src/conversation_surface/render_blocks.rs | 18 ++++++++++ .../src/conversation_surface/work_motion.rs | 35 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/modules/frontend/src/conversation_scene/build.rs b/modules/frontend/src/conversation_scene/build.rs index 484a311c9..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 diff --git a/modules/frontend/src/conversation_surface/render_blocks.rs b/modules/frontend/src/conversation_surface/render_blocks.rs index c934ad7fa..785d82373 100644 --- a/modules/frontend/src/conversation_surface/render_blocks.rs +++ b/modules/frontend/src/conversation_surface/render_blocks.rs @@ -65,6 +65,10 @@ 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 @@ -80,10 +84,24 @@ impl TurnRowView { 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. diff --git a/modules/frontend/src/conversation_surface/work_motion.rs b/modules/frontend/src/conversation_surface/work_motion.rs index a70a7f518..189ca7cf8 100644 --- a/modules/frontend/src/conversation_surface/work_motion.rs +++ b/modules/frontend/src/conversation_surface/work_motion.rs @@ -51,6 +51,8 @@ const SWAP: Duration = Duration::from_millis(150); 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); @@ -80,6 +82,8 @@ pub(super) struct WorkMotion { 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, } @@ -232,6 +236,37 @@ impl WorkMotion { 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) { From 3ff2db6aaeb25be10aeb24598d39394cea7f9f93 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 20:00:03 +0200 Subject: [PATCH 35/40] conversation: never wait on a provider that has already answered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live turn's status is derived afresh on every delivery, and only a streaming non-commentary reply, tool activity, or reasoning counted as the provider responding. Codex's commentary preamble, settled prose, and approval or question cards did not, so a turn that had replied and run a tool could fall back to "Waiting for Codex to respond…". Any prose or provider-produced fact now reads as thinking; only a turn with no response at all waits. Co-Authored-By: Claude Opus 5.5 --- .../impl_dispatch.rs | 62 ++++++++++++++++--- tests/frontend/conversation_state_machine.rs | 28 +++++++++ 2 files changed, 81 insertions(+), 9 deletions(-) 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/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()); From 1266e83af49444d13b78226f78bd6c4b4e2046fb Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 20:00:03 +0200 Subject: [PATCH 36/40] inspector: close every section with a grid rule Only the Context section closed with the full-width rule and crosshair, so the Agents, Checklist and Terminals sections ended open below. Each shown section now closes with the same rule, the column inset below its last row; a hidden section takes its rule with it. Co-Authored-By: Claude Opus 5.5 --- modules/frontend/src/thread_screen.rs | 32 ++++++++++++ modules/frontend/src/thread_screen/render.rs | 53 ++++++++++++++------ 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/modules/frontend/src/thread_screen.rs b/modules/frontend/src/thread_screen.rs index d7f45e622..b2a3ea163 100644 --- a/modules/frontend/src/thread_screen.rs +++ b/modules/frontend/src/thread_screen.rs @@ -126,6 +126,12 @@ pub const THREAD_SCREEN_INSPECTOR_SELECTOR: &str = "artisan-thread-screen-inspec 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"; @@ -1232,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()); @@ -1252,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 d3634d9b1..bbb7c06f6 100644 --- a/modules/frontend/src/thread_screen/render.rs +++ b/modules/frontend/src/thread_screen/render.rs @@ -490,8 +490,8 @@ impl ThreadScreen { /// intercept input. The titlebar end of that rule is the shell's /// junction crosshair, fed by [`ThreadScreen::visible_inspector_width`]. /// - /// A rule closes the Context section across the column's full width, so - /// the inspector reads as a grid with the titlebar rule above it; its + /// 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); @@ -502,15 +502,35 @@ impl ThreadScreen { .flex_col() .gap(section_gap) .child(self.render_context_section(theme)) - .child(Self::render_context_rule(width, rule, section_gap)); - 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); + .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() @@ -535,7 +555,7 @@ impl ThreadScreen { .child(sections) } - /// The full-width rule under the Context section. + /// 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 @@ -543,7 +563,12 @@ impl ThreadScreen { /// 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_context_rule(width: f32, rule: Pixels, section_gap: Pixels) -> impl IntoElement { + 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. @@ -557,7 +582,7 @@ impl ThreadScreen { .ml(px(-DESKTOP_COLUMN_INSET_PX)) .my(breathing) .bg(line.line) - .debug_selector(|| THREAD_SCREEN_CONTEXT_RULE_SELECTOR.to_owned()) + .debug_selector(move || selector.to_owned()) .child( junction_crosshair(line, rule) .left(-arm_offset) From 837f72f41fe5b84927fa5cf88575eeae2d44df16 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 20:11:25 +0200 Subject: [PATCH 37/40] usage: fill a context window the engine did not report from the catalog Claude reports how much context a run holds but never the window it fills, so its usage carried no denominator and the context meter could not paint. Usage results the Forge pushes or answers now take a missing window from the cached model catalog (Claude's discovery lists max_input_tokens). The lookup never probes, so a usage read never waits on an engine, and a reported window is never replaced. Co-Authored-By: Claude Opus 5.5 --- modules/backend/src/composer_state_handler.rs | 6 +++ .../backend/src/context_compaction_policy.rs | 38 +++++++++++++++++++ .../src/conversation_delivery_driver.rs | 1 + modules/backend/src/model_discovery/mod.rs | 12 ++++++ modules/domain/src/run_usage.rs | 12 ++++++ 5 files changed, 69 insertions(+) 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/model_discovery/mod.rs b/modules/backend/src/model_discovery/mod.rs index 833efc72b..c687f986f 100644 --- a/modules/backend/src/model_discovery/mod.rs +++ b/modules/backend/src/model_discovery/mod.rs @@ -157,6 +157,18 @@ fn cache() -> &'static Cache { CACHE.get_or_init(|| Mutex::new(None)) } +/// 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 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 { From 5de5979b23dac16c5e2a38f4ce42524a53c4198e Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 20:11:25 +0200 Subject: [PATCH 38/40] composer: track context usage for every engine and keep it when idle The Editor opened a usage scope only for OpenCode2 runs, so Claude, Codex and Grok reports were dropped as unscoped and the context meter never showed for them. A native engine's run now opens a scope that accepts its reports under the engine's own model and route ids, the thread and run being the attribution. The reading also outlives its run, so an idle thread keeps showing the last context reading instead of hiding the meter the moment the run settles. Co-Authored-By: Claude Opus 5.5 --- .../src/composer_queue_state/state.rs | 62 +++++++++++++++---- .../src/composer_queue_state/tests.rs | 31 ++++++++++ .../src/native_composer_queue_application.rs | 34 ++++++---- 3 files changed, 101 insertions(+), 26 deletions(-) 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/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); } From b3a95689013914d6b42db0ae7c60daffefdd3a91 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 20:21:59 +0200 Subject: [PATCH 39/40] claude: report the context window the turn ran in Claude's result frame names each model's contextWindow under modelUsage, but the usage report always carried no window, so the context meter fell back to the catalog at best. The result's report now carries the largest window it names (the root model's; subagents run on smaller ones), and keeps the last response's context gauge, which the result's totals lack, so the final reading stays whole. Co-Authored-By: Claude Opus 5.5 --- .../src/engine_owner/claude/adapter.rs | 14 ++++++++++++- .../src/engine_owner/claude/protocol.rs | 17 ++++++++++++--- .../backend/src/engine_owner/claude/usage.rs | 21 ++++++++++++++++++- tests/backend/engine_owner_claude.rs | 14 +++++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) 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/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}}"#, From 3fdf308862865297956c61fbeeac3a3b635d33b9 Mon Sep 17 00:00:00 2001 From: ss Date: Sat, 3 Oct 2026 20:21:59 +0200 Subject: [PATCH 40/40] inspector: clear the Agents section once the run settles The section kept the settled run's subagents indefinitely, so finished agents stayed listed with their completed dots after the thread went idle. Agents now list only while the thread's run is live; a settled run lists none, and a new run starts over. Co-Authored-By: Claude Opus 5.5 --- .../native_application/impl_route_surface.rs | 12 ++++++--- .../tests/thread_inspector.rs | 26 +++++++++++++++++-- .../src/native_composer_run_controls.rs | 12 +++++++++ modules/frontend/src/thread_agents.rs | 7 ++--- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/modules/frontend/src/native_application/impl_route_surface.rs b/modules/frontend/src/native_application/impl_route_surface.rs index c9fe1b975..f44154a34 100644 --- a/modules/frontend/src/native_application/impl_route_surface.rs +++ b/modules/frontend/src/native_application/impl_route_surface.rs @@ -252,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 @@ -305,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/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_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/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)]