From 7f11f04d033868be046cb3297ec575e119d1e120 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Mon, 10 Aug 2026 16:19:38 +0200 Subject: [PATCH 01/15] feat(serve-web, plugin): Make the web UI a chat client The web interface could browse conversations and nothing else. Reading one meant a terminal was still the only place to answer it, which made the UI a viewer rather than a client. It now composes and posts turns, watches them stream, stops them, and starts new conversations. Everything it does goes through the plugin protocol: it holds no credentials, drives no agent loop, and reads no storage directly, so it is a second frontend over the same core rather than a second implementation of it. Two host capabilities come with it. `output_format` on `init` tells a plugin how the host renders, so `jp --format json` reaches a plugin's output the way it reaches the host's own commands. `lock` on `events` says whether a turn is running and whether it is this connection's to interrupt, read from the conversation lock rather than the transcript, which cannot tell a running turn from an interrupted one. A lock whose recorded holder is no longer alive counts as free, so a killed run does not leave a conversation looking busy forever. Protocol 8, for `lock`. `output_format` defaults to text on an older host and needs no version of its own. This is a spike kept whole rather than split: it was built by using it, and the value in reviewing it is the shape of the thing, not the individual commits. Signed-off-by: Jean Mertz --- Cargo.lock | 6 + Cargo.toml | 1 + crates/jp_cli/src/cmd/plugin/dispatch.rs | 54 +- crates/jp_plugin/src/message.rs | 111 ++ crates/jp_plugin/src/message_tests.rs | 1 + crates/jp_plugin/src/protocol.rs | 3 +- crates/plugins/command/gui/src/main_tests.rs | 3 +- crates/plugins/command/serve-web/Cargo.toml | 8 +- crates/plugins/command/serve-web/README.md | 113 ++ .../plugins/command/serve-web/src/client.rs | 309 ++- .../command/serve-web/src/client_tests.rs | 95 + crates/plugins/command/serve-web/src/icon.svg | 13 + crates/plugins/command/serve-web/src/main.rs | 42 +- .../plugins/command/serve-web/src/render.rs | 41 + .../plugins/command/serve-web/src/routes.rs | 957 ++++++++- .../plugins/command/serve-web/src/style.css | 1029 +++++++++- crates/plugins/command/serve-web/src/style.rs | 23 + .../command/serve-web/src/views/detail.rs | 1739 ++++++++++++++++- .../command/serve-web/src/views/layout.rs | 54 +- .../command/serve-web/src/views/list.rs | 186 +- .../command/serve-web/src/views/list_tests.rs | 55 + .../command/serve-web/src/views/mod.rs | 1 + .../command/serve-web/src/views/new.rs | 115 ++ .../plugins/command/ticket/src/main_tests.rs | 3 +- justfile | 5 +- 25 files changed, 4858 insertions(+), 109 deletions(-) create mode 100644 crates/plugins/command/serve-web/README.md create mode 100644 crates/plugins/command/serve-web/src/icon.svg create mode 100644 crates/plugins/command/serve-web/src/views/list_tests.rs create mode 100644 crates/plugins/command/serve-web/src/views/new.rs diff --git a/Cargo.lock b/Cargo.lock index cb88f3395..c874e67fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -270,6 +270,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -283,6 +284,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", "tokio", "tower", @@ -2104,9 +2108,11 @@ dependencies = [ "axum", "chrono", "comrak", + "form_urlencoded", "jp_plugin", "maud", "pretty_assertions", + "serde", "serde_json", "sha2", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 49fe5c828..f19a73dd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,7 @@ dyn-clone = { version = "1", default-features = false } dyn-hash = { version = "1", default-features = false } eventsource-stream = { version = "0.2", default-features = false } fancy-regex = { version = "0.19", default-features = false } +form_urlencoded = { version = "1", default-features = false, features = ["alloc"] } futures = { version = "0.3", default-features = false } gemini_client_rs = { git = "https://github.com/JeanMertz/gemini-client", default-features = false } # gimli = { version = "0.33" } diff --git a/crates/jp_cli/src/cmd/plugin/dispatch.rs b/crates/jp_cli/src/cmd/plugin/dispatch.rs index 02612e36f..7ff21bb79 100644 --- a/crates/jp_cli/src/cmd/plugin/dispatch.rs +++ b/crates/jp_cli/src/cmd/plugin/dispatch.rs @@ -40,11 +40,12 @@ use jp_plugin::{ ComposeMode, ComposeOption, ComposeRequest, ComposeResponse, ConfigEntry, ConfigResponse, ConfigsResponse, ConversationSummary, ConversationsResponse, CreatedResponse, DescribeResponse, DoneResponse, DraftResponse, ErrorResponse, EventsResponse, HostToPlugin, - InitMessage, LogMessage, PathsInfo, PluginToHost, QueryCompleteResponse, QueryRequest, - SetTitleRequest, WorkspaceInfo, WriteDraftRequest, + InitMessage, LockState, LogMessage, OutputFormat as PluginOutputFormat, PathsInfo, + PluginToHost, QueryCompleteResponse, QueryRequest, SetTitleRequest, WorkspaceInfo, + WriteDraftRequest, }, }; -use jp_printer::Printer; +use jp_printer::{OutputFormat, Printer}; use jp_storage::backend::{FsStorageBackend, Projection}; use jp_workspace::{ConversationLock, LockResult, Workspace, session::Session}; use serde_json::Value; @@ -249,6 +250,7 @@ fn init_message( paths: PluginPaths<'_>, config: &Arc, log_level: u8, + format: OutputFormat, ) -> Result<(HostToPlugin, Value), cmd::Error> { let config_json = serde_json::to_value(config.as_ref().to_partial()) .map_err(|e| cmd::Error::from(format!("failed to serialize config: {e}")))?; @@ -276,6 +278,7 @@ fn init_message( options, args: args.to_vec(), log_level, + output_format: output_format(format), }); Ok((init, config_json)) @@ -313,6 +316,7 @@ pub(crate) async fn run_plugin( paths, &config, ctx.term.args.verbose, + ctx.printer.format(), )?; let composer = Composer { @@ -530,6 +534,19 @@ fn stop_plugin(stdin: &Mutex, sent: &AtomicBool, child_id: u32, grac kill_child(child_id); } +/// The host's output format, in the protocol's vocabulary. +/// +/// Two enums rather than one shared type: the protocol should not depend on a +/// particular renderer, so it carries its own. +fn output_format(format: OutputFormat) -> PluginOutputFormat { + match format { + OutputFormat::Text => PluginOutputFormat::Text, + OutputFormat::TextPretty => PluginOutputFormat::TextPretty, + OutputFormat::Json => PluginOutputFormat::Json, + OutputFormat::JsonPretty => PluginOutputFormat::JsonPretty, + } +} + /// The JP directories a plugin is told about, so it needs no platform logic of /// its own. fn well_known_paths(user_storage_path: Option<&Utf8Path>) -> PathsInfo { @@ -1655,13 +1672,44 @@ fn handle_read_events( jp_conversation::decode_event_value(value); } + // Carried here so labelling one conversation doesn't cost a plugin the whole + // conversation list, which reads every conversation's metadata. + let title = workspace + .metadata(&handle) + .ok() + .and_then(|meta| meta.title.clone()); + HostToPlugin::Events(EventsResponse { id: req_id, conversation: conversation_id.to_owned(), + lock: lock_state(workspace, &conv_id), + title, data: event_values, }) } +/// Whether a turn is running on a conversation, and whose it is. +/// +/// Read from the lock rather than from the transcript: a stream ending in a +/// request looks identical whether a turn is running, was interrupted, or +/// failed outright. +/// +/// A lock file outlives the process that wrote it when that process is killed, +/// so a recorded holder that is no longer alive counts as no holder at all. +/// Otherwise a crashed run would leave a conversation looking busy forever. +fn lock_state(workspace: &Workspace, id: &ConversationId) -> LockState { + workspace + .conversation_lock_info(id) + .filter(|info| is_process_alive(info.pid)) + .map_or(LockState::Free, |info| { + if info.pid == std::process::id() { + LockState::Here + } else { + LockState::Elsewhere + } + }) +} + fn handle_read_config( config_json: &Value, path: Option, diff --git a/crates/jp_plugin/src/message.rs b/crates/jp_plugin/src/message.rs index 22fdf8ada..b0c4299e9 100644 --- a/crates/jp_plugin/src/message.rs +++ b/crates/jp_plugin/src/message.rs @@ -189,6 +189,90 @@ impl PluginToHost { // --- Host-to-Plugin messages --- +/// How the host renders what it prints. +/// +/// A plugin reads this to decide the shape of its own output, so `jp --format +/// json` reaches a plugin's listings the way it reaches the host's own commands +/// and a caller does not have to learn a separate flag per plugin. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OutputFormat { + /// Plain text, with no ANSI colors and no unicode decoration. + #[default] + Text, + + /// Text with ANSI colors and unicode decoration. + TextPretty, + + /// Compact JSON, one line per print. + Json, + + /// Indented JSON. + JsonPretty, +} + +impl OutputFormat { + /// Whether output should be machine-readable. + #[must_use] + pub const fn is_json(self) -> bool { + matches!(self, Self::Json | Self::JsonPretty) + } + + /// Whether JSON output should be indented. + #[must_use] + pub const fn is_json_pretty(self) -> bool { + matches!(self, Self::JsonPretty) + } + + /// Whether text output can carry ANSI colors and unicode decoration. + #[must_use] + pub const fn is_pretty(self) -> bool { + matches!(self, Self::TextPretty) + } +} + +/// Who holds a conversation. +/// +/// A conversation is locked for the length of a turn, so this says whether one +/// is running, and whether it is the reader's to interrupt. +/// A turn in another process can be waited for but not signalled from here. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LockState { + /// Nobody. + /// No turn is running. + #[default] + Free, + + /// A turn in another process. + Elsewhere, + + /// A turn in the host answering this request. + Here, +} + +impl LockState { + /// Whether no turn is running. + /// + /// Takes a reference because `skip_serializing_if` calls it with one. + #[must_use] + pub const fn is_free(&self) -> bool { + matches!(self, Self::Free) + } + + /// Whether a turn is running, wherever it is. + #[must_use] + pub const fn is_held(&self) -> bool { + !self.is_free() + } + + /// Whether the running turn can be interrupted through this connection. + #[must_use] + pub const fn is_here(&self) -> bool { + matches!(self, Self::Here) + } +} + /// The `init` message sent to the plugin on startup. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct InitMessage { @@ -227,6 +311,18 @@ pub struct InitMessage { /// that stderr output matches the host's `-v` flags. #[serde(default)] pub log_level: u8, + + /// The shape the host's own output takes, resolved from `--format`. + /// + /// A plugin that prints listings or records should match it, so one flag + /// governs the whole invocation. + /// + /// Reads as [`OutputFormat::Text`] when the host is old enough not to send + /// it, which is the shape plugins printed before they could ask. + /// That fallback is why this needs no protocol version of its own: there is + /// nothing a plugin has to refuse to run without. + #[serde(default)] + pub output_format: OutputFormat, } /// Workspace metadata included in the `init` message. @@ -283,6 +379,21 @@ pub struct EventsResponse { /// The conversation ID. pub conversation: String, + /// Who holds this conversation, if anyone. + /// + /// Read from the conversation lock, which is the only authoritative answer: + /// a transcript ending in a request looks identical whether a turn is + /// running, was interrupted, or failed outright. + #[serde(default, skip_serializing_if = "LockState::is_free")] + pub lock: LockState, + + /// The conversation's title, if it has one. + /// + /// Saves a plugin from asking for the whole conversation list to label one + /// conversation, which reads every conversation's metadata. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Serialized conversation events. pub data: Vec, } diff --git a/crates/jp_plugin/src/message_tests.rs b/crates/jp_plugin/src/message_tests.rs index 1e213f52a..065eb9d3a 100644 --- a/crates/jp_plugin/src/message_tests.rs +++ b/crates/jp_plugin/src/message_tests.rs @@ -21,6 +21,7 @@ fn host_init_roundtrip() { options: Map::from_iter([("port".to_owned(), json!(8080))]), args: vec!["--web".to_owned()], log_level: 0, + output_format: OutputFormat::JsonPretty, }); let json = serde_json::to_string(&msg).unwrap(); diff --git a/crates/jp_plugin/src/protocol.rs b/crates/jp_plugin/src/protocol.rs index 9d4df5292..58758287d 100644 --- a/crates/jp_plugin/src/protocol.rs +++ b/crates/jp_plugin/src/protocol.rs @@ -16,7 +16,8 @@ use crate::message::{ExitMessage, ReadyMessage}; /// | 5 | `list_configs`, naming the configurations a query can select. | /// | 6 | `query`, with `created` and `query_complete` in reply. | /// | 7 | `interrupt`, for stopping a turn the host is running. | -pub const PROTOCOL_VERSION: u32 = 7; +/// | 8 | `lock` on `events`, saying whether a turn is running. | +pub const PROTOCOL_VERSION: u32 = 8; /// Answer a host's `init`, refusing it when it is too old to serve this plugin. /// diff --git a/crates/plugins/command/gui/src/main_tests.rs b/crates/plugins/command/gui/src/main_tests.rs index e8cc34395..b2bfe26a7 100644 --- a/crates/plugins/command/gui/src/main_tests.rs +++ b/crates/plugins/command/gui/src/main_tests.rs @@ -1,6 +1,6 @@ use std::{cell::RefCell, env, io::Cursor}; -use jp_plugin::message::{PathsInfo, ReadyMessage, WorkspaceInfo}; +use jp_plugin::message::{OutputFormat, PathsInfo, ReadyMessage, WorkspaceInfo}; use pretty_assertions::assert_eq; use super::*; @@ -48,6 +48,7 @@ fn init_message(root: &str, args: &[&str]) -> String { options: serde_json::Map::new(), args: args.iter().map(|a| (*a).to_owned()).collect(), log_level: 0, + output_format: OutputFormat::default(), }; format!( diff --git a/crates/plugins/command/serve-web/Cargo.toml b/crates/plugins/command/serve-web/Cargo.toml index bc959e27a..24cafaa8f 100644 --- a/crates/plugins/command/serve-web/Cargo.toml +++ b/crates/plugins/command/serve-web/Cargo.toml @@ -2,7 +2,7 @@ name = "jp-serve-web" authors.workspace = true -description = "Read-only web UI for browsing JP conversations." +description = "Web UI for browsing JP conversations and continuing them." documentation.workspace = true edition.workspace = true homepage.workspace = true @@ -15,10 +15,12 @@ version.workspace = true [dependencies] jp_plugin = { workspace = true } -axum = { workspace = true, features = ["http1", "tokio"] } +axum = { workspace = true, features = ["form", "http1", "json", "query", "tokio"] } chrono = { workspace = true } comrak = { workspace = true } +form_urlencoded = { workspace = true } maud = { workspace = true, features = ["axum"] } +serde = { workspace = true, features = ["derive", "std"] } serde_json = { workspace = true, features = ["std"] } sha2 = { workspace = true } tokio = { workspace = true } @@ -35,7 +37,7 @@ workspace = true [package.metadata.jp-registry] id = "serve-web" command = ["serve", "web"] -description = "Read-only web UI for browsing conversations" +description = "Web UI for browsing conversations and continuing them" official = true requires = ["serve"] repository = "https://github.com/dcdpr/jp" diff --git a/crates/plugins/command/serve-web/README.md b/crates/plugins/command/serve-web/README.md new file mode 100644 index 000000000..913525a81 --- /dev/null +++ b/crates/plugins/command/serve-web/README.md @@ -0,0 +1,113 @@ +# jp-serve-web + +A command plugin that serves JP conversations over HTTP, and lets you continue +them from a browser. + +Run it with `jp serve-web`. +The server is read-write: it renders the transcript, takes a message from a +composer, and asks the host to run the turn. + +```sh +jp serve-web --bind 127.0.0.1 --port 3000 +``` + +## What it does and does not own + +The plugin is a presentation layer. +It never talks to a model, holds a credential, executes a tool, or writes to a +conversation. +Everything it shows it asked the host for, and every turn it starts the host +runs. + +That split is the reason the protocol exists. +A plugin that ran its own agent loop would need the user's API keys, the tool +registry, the MCP servers, and a second copy of the turn loop to keep in step +with the first. + +| Concern | Owner | +| --------------------------- | ------ | +| Rendering, routing, styling | Plugin | +| Conversation storage | Host | +| Config resolution | Host | +| Model calls and tool runs | Host | +| Interrupting a turn | Host | + +## Protocol + +Needs protocol 7 (`REQUIRED_PROTOCOL`). +The host refuses an older pairing at the handshake rather than failing later, so +a stale `jp` alongside a fresh plugin is an error message and not a mystery. + +| Message | Direction | Used for | +| -------------------- | --------- | ------------------------------------------------ | +| `list_conversations` | → host | The conversation index | +| `read_events` | → host | One conversation's transcript and title | +| `list_configs` | → host | The configurations a new conversation can name | +| `query` | → host | Start a turn, or start a conversation | +| `created` | ← host | The id of a conversation just created | +| `query_complete` | ← host | That turn finished | +| `interrupt` | → host | Stop the turn on one named conversation | +| `read_draft` | → host | The message being composed, as the CLI stores it | +| `write_draft` | → host | Save it back, conditional on a revision | + +Starting a conversation is answered twice: `created` as soon as there is +somewhere to send the reader, and `query_complete` when the first turn ends. +The client registers both waiters before sending, because a turn that finishes +quickly would otherwise arrive before anything was listening for it. + +## How the page stays current + +There is no push channel yet, so the page polls `/conversations/{id}/messages` +every second while a turn is running and every three when it isn't. +The endpoint returns an event count and the rendered transcript; the page swaps +its contents only when the count moves, so reading isn't interrupted on every +tick. + +The host re-reads the conversation from disk on each request, which means a turn +you started in a terminal shows up in the browser too, without a restart. + +Events arrive in batches rather than token by token: the turn loop persists at +each streaming boundary, so a page sees a complete assistant response or tool +call at a time. +Per-token updates need the host to push, which is future work. + +Everything on the page works without JavaScript except the polling. +The composer and the stop button are plain form posts, and the transcript is +server-rendered. + +## Endpoints + +| Path | Method | Purpose | +| ------------------------------- | ------ | -------------------------------- | +| `/conversations` | GET | Index | +| `/conversations/{id}` | GET | Transcript and composer | +| `/conversations/{id}/turn` | POST | Start a turn | +| `/conversations/{id}/messages` | GET | Transcript as JSON, for the poll | +| `/conversations/{id}/interrupt` | POST | Stop the running turn | +| `/status` | GET | Whether a turn is in flight | + +`/status` exists for whoever supervises the process: restarting to pick up a new +build aborts a turn in flight, so a supervisor polls it and waits for `busy` to +go false. +`just serve-web-watch` does exactly that. + +## Security + +No authentication, and every conversation in the workspace is readable. +Anyone who can reach the port can also start a turn, which spends tokens and +runs whatever tools the conversation allows. + +Binding to a non-loopback address hands that to the network. +The plugin warns on startup when you do. + +## Development + +```sh +just serve-web-watch --bind 0.0.0.0 --port 3001 +``` + +Rebuilds on any change under `crates/` and restarts once no turn is running. +A plain file watcher can't be used here: a turn started from the browser runs +inside the host process the plugin is attached to, so restarting on save aborts +whatever the assistant was in the middle of — including the assistant editing +these files. diff --git a/crates/plugins/command/serve-web/src/client.rs b/crates/plugins/command/serve-web/src/client.rs index ce99bd975..862cacfd3 100644 --- a/crates/plugins/command/serve-web/src/client.rs +++ b/crates/plugins/command/serve-web/src/client.rs @@ -5,7 +5,7 @@ //! Thread-safe and shareable across axum handlers via `Arc`. use std::{ - collections::HashMap, + collections::{HashMap, VecDeque}, io::{BufRead, Write}, sync::{ Arc, Mutex, @@ -16,8 +16,9 @@ use std::{ }; use jp_plugin::message::{ - ConversationSummary, EventsResponse, ExitMessage, HostToPlugin, OptionalId, PluginToHost, - ReadEventsRequest, + ConfigEntry, ConversationRequest, ConversationSummary, DraftResponse, EventsResponse, + ExitMessage, HostToPlugin, InterruptRequest, OptionalId, PluginToHost, QueryRequest, + ReadEventsRequest, SetTitleRequest, WriteDraftRequest, }; use tokio::sync::{oneshot, watch}; use tracing::{debug, error, trace, warn}; @@ -32,6 +33,15 @@ pub type SharedWriter = Arc>>; /// forever, which would otherwise stall graceful shutdown. const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +/// How long a delegated turn is given before the request is abandoned. +/// +/// A turn runs the whole agent loop: the model thinks, tools run, the model +/// thinks again. +/// Minutes are normal, so this is generous — it exists to stop a lost response +/// from pinning a browser connection open forever, not to bound how long the +/// assistant may take. +const QUERY_TIMEOUT: Duration = Duration::from_mins(15); + /// A protocol client that talks to the JP host over stdin/stdout. /// /// Cloneable via `Arc` internally — pass it into axum state directly. @@ -40,9 +50,41 @@ pub struct PluginClient { inner: Arc, } +/// The still-running turn a newly created conversation was started with. +/// +/// Held by whoever needs to know when that turn ends — which is not the +/// request that created the conversation, since it returned as soon as there +/// was somewhere to send the reader. +pub struct TurnOutcome { + rx: oneshot::Receiver, +} + +impl TurnOutcome { + /// Wait for the turn to finish. + /// + /// Takes as long as the turn does, which can be minutes. + pub async fn finished(self) -> Result<(), ClientError> { + match tokio::time::timeout(QUERY_TIMEOUT, self.rx).await { + Ok(Ok(HostToPlugin::QueryComplete(_))) => Ok(()), + Ok(Ok(HostToPlugin::Error(e))) => Err(ClientError::Host(e.message)), + Ok(Ok(other)) => Err(ClientError::Unexpected(format!("{other:?}"))), + Ok(Err(_)) => Err(ClientError::ChannelClosed), + Err(_) => Err(ClientError::Timeout), + } + } +} + struct Inner { writer: SharedWriter, - pending: Mutex>>, + + /// Waiters per request, in the order their replies are expected. + /// + /// A queue rather than one waiter, because a request can be answered more + /// than once: starting a conversation is told the id as soon as it exists + /// and told again when its first turn ends. + /// Both waiters are registered before the request goes out, so a turn that + /// finishes quickly cannot arrive before anything is listening for it. + pending: Mutex>>>, next_id: AtomicU64, } @@ -103,6 +145,195 @@ impl PluginClient { } } + /// Ask the host to run a turn on a conversation. + /// + /// Returns once the turn has finished and its events are persisted; read + /// them back with [`Self::read_events`]. + /// The host owns the agent loop, so this resolves the model, calls the + /// provider, and runs tools without the plugin seeing any of it. + pub async fn query( + &self, + conversation: &str, + content: &str, + cfg: Vec, + ) -> Result<(), ClientError> { + let id = self.next_id(); + let msg = PluginToHost::Query(QueryRequest { + new: false, + title: None, + cfg, + id: Some(id.clone()), + conversation: conversation.to_owned(), + content: content.to_owned(), + }); + + match self.request_within(&id, &msg, QUERY_TIMEOUT).await? { + HostToPlugin::QueryComplete(_) => Ok(()), + HostToPlugin::Error(e) => Err(ClientError::Host(e.message)), + other => Err(ClientError::Unexpected(format!("{other:?}"))), + } + } + + /// List the configurations a new conversation can be started with. + pub async fn list_configs(&self) -> Result, ClientError> { + let id = self.next_id(); + let msg = PluginToHost::ListConfigs(OptionalId { + id: Some(id.clone()), + }); + + match self.request(&id, &msg).await? { + HostToPlugin::Configs(resp) => Ok(resp.data), + HostToPlugin::Error(e) => Err(ClientError::Host(e.message)), + other => Err(ClientError::Unexpected(format!("{other:?}"))), + } + } + + /// Start a conversation and set its first turn running. + /// + /// Returns the id the host gave it, which is the only place that id exists: + /// the conversation did not exist when the request was sent. + /// + /// Returns as soon as the conversation exists, not when the turn finishes. + /// The turn's progress is in the conversation's events, which is where a + /// reader sent to it will be looking anyway. + pub async fn start_conversation( + &self, + content: &str, + title: Option, + cfg: Vec, + ) -> Result<(String, TurnOutcome), ClientError> { + let id = self.next_id(); + let msg = PluginToHost::Query(QueryRequest { + id: Some(id.clone()), + conversation: String::new(), + content: content.to_owned(), + new: true, + title, + cfg, + }); + + // Both waiters before the request goes out. Registering the second one + // after the first reply arrives would race a turn that finished in between, + // and a lost completion leaves the conversation marked busy forever. + let created = self.register(&id); + let finished = self.register(&id); + + if let Err(error) = self.send(&msg) { + self.forget(&id); + return Err(error); + } + + // The default timeout, not the turn's: the host answers as soon as the + // conversation exists, without waiting for its first turn. + let conversation = match tokio::time::timeout(REQUEST_TIMEOUT, created).await { + Ok(Ok(HostToPlugin::Created(resp))) => resp.conversation, + Ok(Ok(HostToPlugin::Error(e))) => { + self.forget(&id); + return Err(ClientError::Host(e.message)); + } + Ok(Ok(other)) => { + self.forget(&id); + return Err(ClientError::Unexpected(format!("{other:?}"))); + } + Ok(Err(_)) => { + self.forget(&id); + return Err(ClientError::ChannelClosed); + } + Err(_) => { + self.forget(&id); + return Err(ClientError::Timeout); + } + }; + + Ok((conversation, TurnOutcome { rx: finished })) + } + + /// Move a conversation to the archive. + pub async fn archive(&self, conversation: &str) -> Result<(), ClientError> { + let id = self.next_id(); + let msg = PluginToHost::ArchiveConversation(ConversationRequest { + id: Some(id.clone()), + conversation: conversation.to_owned(), + }); + + self.done(&id, &msg).await + } + + /// Rename a conversation. + /// An empty title clears it. + pub async fn set_title(&self, conversation: &str, title: &str) -> Result<(), ClientError> { + let id = self.next_id(); + let msg = PluginToHost::SetTitle(SetTitleRequest { + id: Some(id.clone()), + conversation: conversation.to_owned(), + title: Some(title.to_owned()), + }); + + self.done(&id, &msg).await + } + + /// Send a request whose only answer is whether it worked. + async fn done(&self, id: &str, msg: &PluginToHost) -> Result<(), ClientError> { + match self.request(id, msg).await? { + HostToPlugin::Done(_) => Ok(()), + HostToPlugin::Error(e) => Err(ClientError::Host(e.message)), + other => Err(ClientError::Unexpected(format!("{other:?}"))), + } + } + + /// Read a conversation's query draft. + pub async fn read_draft(&self, conversation: &str) -> Result { + let id = self.next_id(); + let msg = PluginToHost::ReadDraft(ConversationRequest { + id: Some(id.clone()), + conversation: conversation.to_owned(), + }); + + match self.request(&id, &msg).await? { + HostToPlugin::Draft(resp) => Ok(resp), + HostToPlugin::Error(e) => Err(ClientError::Host(e.message)), + other => Err(ClientError::Unexpected(format!("{other:?}"))), + } + } + + /// Replace a conversation's query draft, if it still matches `revision`. + /// + /// A refusal comes back as a [`DraftResponse`] with `conflict` set and the + /// current draft attached, rather than as an error: the caller needs the + /// other side's text to do anything sensible about it. + pub async fn write_draft( + &self, + conversation: &str, + content: &str, + revision: Option, + ) -> Result { + let id = self.next_id(); + let msg = PluginToHost::WriteDraft(WriteDraftRequest { + id: Some(id.clone()), + conversation: conversation.to_owned(), + content: content.to_owned(), + revision, + }); + + match self.request(&id, &msg).await? { + HostToPlugin::Draft(resp) => Ok(resp), + HostToPlugin::Error(e) => Err(ClientError::Host(e.message)), + other => Err(ClientError::Unexpected(format!("{other:?}"))), + } + } + + /// Ask the host to interrupt the turn it is running. + /// + /// Returns as soon as the request is on the wire. + /// There is no reply to wait for: the interrupt lands in the conversation, + /// and the turn's own outcome still arrives as the answer to the `query` + /// that started it. + pub fn interrupt(&self, conversation: &str) -> Result<(), ClientError> { + self.send(&PluginToHost::Interrupt(InterruptRequest { + conversation: conversation.to_owned(), + })) + } + /// Register a request, send it, and await the matching response. /// /// Removes the pending entry on a transport failure (send error or timeout) @@ -111,10 +342,20 @@ impl PluginClient { /// leaves nothing to remove, so the cleanup here targets only the /// transport-error paths. async fn request(&self, id: &str, msg: &PluginToHost) -> Result { + self.request_within(id, msg, REQUEST_TIMEOUT).await + } + + /// [`Self::request`], with a deadline of the caller's choosing. + async fn request_within( + &self, + id: &str, + msg: &PluginToHost, + timeout: Duration, + ) -> Result { let rx = self.register(id); let result = match self.send(msg) { - Ok(()) => await_response(rx).await, + Ok(()) => await_response(rx, timeout).await, Err(e) => Err(e), }; @@ -147,10 +388,21 @@ impl PluginClient { .pending .lock() .expect("pending lock poisoned") - .insert(id.to_owned(), tx); + .entry(id.to_owned()) + .or_default() + .push_back(tx); rx } + /// Drop every waiter for a request that will never be answered again. + fn forget(&self, id: &str) { + self.inner + .pending + .lock() + .expect("pending lock poisoned") + .remove(id); + } + fn send(&self, msg: &PluginToHost) -> Result<(), ClientError> { let json = serde_json::to_string(msg).map_err(|e| ClientError::Protocol(e.to_string()))?; let mut writer = self.inner.writer.lock().expect("writer lock poisoned"); @@ -178,8 +430,11 @@ pub enum ClientError { /// Await a pending response, failing with [`ClientError`] on a closed channel /// or timeout instead of blocking forever. -async fn await_response(rx: oneshot::Receiver) -> Result { - tokio::time::timeout(REQUEST_TIMEOUT, rx) +async fn await_response( + rx: oneshot::Receiver, + timeout: Duration, +) -> Result { + tokio::time::timeout(timeout, rx) .await .map_err(|_| ClientError::Timeout)? .map_err(|_| ClientError::ChannelClosed) @@ -227,6 +482,11 @@ fn reader_loop(reader: impl BufRead, inner: &Inner, shutdown_tx: &watch::Sender< HostToPlugin::Conversations(r) => r.id.clone(), HostToPlugin::Events(r) => r.id.clone(), HostToPlugin::Config(r) => r.id.clone(), + HostToPlugin::QueryComplete(r) => r.id.clone(), + HostToPlugin::Configs(r) => r.id.clone(), + HostToPlugin::Created(r) => r.id.clone(), + HostToPlugin::Done(r) => r.id.clone(), + HostToPlugin::Draft(r) => r.id.clone(), HostToPlugin::Error(r) => r.id.clone(), _ => None, }; @@ -237,25 +497,21 @@ fn reader_loop(reader: impl BufRead, inner: &Inner, shutdown_tx: &watch::Sender< let _ = shutdown_tx.send(true); } - HostToPlugin::Init(_) | HostToPlugin::Describe => { + // `Composed` answers a `Compose` request, which this plugin never + // sends: it serves HTTP and has no prompts to raise. + HostToPlugin::Init(_) | HostToPlugin::Describe | HostToPlugin::Composed(_) => { warn!("Unexpected message after startup"); } - // This plugin only reads, so neither of these answers a request it - // sent: they belong to something that isn't ours. - HostToPlugin::Composed(_) - | HostToPlugin::Done(_) - | HostToPlugin::Draft(_) - | HostToPlugin::Configs(_) - | HostToPlugin::QueryComplete(_) - | HostToPlugin::Created(_) => { - warn!(?msg, "Received a response to a request we never sent"); - } - // Response messages — dispatch to the pending request. msg @ (HostToPlugin::Conversations(_) | HostToPlugin::Events(_) | HostToPlugin::Config(_) + | HostToPlugin::QueryComplete(_) + | HostToPlugin::Configs(_) + | HostToPlugin::Created(_) + | HostToPlugin::Done(_) + | HostToPlugin::Draft(_) | HostToPlugin::Error(_)) => { dispatch(&inner.pending, req_id.as_deref(), msg); } @@ -273,7 +529,7 @@ fn reader_loop(reader: impl BufRead, inner: &Inner, shutdown_tx: &watch::Sender< /// Dispatch a response to the pending request with the given ID. fn dispatch( - pending: &Mutex>>, + pending: &Mutex>>>, id: Option<&str>, msg: HostToPlugin, ) { @@ -282,7 +538,16 @@ fn dispatch( return; }; - let tx = pending.lock().expect("pending lock poisoned").remove(id); + // Taken in order, and the entry removed once its last waiter is served, so an + // id that expects one reply behaves exactly as it did before. + let tx = { + let mut pending = pending.lock().expect("pending lock poisoned"); + let tx = pending.get_mut(id).and_then(VecDeque::pop_front); + if pending.get(id).is_some_and(VecDeque::is_empty) { + pending.remove(id); + } + tx + }; match tx { Some(tx) => { diff --git a/crates/plugins/command/serve-web/src/client_tests.rs b/crates/plugins/command/serve-web/src/client_tests.rs index ad6e17cd2..e2650a180 100644 --- a/crates/plugins/command/serve-web/src/client_tests.rs +++ b/crates/plugins/command/serve-web/src/client_tests.rs @@ -43,6 +43,99 @@ fn feed_after_register(client: &PluginClient, tx: std::sync::mpsc::Sender, l }); } +/// Two replies to one request reach two waiters, in the order they registered. +/// +/// Starting a conversation is answered twice: once with the id as soon as it +/// exists, and again when its first turn ends. +/// The second reply is what clears the turn from the busy map, so a dispatcher +/// that served only the first would leave the conversation marked running for +/// the life of the process. +#[tokio::test] +async fn two_replies_to_one_request_reach_both_waiters() { + let (client, _tx) = channel_client(); + + let first = client.register("7"); + let second = client.register("7"); + + dispatch( + &client.inner.pending, + Some("7"), + HostToPlugin::Created(CreatedResponse { + id: Some("7".to_owned()), + conversation: "jp-c1".to_owned(), + }), + ); + dispatch( + &client.inner.pending, + Some("7"), + HostToPlugin::QueryComplete(QueryCompleteResponse { + id: Some("7".to_owned()), + conversation: "jp-c1".to_owned(), + }), + ); + + assert!( + matches!(first.await, Ok(HostToPlugin::Created(_))), + "the first waiter gets the first reply" + ); + assert!( + matches!(second.await, Ok(HostToPlugin::QueryComplete(_))), + "the second waiter gets the second, rather than the first being served twice or the \ + second being dropped" + ); + + assert!( + client.inner.pending.lock().unwrap().is_empty(), + "the request is forgotten once its last waiter is served" + ); +} + +/// One waiter still behaves as it always did. +/// +/// The queue is only there for requests answered more than once; every other +/// request registers one waiter and must be cleaned up by the reply that serves +/// it, not left behind for a second that never comes. +#[tokio::test] +async fn a_single_reply_still_clears_its_request() { + let (client, _tx) = channel_client(); + + let only = client.register("3"); + + dispatch( + &client.inner.pending, + Some("3"), + HostToPlugin::QueryComplete(QueryCompleteResponse { + id: Some("3".to_owned()), + conversation: "jp-c1".to_owned(), + }), + ); + + assert!(matches!(only.await, Ok(HostToPlugin::QueryComplete(_)))); + assert!( + client.inner.pending.lock().unwrap().is_empty(), + "a request with one waiter is forgotten when that waiter is served" + ); +} + +/// Abandoning a request drops every waiter it registered. +/// +/// The error paths in `start_conversation` register two and may bail after the +/// first; leaving the second behind would keep a sender alive for a reply that +/// is never coming. +#[tokio::test] +async fn forgetting_a_request_drops_all_of_its_waiters() { + let (client, _tx) = channel_client(); + + let first = client.register("9"); + let second = client.register("9"); + + client.forget("9"); + + assert!(client.inner.pending.lock().unwrap().is_empty()); + assert!(first.await.is_err(), "a dropped sender closes its channel"); + assert!(second.await.is_err()); +} + #[tokio::test] async fn list_conversations_roundtrip() { let response = HostToPlugin::Conversations(ConversationsResponse { @@ -68,6 +161,8 @@ async fn list_conversations_roundtrip() { #[tokio::test] async fn read_events_roundtrip() { let response = HostToPlugin::Events(EventsResponse { + lock: jp_plugin::message::LockState::Free, + title: None, id: Some("1".to_owned()), conversation: "456".to_owned(), data: vec![json!({"type": "turn_start", "timestamp": "2025-01-01T00:00:00Z"})], diff --git a/crates/plugins/command/serve-web/src/icon.svg b/crates/plugins/command/serve-web/src/icon.svg new file mode 100644 index 000000000..9b159d382 --- /dev/null +++ b/crates/plugins/command/serve-web/src/icon.svg @@ -0,0 +1,13 @@ + + + jp + diff --git a/crates/plugins/command/serve-web/src/main.rs b/crates/plugins/command/serve-web/src/main.rs index 45082bfa7..e45b8e7bc 100644 --- a/crates/plugins/command/serve-web/src/main.rs +++ b/crates/plugins/command/serve-web/src/main.rs @@ -1,9 +1,11 @@ -//! `jp-serve-web`: read-only web UI plugin for JP. +//! `jp-serve-web`: web UI plugin for JP. //! //! Communicates with the `jp` host over the JSON-lines plugin protocol -//! (stdin/stdout) and serves a read-only conversation browser over HTTP. +//! (stdin/stdout) and serves a conversation browser over HTTP. +//! Turns composed in the browser are delegated to the host, which owns the +//! agent loop. //! -//! See: `docs/rfd/D17-command-plugin-system.md` +//! See: `docs/rfd/072-command-plugin-system.md` mod client; mod log_layer; @@ -28,27 +30,35 @@ use crate::{ /// The protocol version this plugin needs from the host. /// -/// It reads conversations, events, and config, all of which the first version -/// carries. -const REQUIRED_PROTOCOL: u32 = 1; +/// It archives and renames conversations (3), syncs what is being typed through +/// the draft messages (4), offers the configurations a turn can name (5), posts +/// turns with `query` and learns their id from `created` (6), stops them with +/// `interrupt` (7), and reads whether a turn is already running from `lock` on +/// `events` (8). +/// +/// The last is what makes 8 the floor rather than 7: defaulting `lock` to free +/// would draw a send button for a conversation that is busy, and the request +/// behind it would be refused as already-locked. +const REQUIRED_PROTOCOL: u32 = 8; const HELP_TEXT: &str = "\ -Start the read-only web interface for browsing JP conversations. +Start the web interface for browsing JP conversations and continuing them. -Usage: jp serve web [OPTIONS] +Usage: jp serve-web [OPTIONS] Options: --bind Address to bind to [default: 127.0.0.1] --port Port to listen on [default: 3000] Configuration (in .jp/config.toml): - [plugins.command.serve.options] + [plugins.command.serve-web.options] bind = \"127.0.0.1\" port = 8080 The server has no authentication and exposes every conversation in the -workspace. Binding to a non-loopback address (e.g. 0.0.0.0) makes all of them -reachable from the network."; +workspace, and anyone who reaches it can start a turn, which spends tokens and +runs whatever tools the conversation allows. Binding to a non-loopback address +(e.g. 0.0.0.0) hands that to the network."; fn main() { let log_handle = init_tracing(); @@ -61,7 +71,7 @@ fn main() { drop(writeln!(err)); drop(writeln!( err, - "Note: this binary is a JP plugin. Run it via `jp serve web`." + "Note: this binary is a JP plugin. Run it via `jp serve-web`." )); std::process::exit(0); } @@ -142,7 +152,8 @@ fn run_server( if !is_loopback { warn!( %socket_addr, - "Binding to a non-loopback address exposes all conversations without authentication" + "Binding to a non-loopback address exposes all conversations, and lets anyone who \ + reaches it start a turn, without authentication" ); } @@ -168,7 +179,8 @@ fn run_server( &mut stdout, &PluginToHost::Print(PrintMessage { text: "Warning: bound to a non-loopback address; every conversation in this \ - workspace is reachable over the network without authentication.\n" + workspace is readable over the network without authentication, and anyone \ + who reaches it can start a turn.\n" .into(), channel: "content".into(), format: "plain".into(), @@ -225,7 +237,7 @@ fn send_describe(stdout: &mut impl Write) -> Result<(), String> { &PluginToHost::Describe(DescribeResponse { name: "serve-web".to_owned(), version: env!("CARGO_PKG_VERSION").to_owned(), - description: "Read-only web UI for browsing conversations".to_owned(), + description: "Web UI for browsing conversations and continuing them".to_owned(), command: vec!["serve".to_owned(), "web".to_owned()], author: Some("Jean Mertz ".to_owned()), help: Some(HELP_TEXT.to_owned()), diff --git a/crates/plugins/command/serve-web/src/render.rs b/crates/plugins/command/serve-web/src/render.rs index 9b7c6f8e5..9745f6053 100644 --- a/crates/plugins/command/serve-web/src/render.rs +++ b/crates/plugins/command/serve-web/src/render.rs @@ -31,6 +31,47 @@ pub(crate) enum RenderedEvent { }, } +/// The first event that can still change, or the end if none can. +/// +/// A tool call is rendered when it is requested and gains its result later, so +/// its entry is not final the moment it appears. +/// Anything from here on has to be sent again rather than assumed unchanged — +/// without this, a caller that only ever appends keeps the request and never +/// learns the answer. +pub(crate) fn settled_upto(events: &[RenderedEvent]) -> usize { + events + .iter() + .position(|event| matches!(event, RenderedEvent::ToolCall { result: None, .. })) + .unwrap_or(events.len()) +} + +/// Whether the conversation is waiting on the assistant. +/// +/// True when the last thing in the transcript is the user's message, or a tool +/// call with no result yet. +/// Read from the transcript rather than from any bookkeeping, so it holds for a +/// turn started from another process, and survives this server restarting +/// mid-turn. +/// +/// A turn that was interrupted and never resumed looks the same as one still +/// running. +/// Both are "the assistant owes you a reply", which is what the page reports, +/// so the conflation is honest rather than merely convenient. +pub(crate) fn awaiting_response(events: &[RenderedEvent]) -> bool { + events + .iter() + .rev() + .find(|event| !matches!(event, RenderedEvent::TurnSeparator)) + .is_some_and(|event| match event { + RenderedEvent::UserMessage { .. } => true, + RenderedEvent::ToolCall { result, .. } => result.is_none(), + RenderedEvent::AssistantMessage { .. } + | RenderedEvent::Reasoning { .. } + | RenderedEvent::Structured { .. } + | RenderedEvent::TurnSeparator => false, + }) +} + /// Which kind of text a [`PendingText`] region holds. #[derive(Clone, Copy, PartialEq)] enum TextKind { diff --git a/crates/plugins/command/serve-web/src/routes.rs b/crates/plugins/command/serve-web/src/routes.rs index ffce15d60..08bd1a9b1 100644 --- a/crates/plugins/command/serve-web/src/routes.rs +++ b/crates/plugins/command/serve-web/src/routes.rs @@ -1,16 +1,22 @@ //! Axum router and HTTP handlers. -use std::future::Future; +use std::{ + collections::HashMap, + future::Future, + sync::{Arc, Mutex}, +}; use axum::{ - Router, - extract::{Path, State}, + Form, Json, Router, + extract::{Path, Query, State}, http::{StatusCode, header}, response::{IntoResponse, Redirect, Response}, }; +use jp_plugin::message::LockState; use maud::Markup; +use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; -use tracing::{debug, info}; +use tracing::{debug, error, info}; use crate::{ client::{ClientError, PluginClient}, @@ -21,6 +27,78 @@ use crate::{ #[derive(Clone)] struct AppState { client: PluginClient, + + /// What each conversation's most recent delegated turn is doing. + /// + /// A turn outlives the request that started it, so its outcome has to live + /// somewhere the polling endpoint can find it. + turns: Arc>>, + + /// Identifies this run of the server. + /// + /// A page polls it and can tell that the process it loaded from has been + /// replaced, which is the only way it can know its own markup and styles + /// are out of date. + /// Data recovers on its own; the page itself does not. + boot: String, +} + +/// The state of a turn started from the browser. +#[derive(Debug, Clone)] +enum TurnStatus { + /// The host is working on it. + /// + /// `pending` is the message the browser submitted, held until it shows up + /// in the transcript. + /// The host appends the request only after it has waited for MCP servers + /// and resolved tools, so there are a few seconds where the turn is + /// underway and the conversation has no record of what was asked. + /// Showing it from here closes that gap without moving the host's commit + /// point. + Running { + pending: Option, + /// Which client asked for it, when one said. + /// + /// Kept here rather than on the lock: this distinction never leaves the + /// process, so it is nobody else's business. + /// Another peer only needs to know the turn is this server's, which the + /// lock already says. + client: Option, + }, + + /// It failed, and nobody has been told yet. + Failed(String), +} + +/// What the page needs to know about a turn this server started. +struct TurnView { + running: bool, + error: Option, + pending: Option, + client: Option, +} + +/// What stopping the running turn would take. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum StopMode { + /// Nothing to stop. + None, + + /// The asker started it. + /// Stopping is theirs to do. + Own, + + /// This server is running it, for somebody else. + /// Stoppable, with a warning: the work belongs to another window, and they + /// get no say. + Shared, + + /// Another process entirely. + /// There is no way to reach it from here — a signal would run that + /// process's own interrupt policy, which may be to prompt a terminal nobody + /// is watching. + Unreachable, } /// Start the HTTP server on an already-bound listener and block until @@ -30,7 +108,13 @@ pub(crate) async fn serve( listener: std::net::TcpListener, shutdown: impl Future + Send + 'static, ) -> Result<(), String> { - let state = AppState { client }; + let state = AppState { + client, + turns: Arc::new(Mutex::new(HashMap::new())), + boot: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or_else(|_| "unknown".to_owned(), |d| d.as_millis().to_string()), + }; let app = Router::new() .route("/", axum::routing::get(index)) @@ -39,7 +123,34 @@ pub(crate) async fn serve( "/conversations/{id}", axum::routing::get(conversation_detail), ) + .route("/conversations/{id}/turn", axum::routing::post(start_turn)) + .route("/conversations/{id}/messages", axum::routing::get(messages)) + .route( + "/conversations/{id}/interrupt", + axum::routing::post(interrupt), + ) + .route( + "/conversations/new", + axum::routing::get(new_conversation_form).post(start_conversation), + ) + .route( + "/conversations/{id}/draft", + axum::routing::get(read_draft).post(write_draft), + ) + .route( + "/conversations/count", + axum::routing::get(conversation_count), + ) + .route( + "/conversations/{id}/archive", + axum::routing::post(archive_conversation), + ) + .route("/conversations/{id}/title", axum::routing::post(set_title)) + .route("/configs", axum::routing::get(list_configs)) + .route("/status", axum::routing::get(status)) .route("/assets/style.css", axum::routing::get(serve_css)) + .route("/assets/icon.svg", axum::routing::get(serve_icon)) + .route("/manifest.webmanifest", axum::routing::get(serve_manifest)) .with_state(state); let local_addr = listener.local_addr().ok(); @@ -74,13 +185,801 @@ async fn conversation_list(State(state): State) -> Result, +} + +async fn status(State(state): State) -> Json { + let turns: Vec = state + .turns + .lock() + .expect("turns lock poisoned") + .iter() + .filter(|(_, status)| matches!(status, TurnStatus::Running { .. })) + .map(|(id, _)| id.clone()) + .collect(); + + Json(StatusBody { + busy: !turns.is_empty(), + turns, + }) +} + +/// A new turn, as posted by the composer form. +/// +/// Read from decoded pairs rather than through `Form`, for the same reason the +/// new-conversation form is: a set of checkboxes sharing a name posts that name +/// once per ticked box, and the urlencoded deserialiser cannot collect repeats. +#[derive(Debug, Default)] +struct TurnForm { + content: String, + cfg: Vec, + client: Option, +} + +impl TurnForm { + fn parse(body: &str) -> Self { + let mut form = Self::default(); + + for (key, value) in form_urlencoded::parse(body.as_bytes()) { + match key.as_ref() { + "content" => form.content = value.into_owned(), + "cfg" => form.cfg.push(value.into_owned()), + // Without this the turn is recorded unattributed, and the page + // that started it is told the turn is somebody else's. + "client" => form.client = Some(value.into_owned()), + _ => {} + } + } + + form + } +} + +/// Start a turn on this conversation and send the browser straight back to it. +/// +/// The turn runs in the background rather than on this request. +/// A turn can take many minutes, and holding the response open for it means the +/// page renders nothing until the whole thing is over: no request appearing, no +/// tool calls, no partial answer. +/// Returning immediately lets the page poll instead, and the turn loop persists +/// at every streaming boundary, so progress shows up as it happens. +/// +/// Answers with `204` when the caller asks for JSON, and a redirect otherwise, +/// so the page can post in the background while a plain form post still lands +/// somewhere. +async fn start_turn( + State(state): State, + Path(id): Path, + headers: axum::http::HeaderMap, + body: String, +) -> Response { + debug!(%id, "POST /conversations/{{id}}/turn"); + + let form = TurnForm::parse(&body); + + // The page posts in the background and updates itself from the poll, so it + // wants nothing back. A plain form post has no such option and needs somewhere + // to land. + let wants_json = headers + .get(header::ACCEPT) + .and_then(|value| value.to_str().ok()) + .is_some_and(|accept| accept.contains("application/json")); + + let content = form.content.trim().to_owned(); + + // Built before the turn is spawned, which takes ownership of `id`. + // + // The provisional message is rendered here rather than left to the next poll: + // that would cost a second round trip and a re-render of the whole transcript, + // and a second of nothing after pressing send reads as a failure. Rendered by + // the same function the poll would use, so it is the final markup, not an + // approximation of it. + let response = if wants_json { + Json(TurnStarted { + pending: views::detail::pending(&content).into_string(), + }) + .into_response() + } else { + Redirect::to(&format!("/conversations/{id}")).into_response() + }; + + if content.is_empty() { + return response; + } + + // Sending while a turn runs is refused rather than made to interrupt it. + // + // Interrupting and immediately starting a second turn was tried and withdrawn: + // it relied on a fixed delay to guess when the first turn had released the + // conversation, and the turn that followed came back empty. Stopping and + // sending are separate acts until the host can say when a turn has finished + // unwinding. + let busy = matches!( + state.turns.lock().expect("turns lock poisoned").get(&id), + Some(TurnStatus::Running { .. }) + ); + + if busy { + return ( + StatusCode::CONFLICT, + Json(TurnRefused { + error: "A turn is still running. Stop it first, then send.".to_owned(), + }), + ) + .into_response(); + } + + state + .turns + .lock() + .expect("turns lock poisoned") + .insert(id.clone(), TurnStatus::Running { + pending: Some(content.clone()), + client: form.client.clone(), + }); + + let client = state.client.clone(); + let turns = Arc::clone(&state.turns); + let cfg = form.cfg; + tokio::spawn(async move { + let failure = match client.query(&id, &content, cfg).await { + Ok(()) => { + info!(%id, "Turn completed"); + None + } + Err(error) => { + error!(%id, %error, "Turn failed"); + Some(TurnStatus::Failed(error.to_string())) + } + }; + + let mut turns = turns.lock().expect("turns lock poisoned"); + match failure { + Some(failed) => turns.insert(id, failed), + None => turns.remove(&id), + }; + }); + + response +} + +/// Stop the turn the host is running, then send the browser back. +/// +/// The turn ends the way an interrupted terminal turn does: whatever the +/// assistant produced so far is kept, and the conversation is left in a state +/// another turn can continue from. +/// Answers `204` for a background post and a redirect otherwise, so the page +/// can stop a turn without navigating while the form still works on its own. +async fn interrupt( + State(state): State, + Path(id): Path, + headers: axum::http::HeaderMap, +) -> Response { + debug!(%id, "POST /conversations/{{id}}/interrupt"); + + if let Err(error) = state.client.interrupt(&id) { + error!(%id, %error, "Interrupt failed"); + state + .turns + .lock() + .expect("turns lock poisoned") + .insert(id.clone(), TurnStatus::Failed(error.to_string())); + } + + if wants_json(&headers) { + StatusCode::NO_CONTENT.into_response() + } else { + Redirect::to(&format!("/conversations/{id}")).into_response() + } +} + +/// What the new-conversation form submits. +/// +/// Read from decoded pairs rather than through `Form`, because a set of +/// checkboxes sharing a name posts the name once per ticked box, and the +/// urlencoded deserialiser behind `Form` has no way to express "collect the +/// repeats" — it sees the second `cfg` and reports a string where a sequence +/// was expected. +#[derive(Debug, Default)] +struct NewConversationForm { + content: String, + title: String, + cfg: Vec, + + /// Which page is asking, so the turn it starts is attributed to it. + client: Option, +} + +impl NewConversationForm { + /// Read a form body, keeping every value of a repeated field. + /// + /// Unknown fields are ignored, which is the same latitude `Form` allows and + /// keeps a stray browser-added field from failing the whole submission. + fn parse(body: &str) -> Self { + let mut form = Self::default(); + + for (key, value) in form_urlencoded::parse(body.as_bytes()) { + match key.as_ref() { + "content" => form.content = value.into_owned(), + "title" => form.title = value.into_owned(), + "cfg" => form.cfg.push(value.into_owned()), + "client" => form.client = Some(value.into_owned()), + _ => {} + } + } + + form + } +} + +async fn new_conversation_form(State(state): State) -> Result { + debug!("GET /conversations/new"); + + let configs = state + .client + .list_configs() + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + Ok(views::new::render(&configs, "", "", &[], None)) +} + +/// Start a conversation, then send the browser to it. +/// +/// Unlike a turn on an existing conversation, this waits for the host: the +/// conversation has no id until the host has made one, and there is nowhere to +/// redirect to until then. +async fn start_conversation( + State(state): State, + body: String, +) -> Result { + debug!("POST /conversations/new"); + + let form = NewConversationForm::parse(&body); + + let content = form.content.trim().to_owned(); + let title = Some(form.title.trim().to_owned()).filter(|t| !t.is_empty()); + + let error = if content.is_empty() { + Some("A message is required.".to_owned()) + } else { + match state + .client + .start_conversation(&content, title, form.cfg.clone()) + .await + { + Ok((id, outcome)) => { + info!(%id, "Started a conversation."); + + // Recorded before the redirect, so the page it lands on shows the + // working indicator and the stop button from its first paint. The + // request is already in the conversation, so no pending copy is + // needed. + // Attributed to whoever filled the form, so the page they land on + // can stop the first turn without being asked whose it is. + state.turns.lock().expect("turns lock poisoned").insert( + id.clone(), + TurnStatus::Running { + pending: None, + client: form.client.clone(), + }, + ); + + // Cleared when the turn ends, which is the half that has to exist: + // an entry nothing ever removes leaves the conversation busy for the + // life of the process. + let turns = Arc::clone(&state.turns); + let finished_id = id.clone(); + tokio::spawn(async move { + let failure = match outcome.finished().await { + Ok(()) => { + info!(id = %finished_id, "First turn completed."); + None + } + Err(error) => { + error!(id = %finished_id, %error, "First turn failed."); + Some(TurnStatus::Failed(error.to_string())) + } + }; + + let mut turns = turns.lock().expect("turns lock poisoned"); + match failure { + Some(failed) => turns.insert(finished_id, failed), + None => turns.remove(&finished_id), + }; + }); + + return Ok(Redirect::to(&format!("/conversations/{id}")).into_response()); + } + Err(error) => { + error!(%error, "Failed to start a conversation."); + Some(error.to_string()) + } + } + }; + + // Re-listed rather than carried through the failure: the form has to be drawn + // again, and drawing it without its choices would lose them. + let configs = state.client.list_configs().await.unwrap_or_default(); + + Ok( + views::new::render(&configs, &content, &form.title, &form.cfg, error.as_deref()) + .into_response(), + ) +} + +/// Move a conversation to the archive. +/// +/// Answers `204` for a background post and a redirect otherwise, so the list +/// page works with or without script. +async fn archive_conversation( + State(state): State, + Path(id): Path, + headers: axum::http::HeaderMap, +) -> Response { + debug!(%id, "POST /conversations/{{id}}/archive"); + + match state.client.archive(&id).await { + Ok(()) => { + info!(%id, "Archived a conversation."); + if wants_json(&headers) { + StatusCode::NO_CONTENT.into_response() + } else { + Redirect::to("/conversations").into_response() + } + } + Err(error) => { + error!(%id, %error, "Failed to archive."); + AppError::Internal(error.to_string()).into_response() + } + } +} + +/// What a rename posts. +#[derive(Debug, Deserialize)] +struct TitleForm { + title: String, +} + +async fn set_title( + State(state): State, + Path(id): Path, + headers: axum::http::HeaderMap, + Form(form): Form, +) -> Response { + debug!(%id, "POST /conversations/{{id}}/title"); + + match state.client.set_title(&id, &form.title).await { + Ok(()) => { + if wants_json(&headers) { + StatusCode::NO_CONTENT.into_response() + } else { + Redirect::to(&format!("/conversations/{id}")).into_response() + } + } + Err(error) => { + error!(%id, %error, "Failed to rename."); + AppError::Internal(error.to_string()).into_response() + } + } +} + +/// Whether the caller posted in the background and wants no page back. +fn wants_json(headers: &axum::http::HeaderMap) -> bool { + headers + .get(header::ACCEPT) + .and_then(|value| value.to_str().ok()) + .is_some_and(|accept| accept.contains("application/json")) +} + +/// How many conversations there are. +#[derive(Debug, Serialize)] +struct ConversationCount { + count: usize, +} + +/// How many conversations there are. +/// +/// Enough for a page to tell whether its copy of the list is still the whole +/// list, without asking for the list itself. +async fn conversation_count( + State(state): State, +) -> Result, AppError> { + state + .client + .list_conversations() + .await + .map(|list| Json(ConversationCount { count: list.len() })) + .map_err(|e| AppError::Internal(e.to_string())) +} + +/// The configurations a message can be run under. +/// +/// Fetched by the page when its configuration dialog is first opened, rather +/// than rendered into every conversation, since most visits never open it. +async fn list_configs( + State(state): State, +) -> Result>, AppError> { + state + .client + .list_configs() + .await + .map(Json) + .map_err(|e| AppError::Internal(e.to_string())) +} + +/// The reply to a turn the page started in the background. +#[derive(Debug, Serialize)] +struct TurnStarted { + /// The submitted message, rendered as it will appear in the transcript. + pending: String, +} + +/// Why a turn was not started, for the page to show and to keep the text. +#[derive(Debug, Serialize)] +struct TurnRefused { + error: String, +} + +/// A query draft, as the page sees it. +#[derive(Debug, Serialize)] +struct DraftBody { + content: String, + #[serde(skip_serializing_if = "Option::is_none")] + revision: Option, + conflict: bool, +} + +impl From for DraftBody { + fn from(resp: jp_plugin::message::DraftResponse) -> Self { + Self { + content: resp.content, + revision: resp.revision, + conflict: resp.conflict, + } + } +} + +/// What the page sends when saving a draft. +#[derive(Debug, Deserialize)] +struct DraftForm { + content: String, + + /// The revision the page last saw, absent when it believes there is no + /// draft. + #[serde(default)] + revision: Option, +} + +async fn read_draft( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + state + .client + .read_draft(&id) + .await + .map(|resp| Json(resp.into())) + .map_err(|e| AppError::Internal(e.to_string())) +} + +/// Save the draft, refusing if it moved since the page last read it. +/// +/// A refusal is a 200 with `conflict` set, not an error: the body carries what +/// is on disk so the page can offer both rather than discard either. +async fn write_draft( + State(state): State, + Path(id): Path, + Json(form): Json, +) -> Result, AppError> { + state + .client + .write_draft(&id, &form.content, form.revision) + .await + .map(|resp| Json(resp.into())) + .map_err(|e| AppError::Internal(e.to_string())) +} + +/// The messages of a conversation, for the page's poller. +/// +/// `count` lets the page skip the swap when nothing has changed, which is the +/// common case: the host re-reads the conversation from disk on every request, +/// so this reflects writes by any `jp` process, not just turns started here. +/// +/// `running` says whether a turn started from this server is still going, which +/// is how the page knows to keep the working indicator up. +/// `error` is delivered once and then cleared, so a failure reaches whoever is +/// watching without sticking around forever. +#[derive(Debug, Serialize)] +struct MessagesBody { + count: usize, + + /// Rendered messages the caller does not have, or the whole transcript when + /// it cannot be told what it has. + /// + /// Absent when the caller is up to date. + /// Rendering means running markdown over every message included, so sending + /// the whole conversation once a second to produce something the page + /// already has is waste at both ends. + #[serde(skip_serializing_if = "Option::is_none")] + html: Option, + + /// Where `html` starts. + /// + /// Zero means it is the whole transcript and replaces what the caller has; + /// anything else means it continues from there and is appended. + /// A conversation only grows, so continuing is the usual case — and + /// appending leaves the messages already on the page untouched, which is + /// what keeps their disclosure state, their measured heights and the scroll + /// position intact. + from: usize, + + running: bool, + + /// What stopping the running turn would take, from the asker's side. + stop: StopMode, + + /// This run of the server; a change means the page should reload. + boot: String, + + /// A submitted message the transcript doesn't carry yet, rendered the same + /// way the real request will be so the swap is invisible. + #[serde(skip_serializing_if = "Option::is_none")] + pending: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +/// How many rendered events a page holds at once. +/// +/// Enough that scrolling back a little never waits, small enough that the first +/// paint is cheap however long the conversation is. +/// The cost of getting this wrong is a fetch, not a broken view. +const WINDOW: usize = 200; + +/// What the poller already has, so the answer can leave it out. +#[derive(Debug, Deserialize)] +struct MessagesQuery { + /// Ask for the events *before* this index instead of the ones after + /// `count`. + /// + /// How the page walks backwards through a conversation it only holds the + /// tail of. + #[serde(default)] + before: Option, + + /// With `before`, take everything preceding it rather than one window. + /// + /// For jumping to the top, and for the platforms that would rather hold the + /// whole conversation than fetch it a window at a time. + #[serde(default)] + all: Option, + + /// The event count the caller last rendered. + #[serde(default)] + count: Option, + + /// Which client is asking, so a turn it started can be told from one it + /// merely shares a server with. + #[serde(default)] + client: Option, +} + +async fn messages( + State(state): State, + Path(id): Path, + Query(query): Query, +) -> Result, AppError> { + let resp = read_conversation(&state, &id).await?; + let rendered = render::render_events(&resp.data); + // A pending message is only worth showing until the transcript carries it. + let landed = render::awaiting_response(&rendered); + let view = take_turn_status(&state, &id, landed); + + // Walking backwards: a window of what came before what the caller holds. + if let Some(before) = query.before { + let before = before.min(rendered.len()); + let from = if query.all.is_some_and(|all| all != 0) { + 0 + } else { + before.saturating_sub(WINDOW) + }; + + return Ok(Json(MessagesBody { + count: rendered.len(), + from, + html: (from < before) + .then(|| views::detail::messages(&rendered[from..before]).into_string()), + pending: None, + stop: stop_mode(&view, resp.lock, query.client.as_deref()), + boot: state.boot.clone(), + running: view.running || resp.lock.is_held(), + error: None, + })); + } + + // What the caller already has, when that is a prefix of what is here. A count + // beyond the end means the transcript was rewritten under it — compacted, or + // edited on disk — and the only safe answer is the tail, from scratch. + let from = query + .count + .filter(|&count| count <= rendered.len()) + .unwrap_or_else(|| rendered.len().saturating_sub(WINDOW)) + // Never past an event that can still change. A tool call is rendered when + // it is requested and gains its result later, so sending only what comes + // after it would leave the caller holding the question forever. + .min(render::settled_upto(&rendered)); + + let stale = from != rendered.len(); + + Ok(Json(MessagesBody { + count: rendered.len(), + from, + html: stale.then(|| views::detail::messages(&rendered[from..]).into_string()), + pending: view + .pending + .as_deref() + .map(|content| views::detail::pending(content).into_string()), + // The lock is the authority on whether a turn is running. Inferring it + // from a transcript ending in a request cannot tell a live turn from one + // that failed, and got that wrong in the direction that blocks the + // composer for a conversation nothing is working on. + // + // `view.running` still counts, for the moment between this server + // starting a turn and the host taking the lock. + stop: stop_mode(&view, resp.lock, query.client.as_deref()), + boot: state.boot.clone(), + running: view.running || resp.lock.is_held(), + error: view.error, + })) +} + +/// What stopping the running turn would take, for the client that is asking. +/// +/// Three cases, because "this server can reach it" and "you started it" are not +/// the same question once more than one browser is connected. +fn stop_mode(view: &TurnView, lock: LockState, asker: Option<&str>) -> StopMode { + if !(view.running || lock.is_here()) { + return if lock.is_held() { + StopMode::Unreachable + } else { + StopMode::None + }; + } + + // Unattributed turns count as shared: a turn started before this page knew + // its own identity is not one it can claim. + match (view.client.as_deref(), asker) { + (Some(owner), Some(asker)) if owner == asker => StopMode::Own, + _ => StopMode::Shared, + } +} + +/// Read a conversation's turn state, consuming what should only be seen once. +/// +/// A failure is reported once: leaving it in place would have every later poll +/// re-raise an error the reader has already seen. +/// The pending message is dropped as soon as `landed` says the transcript has +/// the request, so the page stops showing its provisional copy. +fn take_turn_status(state: &AppState, id: &str, landed: bool) -> TurnView { + let mut turns = state.turns.lock().expect("turns lock poisoned"); + + match turns.get_mut(id) { + Some(TurnStatus::Running { pending, client }) => { + if landed { + pending.take(); + } + + TurnView { + running: true, + error: None, + pending: pending.clone(), + client: client.clone(), + } + } + Some(TurnStatus::Failed(_)) => { + let error = match turns.remove(id) { + Some(TurnStatus::Failed(message)) => Some(message), + _ => None, + }; + + TurnView { + running: false, + error, + pending: None, + client: None, + } + } + None => TurnView { + running: false, + error: None, + pending: None, + client: None, + }, + } +} + +/// A page whose content changes while it is open, marked as never reusable. +/// +/// Without this a browser is free to show the copy it already has — on a +/// reload, on a back navigation, or when restoring a backgrounded tab — and a +/// transcript from ten minutes ago looks like a transcript from now. +/// The poll would correct it within a second or three, which is long enough to +/// read as broken. +fn uncached(markup: Markup) -> Response { + use axum::http::HeaderValue; + + let mut response = markup.into_response(); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + + response +} + async fn conversation_detail( State(state): State, Path(id): Path, -) -> Result { +) -> Result { debug!(%id, "GET /conversations/{{id}}"); - let resp = state.client.read_events(&id).await.map_err(|e| match e { + let resp = read_conversation(&state, &id).await?; + let title = resp.title.clone().unwrap_or_else(|| "Untitled".into()); + + // Read without consuming: the poll that follows within a couple of seconds + // is what clears a failure, and it drives the same indicator. + let started_here = matches!( + state.turns.lock().expect("turns lock poisoned").get(&id), + Some(TurnStatus::Running { .. }) + ); + + let rendered = render::render_events(&resp.data); + let running = started_here || resp.lock.is_held(); + + // Only the tail is rendered into the page. A long conversation is thousands of + // nodes, and painting them all is what made scrolling crawl; the page asks for + // the rest as it scrolls back. + let first = rendered.len().saturating_sub(WINDOW); + + // Which client is asking is a browser-side fact, so the first paint can only + // say whether this server could stop it at all. The poll a second later knows + // the asker and refines `own` from `shared` — invisibly, since both render the + // same button. + let stoppable = started_here || resp.lock.is_here(); + + debug!(%id, events = rendered.len(), running, "Rendered conversation detail"); + Ok(uncached(views::detail::render( + &id, + &title, + &rendered[first..], + first, + rendered.len(), + running, + stoppable, + ))) +} + +/// Read one conversation's events, mapping a missing one to a 404. +async fn read_conversation( + state: &AppState, + id: &str, +) -> Result { + state.client.read_events(id).await.map_err(|e| match e { // The host reports a missing conversation as an error response; other // variants are server-side failures. ClientError::Host(msg) => { @@ -88,22 +987,32 @@ async fn conversation_detail( AppError::NotFound } e => AppError::Internal(e.to_string()), - })?; - - // Find the title from the conversation list (protocol doesn't include it - // in the events response). Fall back to "Untitled". - let title = match state.client.list_conversations().await { - Ok(convos) => convos - .iter() - .find(|c| c.id == id) - .and_then(|c| c.title.clone()) - .unwrap_or_else(|| "Untitled".into()), - Err(_) => "Untitled".into(), - }; + }) +} - let rendered = render::render_events(&resp.data); - debug!(%id, events = rendered.len(), "Rendered conversation detail"); - Ok(views::detail::render(&title, &rendered)) +async fn serve_icon() -> impl IntoResponse { + static_asset("image/svg+xml", style::ICON) +} + +async fn serve_manifest() -> impl IntoResponse { + static_asset("application/manifest+json", style::MANIFEST) +} + +/// A small embedded asset, cached for a day. +/// +/// Shorter than the stylesheet's year: these URLs carry no content hash, so a +/// changed icon has to be able to reach a browser that has seen the old one. +fn static_asset(content_type: &'static str, body: &'static str) -> impl IntoResponse { + use axum::http::HeaderValue; + + let mut headers = axum::http::HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=86400"), + ); + + (StatusCode::OK, headers, body) } async fn serve_css() -> impl IntoResponse { @@ -116,6 +1025,8 @@ async fn serve_css() -> impl IntoResponse { header::CONTENT_TYPE, HeaderValue::from_static("text/css; charset=utf-8"), ); + // Safe to pin for a year: the URL carries `?v=`, so a changed + // stylesheet is a changed URL. headers.insert( header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=31536000, immutable"), @@ -140,7 +1051,7 @@ impl IntoResponse for AppError { (StatusCode::NOT_FOUND, body).into_response() } Self::Internal(msg) => { - tracing::error!(%msg, "internal server error"); + error!(%msg, "internal server error"); let body = views::layout::error_page("Server Error", "Something went wrong."); (StatusCode::INTERNAL_SERVER_ERROR, body).into_response() } diff --git a/crates/plugins/command/serve-web/src/style.css b/crates/plugins/command/serve-web/src/style.css index a12cec86d..346ee9545 100644 --- a/crates/plugins/command/serve-web/src/style.css +++ b/crates/plugins/command/serve-web/src/style.css @@ -40,8 +40,17 @@ html { -webkit-text-size-adjust: 100%; + height: 100%; + /* Nothing outside the transcript may scroll or bounce, including the rubber + band that would otherwise chain up from an inner scroller. */ + overscroll-behavior: none; } +/* A column that fits the visible area exactly, with one scrolling row inside it. + + `--app-height` is set by the page from the visual viewport, which is the only + thing that knows how much room an on-screen keyboard has taken. `100dvh` is the + fallback for a page without script, and for desktop where the two agree. */ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; @@ -49,6 +58,172 @@ body { line-height: 1.6; color: var(--fg); background: var(--bg); + + display: flex; + flex-direction: column; + overflow: hidden; + + /* Deliberately *not* `position: fixed`. + + A fixed element attaches to the fixed viewport, which on iOS is the ICB and + is not resized by the on-screen keyboard. Safari scrolls the page to reveal + a focused field and only repositions fixed elements once that scroll + settles, so a fixed header visibly leaves the screen and snaps back. No + amount of compensation fixes that: it cannot be painted until the scroll + ends. + + In normal flow there is nothing anchored to a viewport that moves, so the + column simply fills the layout viewport and stays that size. The keyboard is + accounted for by moving the composer, not by resizing this. */ + height: 100%; + overscroll-behavior: none; +} + +/* Set by the page when iOS reports the keyboard's destination in one jump rather + than reporting each step of the slide, so there is nothing to follow and the + movement has to be invented. + + The curve is the one the React Native community settled on for animating in sync + with the iOS keyboard; Apple publishes neither it nor the duration, which is + given only to native code. Both are therefore approximations, which is why the + page follows the real height wherever iOS gives it one. */ +html.eased .composer-dock { + transition: translate 250ms cubic-bezier(0.17, 0.59, 0.4, 0.77); +} + +/* The same easing on the space the transcript reserves, so the conversation rises + with the composer rather than snapping to its final position while the composer + is still moving. The page holds the scroll against the bottom for the duration, + which turns this growing padding into a smooth scroll. */ +html.eased .stage { + transition: margin-bottom 250ms cubic-bezier(0.17, 0.59, 0.4, 0.77); +} + +/* A page whose document scrolls, rather than one pinned to the window with a + scroller inside it. + + Chosen per page: anywhere a virtual keyboard is involved needs the pinned + arrangement, and everywhere else is better off with the platform's own + scrolling — momentum, overscroll, and the status-bar tap that returns to the + top, none of which a page can reproduce. */ +body.scrolls { + display: block; + height: auto; + min-height: 100%; + overflow: visible; + + /* Overscroll is allowed back in, which is what pull-to-refresh is: a drag + past the top of a document that is already at the top. The blanket `none` + further up exists to stop the transcript's rubber band chaining to the + document, and there is no transcript here. */ + overscroll-behavior: auto; +} + +/* The root has to allow it too — the gesture belongs to the document, and a + `none` on the root suppresses it whatever the body says. */ +html:has(> body.scrolls) { + overscroll-behavior: auto; +} + +body.scrolls .page-header { + position: sticky; + top: 0; +} + +body.scrolls .conversation-list { + flex: none; + min-height: 0; + overflow: visible; +} + +/* Renaming, in place of the heading. */ +#rename { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + opacity: 0.55; + color: var(--fg-muted); + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; + flex-shrink: 0; +} + +#rename:hover { + opacity: 1; + color: var(--fg); + background: var(--bg-alt); +} + +/* `hidden` loses to a `display` rule, so say it again where it can win. */ +.rename-form[hidden], +#rename[hidden], +#title[hidden] { + display: none; +} + +.rename-form { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; +} + +.rename-form input { + /* Sized to its content rather than the row, so the buttons stay beside the + text and near the pointer that opened them. `field-sizing` does this + natively where supported; the width is the fallback elsewhere. */ + field-sizing: content; + width: 24ch; + max-width: 100%; + min-width: 8ch; + padding: 4px 8px; + font: inherit; + font-size: 1rem; + font-weight: 600; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; +} + +.rename-form button { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + color: var(--fg-muted); + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; + flex-shrink: 0; +} + +.rename-form button:hover { + color: var(--fg); + background: var(--bg-alt); +} + +/* The header is a way back to the top, since it is the thing at the top. */ +.page-header { + cursor: pointer; +} + +.page-header a, +.page-header h1 { + cursor: auto; +} + +.page-header a { + cursor: pointer; } a { @@ -60,32 +235,155 @@ a:hover { text-decoration: underline; } -/* Page header */ +/* Page header + + One row rather than two: a back link and a title don't need the height, and on + a phone every pixel here is transcript the reader doesn't see. + + A flex row of the body rather than `sticky`, since the body doesn't scroll. + + `touch-action: none` because iOS makes the document scrollable while the + keyboard is open: without it, dragging a finger across the header pans the + whole page. Taps are unaffected. */ .page-header { - position: sticky; - top: 0; - z-index: 10; + flex: none; + touch-action: none; + display: flex; + align-items: baseline; + gap: 10px; background: var(--bg); border-bottom: 1px solid var(--border); - padding: 12px 16px; + /* The top inset keeps the title clear of the status bar when this runs + installed to a home screen, where there is no browser chrome above it. + + The bar spans the window, but its contents line up with the conversation + below it — on a wide screen a back link pinned to the far left has nothing + to do with the column it belongs to. The horizontal padding grows to + whatever centres a `--max-width` column, and falls back to the plain inset + once the window is narrower than that. */ + padding-top: max(8px, env(safe-area-inset-top)); + padding-bottom: 8px; + padding-left: max(16px, env(safe-area-inset-left), calc((100% - var(--max-width)) / 2)); + padding-right: max(16px, env(safe-area-inset-right), calc((100% - var(--max-width)) / 2)); } .page-header h1 { margin: 0; - font-size: 1.25rem; + font-size: 1rem; font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .page-header .back { - display: inline-block; - margin-bottom: 4px; - font-size: 0.875rem; + font-size: 0.8125rem; + flex-shrink: 0; +} + +/* The `New` action sits opposite the title, which is otherwise alone on the row. */ +.page-header .new-conversation-link { + margin-left: auto; + flex-shrink: 0; + font-size: 0.8125rem; +} + +/* New conversation form */ +.new-conversation { + display: flex; + flex-direction: column; + gap: 20px; +} + +.new-conversation .field-label { + display: block; + margin-bottom: 6px; + font-size: 0.8rem; + font-weight: 600; + color: var(--fg-muted); +} + +.new-conversation input[type="text"], +.new-conversation textarea { + width: 100%; + box-sizing: border-box; + padding: 10px 12px; + font: inherit; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; +} + +/* One block per load-path directory: the directory is the namespace, and its + files are the choices within it. */ +.new-conversation fieldset { + margin: 0; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 8px; +} + +.new-conversation legend { + padding: 0 6px; + font-size: 0.8rem; + font-weight: 600; + color: var(--fg-muted); +} + +.new-conversation .config-option { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; +} + +.new-conversation button { + padding: 10px 20px; + font: inherit; + font-weight: 600; + color: var(--bg); + background: var(--fg); + border: none; + border-radius: 8px; + cursor: pointer; +} + +/* Filter field above the conversation list. Shares the list's width and side + padding so the field lines up with the entries under it. */ +.list-search { + flex: none; + padding: 8px 16px; + max-width: var(--max-width); + width: 100%; + box-sizing: border-box; + margin: 0 auto; +} + +.list-search input { + width: 100%; + box-sizing: border-box; + padding: 8px 12px; + /* Inherits the body's 16px, which is also the size below which iOS zooms + the page on focus. */ + font: inherit; + color: var(--fg); + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: 8px; } /* Conversation list */ .conversation-list { + flex: 1; + min-height: 0; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding: 0 16px 16px; max-width: var(--max-width); + width: 100%; + box-sizing: border-box; margin: 0 auto; } @@ -99,6 +397,51 @@ a:hover { border-bottom: 1px solid var(--border); } +/* A row that scrolls sideways to reveal its actions. + + Scroll-snap rather than touch handlers: the browser's own scrolling gives + momentum, rubber banding and trackpad support for free, and a hand-rolled + version of those never quite matches. Dragging with a mouse works too, since + this is just a scroller. + + The scrollbar is hidden because the affordance is the gesture, not a bar. */ +.row-track { + display: flex; + overflow-x: auto; + scroll-snap-type: x mandatory; + scrollbar-width: none; + overscroll-behavior-x: contain; +} + +.row-track::-webkit-scrollbar { + display: none; +} + +.row-entry { + flex: 0 0 100%; + scroll-snap-align: start; + scroll-snap-stop: always; +} + +/* Sits past the row's right edge until scrolled to. */ +.row-actions { + flex: 0 0 auto; + display: flex; + align-items: stretch; + scroll-snap-align: end; +} + +.row-actions .archive { + padding: 0 20px; + font: inherit; + font-weight: 600; + color: #ffffff; + background: #b3261e; + border: none; + cursor: pointer; + white-space: nowrap; +} + .conversation-list li a { display: flex; justify-content: space-between; @@ -133,13 +476,646 @@ a:hover { color: var(--fg-muted); } -/* Conversation detail */ +/* Conversation detail + + The page's only scroller. `min-height: 0` is what lets a flex child actually + shrink and scroll rather than growing the column past the viewport. + + `--kb-settled` is the keyboard height, but only once it has stopped moving: the + raised composer overlaps the bottom of this box, and the padding gives the last + message room to scroll clear of it. Deliberately not `--kb`, which changes every + frame — padding is a layout property, and paying for a relayout per frame is + exactly what the transform above avoids. */ .conversation-detail { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + padding: 16px; + padding-left: max(16px, env(safe-area-inset-left)); + padding-right: max(16px, env(safe-area-inset-right)); + /* No bottom padding: the composer below carries its own top margin, padding + and rule, which is already the separation. Adding to it here reads as the + conversation stopping short. */ + padding-bottom: 0; max-width: var(--max-width); + width: 100%; + box-sizing: border-box; margin: 0 auto; } +/* Covers the conversation until it has been scrolled to the end. + + A long transcript paints from the top over a second or more, so without this the + reader watches it stream past and then jump. Removed by the page adding `ready` + to the root, which it does after applying the scroll — or after a timeout, so a + page that never finishes loading is still usable. */ +.loading-veil { + position: absolute; + inset: 0; + z-index: 20; + background: var(--bg); + transition: opacity 150ms ease-out; +} + +html.ready .loading-veil { + /* `display`, not just transparency: a full-size element over the scroller is + still painted when it is invisible, and on desktop that repaints the whole + transcript on every scroll frame. */ + display: none; +} + +/* Jumps within the conversation, floating over its bottom-right corner. + + Inside the stage rather than the transcript, so it stays put while the + conversation scrolls under it. */ +.nav { + position: absolute; + bottom: 16px; + z-index: 15; + + /* Wholly inside the column, against its right edge. + + Measured from the column rather than the window: `16px` from the window is + only inside the column while the two share an edge, and once a gutter opens + up it puts the button astride that edge instead — which is the one placement + that looks broken from either side. + + `max(0px, ...)` covers a window narrower than the column, where there is no + gutter to account for. + + The media query further down moves it out into the gutter once there is + enough of one to hold it. Snapped rather than interpolated, so no window + width lands on the edge. */ + right: calc(max(0px, (100% - var(--max-width)) / 2) + 16px); +} + +.nav summary { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + /* Faded until pointed at, matching the composer's tools. */ + opacity: 0.55; + color: var(--fg-muted); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 50%; + cursor: pointer; + /* The disclosure triangle is replaced by the icon inside. */ + list-style: none; +} + +.nav summary::-webkit-details-marker { + display: none; +} + +.nav summary:hover, +.nav[open] summary { + opacity: 1; + color: var(--fg); + border-color: var(--fg-muted); +} + +/* Opens upward: the toggle sits at the bottom of the view, so there is only room + above it. */ +.nav-menu { + position: absolute; + right: 0; + bottom: calc(100% + 8px); + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 20px; +} + +.nav-menu button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + color: var(--fg-muted); + background: transparent; + border: none; + border-radius: 50%; + cursor: pointer; +} + +.nav-menu button:hover { + color: var(--fg); + background: var(--bg-alt); +} + +/* Marks a choice that will apply to the next message, so it is not made and then + forgotten behind a closed menu. */ +.nav-menu button.active { + color: var(--accent); +} + +/* Which configurations the next message runs under. */ +.config-modal { + max-width: min(90vw, 520px); + padding: 0; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.config-modal::backdrop { + background: rgb(0 0 0 / 0.4); +} + +.config-form { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; +} + +.config-form h2 { + margin: 0; + font-size: 1rem; +} + +.config-note { + margin: 0; + font-size: 0.8rem; + color: var(--fg-muted); +} + +.config-groups { + display: flex; + flex-direction: column; + gap: 12px; + max-height: 50vh; + overflow-y: auto; +} + +.config-groups fieldset { + margin: 0; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.config-groups legend { + padding: 0 6px; + font-size: 0.75rem; + font-weight: 600; + color: var(--fg-muted); +} + +.config-option { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 0; +} + +.config-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.config-actions button { + padding: 8px 16px; + font: inherit; + color: var(--fg); + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; +} + +.config-actions .config-apply { + font-weight: 600; + color: #ffffff; + background: var(--accent); + border-color: var(--accent); +} + +/* Nothing here skips rendering. + + `content-visibility` used to, and it worked: it was the only thing making a long + conversation scroll at a reasonable rate. It also meant every unvisited message + lied about its height, and every feature that asked where the end was had to be + taught to chase a moving answer. The page now holds a window of the conversation + instead, so there is less to paint rather than the same amount pretended away, + and heights are true again. */ + +/* Holds the transcript and whatever floats over it. + + Ends above the composer rather than behind it. The composer is out of flow, so + without this the transcript's box runs to the bottom of the window and the + composer sits on top of it — hiding the last lines of the conversation and the + bottom of its own scrollbar. + + Reserved here rather than as padding on the transcript: padding moves the + content but not the box, which leaves the scrollbar running on underneath. */ +.stage { + position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + margin-bottom: calc(var(--dock, 0px) + var(--kb, 0px)); +} + +/* Raised when the page outlives the server it loaded from. + + Floats over the top of the transcript rather than taking a row of its own: it + is transient, and reflowing the whole conversation to announce it would be a + worse interruption than the thing it announces. Pinned under the header, which + is what carries the status-bar inset. */ +.reload-banner { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 5; + padding: 8px 16px; + font-size: 0.85rem; + text-align: center; + color: var(--fg); + background: var(--user-bg); + border-bottom: 1px solid var(--border); + box-shadow: 0 2px 8px rgb(0 0 0 / 0.15); +} + +.reload-banner a { + color: inherit; + font-weight: 600; + text-decoration: underline; +} + +/* Composer + + Field and send button on one row, so an idle composer costs the conversation a + single line. The draft warning spans both columns beneath them, and is hidden + unless there is something to say. */ +.composer { + display: grid; + grid-template-columns: 1fr auto; + align-items: end; + gap: 8px; +} + +.composer #draft-note { + grid-column: 1 / -1; + margin: 0; +} + +.composer-dock { + /* Fixed, so it is out of flow: moving or growing it cannot lay out the + transcript, because the transcript no longer has it as a sibling taking + space. The transcript reserves room for it with padding instead, from the + `--dock` height the page measures. */ + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 5; + + /* Raised clear of the keyboard by `--kb`, the height it covers, which the page + measures from the visual viewport. + + A transform rather than `bottom`: transforms are composited, so this moves on + the GPU without laying anything out. Animating an inset property instead costs + a relayout per frame, which is what made this stutter. */ + /* Only while the keyboard is up. A transform that is always present promotes + this to its own layer for the life of the page, and a composited element + overlapping the scroller makes every scroll frame a compositing job. */ + translate: 0 calc(-1 * var(--kb, 0px)); + + /* As on the header: block dragging the page by its chrome. The textarea below + opts back in, so it can still scroll once it has grown. */ + touch-action: none; + + width: 100%; + max-width: var(--max-width); + box-sizing: border-box; + margin: 0 auto; + /* The full bottom inset is sized to keep controls clear of the home indicator, + which is more room than a composer needs — the indicator overlaps the gap + below the field, not the field itself. Most of it is given back. */ + padding: 0 max(16px, env(safe-area-inset-left)) + max(6px, calc(env(safe-area-inset-bottom) - 22px)) + max(16px, env(safe-area-inset-right)); + background: var(--bg); +} + +.composer { + /* Tight against the rule: the gap above it was doing nothing the rule does not + already do, and every pixel here is conversation the reader cannot see. */ + margin-top: 0; + padding-top: 8px; + border-top: 1px solid var(--border); +} + +/* Acting on the field below them, so sized to sit quietly above it rather than + compete with the send button. */ +.composer-tools { + /* Placed explicitly, along with the field and the button below: leaving any of + the three to auto-placement puts them in whichever cell is free next, which + is how the row ends up beside the field instead of above it. */ + grid-column: 1 / -1; + grid-row: 1; + display: flex; + gap: 2px; + margin-bottom: 2px; + position: relative; +} + +.composer > textarea { + grid-column: 1; + grid-row: 2; +} + +.composer > button[type="submit"] { + grid-column: 2; + grid-row: 2; +} + +.composer > .composer-error { + grid-column: 1 / -1; + grid-row: 3; +} + +/* What the pointed-at button does, said immediately. + + The native tooltip takes a second to appear, which is long enough to give up + and click to find out. Right-aligned above the row so it never covers the + buttons themselves. */ +.composer-tools button::after { + content: attr(data-label); + position: absolute; + /* The row spans the send button's column too, so the label is inset by its + width and the gap to land against the field's right edge instead. */ + right: calc(42px + 8px); + /* Centred on the icons rather than stacked above them, so the row keeps its + height and the label reads as a caption for what the pointer is on. */ + top: 50%; + transform: translateY(-50%); + padding: 2px 6px; + font-size: 0.7rem; + white-space: nowrap; + color: var(--fg-muted); + background: var(--bg); + border-radius: 4px; + opacity: 0; + pointer-events: none; +} + +.composer-tools button:hover::after, +.composer-tools button:focus-visible::after { + opacity: 1; +} + +.composer-tools button { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + /* Quiet until wanted: these sit above the field for the whole session and + should not compete with the conversation, but they still have to be findable + without hunting. */ + color: var(--fg-muted); + opacity: 0.55; + background: transparent; + border: none; + border-radius: 6px; +} + +.composer-tools button svg { + width: 13px; + height: 13px; +} + +.composer-tools button:hover { + color: var(--fg); + opacity: 1; + background: var(--bg-alt); +} + +/* Marks a configuration choice waiting to be applied. */ +.composer-tools button.active { + color: var(--accent); +} + +/* The composer again, with room to write in. */ +.expand-modal { + width: min(92vw, 720px); + padding: 0; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.expand-modal::backdrop { + background: rgb(0 0 0 / 0.4); +} + +.expand-form { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; +} + +.expand-form textarea { + width: 100%; + box-sizing: border-box; + height: min(60vh, 420px); + padding: 12px; + font: inherit; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + resize: none; +} + +.composer textarea { + width: 100%; + box-sizing: border-box; + /* Drawn inside the field's own box, so gaining focus cannot nudge the row — and + with it everything above — by the ring's width. */ + outline-offset: -2px; + padding: 10px 12px; + font: inherit; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + /* Height is managed by the page, which grows it with the content. */ + resize: none; + overflow-y: auto; + /* Overrides the dock's `none` so the field scrolls at its maximum height. */ + touch-action: pan-y; +} + +/* The send button specifically, not every button in the composer: the tool row + above the field has its own, quieter treatment, and a bare `.composer button` + here would win on source order and make them all blue slabs. */ +.composer > button[type="submit"] { + display: flex; + align-items: center; + justify-content: center; + white-space: nowrap; + /* Square, so an icon sits centred rather than in a slab sized for a word. */ + width: 42px; + height: 42px; + padding: 0; + font: inherit; + font-weight: 600; + /* The accent rather than a straight inversion of the foreground: inverted, it + is a white slab in dark mode, which pulls the eye away from the conversation + it sits under. */ + color: #ffffff; + background: var(--accent); + border: none; + border-radius: 8px; + cursor: pointer; +} + +.composer > button[type="submit"]:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Sealed while a message is on its way to the transcript. The text is still in + there and still recoverable if the turn is refused, so it is dimmed rather than + emptied. */ +.composer textarea:read-only { + opacity: 0.6; +} + +/* The last thing in the conversation while a reply is coming. + + Given room below it, so it does not sit flush against the composer's rule. + + In the transcript rather than docked to the composer, because it stands in for + the block being written: it belongs where that block will appear, and it scrolls + with the conversation like everything else. */ +.composer-status { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 12px; +} + +/* The scroll anchor. A pixel of height, so it has a box to scroll to — a + zero-height element is not a target. */ +#end { + height: 1px; +} + +/* The transcript's own tail, for when there is no indicator to provide it. */ +#pending:last-child, +#messages:last-child { + padding-bottom: 12px; +} + +.composer-status:empty { + display: none; +} + +/* A bare icon beside the dots: the indicator says a reply is coming, and this is + the way to say stop. Sized to the dots rather than to a text button, so the pair + reads as one thing. */ +.composer-stop button { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + color: var(--fg-muted); + background: transparent; + border: none; + border-radius: 50%; + cursor: pointer; +} + +.composer-stop button:hover { + color: var(--fg); + background: var(--bg-alt); +} + +/* Three bouncing dots, the chat-app convention for "a reply is coming". + Keeps a long turn looking alive between event batches, which arrive one per + streaming cycle and can be a minute apart. */ +.composer-working { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 8px 12px; + border-radius: 12px; + background: var(--assistant-bg); +} + +.composer-working i { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--fg-muted); + animation: composer-bounce 1.3s ease-in-out infinite; +} + +.composer-working i:nth-child(2) { + animation-delay: 0.16s; +} + +.composer-working i:nth-child(3) { + animation-delay: 0.32s; +} + +@keyframes composer-bounce { + 0%, 60%, 100% { + transform: translateY(0); + opacity: 0.45; + } + 30% { + transform: translateY(-4px); + opacity: 1; + } +} + +/* Still visible, just not moving. */ +@media (prefers-reduced-motion: reduce) { + .composer-working i { + animation: none; + opacity: 0.7; + } +} + +.composer-hint, +.composer-error { + font-size: 0.8rem; + color: var(--fg-muted); + margin: 0; +} + +.composer-error { + padding: 8px 12px; + color: var(--fg); + background: var(--user-bg); + border-left: 3px solid currentcolor; + border-radius: 4px; + overflow-wrap: anywhere; +} + /* Turn separator */ .turn-separator { border: none; @@ -171,6 +1147,12 @@ a:hover { background: var(--assistant-bg); } +/* Submitted, not yet in the transcript. Dimmed so it reads as provisional + rather than as something the conversation already records. */ +.message.pending { + opacity: 0.6; +} + .message .content { overflow-wrap: break-word; } @@ -297,17 +1279,40 @@ th { /* Responsive: wider viewports */ @media (min-width: 768px) { + /* Vertical only: the horizontal padding is what centres the header's contents + over the column below, and a flat value here would pin them to the window + edges on exactly the screens where that looks worst. */ .page-header { + padding-top: max(16px, env(safe-area-inset-top)); + padding-bottom: 16px; + } + + .conversation-list { padding: 16px 24px; } - .conversation-list, .conversation-detail { padding: 16px 24px; + padding-bottom: 0; + } + + .composer-dock { + padding: 0 24px 12px; } .page-header h1 { - font-size: 1.5rem; + font-size: 1.125rem; + } +} + +/* Wide enough for the navigation button to clear the conversation column. + + `--max-width` plus a gutter big enough for a 36px button and its margins on + both sides. Below this it stays inside the column, where it overlaps a little + text but is at least beside the thing it navigates. */ +@media (min-width: 920px) { + .nav { + right: calc((100% - var(--max-width)) / 2 - 44px); } } diff --git a/crates/plugins/command/serve-web/src/style.rs b/crates/plugins/command/serve-web/src/style.rs index 5e7ac534b..3e4e2d7f9 100644 --- a/crates/plugins/command/serve-web/src/style.rs +++ b/crates/plugins/command/serve-web/src/style.rs @@ -7,6 +7,29 @@ use sha2::{Digest as _, Sha256}; /// The CSS content, embedded at compile time. pub(crate) const CSS: &str = include_str!("style.css"); +/// The app icon, embedded at compile time. +/// +/// SVG rather than PNG so it can live in the source tree as text. +/// Browsers take it for the tab icon and recent iOS takes it from the web +/// manifest for the home screen; older iOS wants a PNG `apple-touch-icon` and +/// falls back to a page screenshot without one. +pub(crate) const ICON: &str = include_str!("icon.svg"); + +/// The web app manifest, so the page installs to a home screen with a name and +/// an icon rather than as a bare bookmark. +pub(crate) const MANIFEST: &str = r##"{ + "name": "JP Conversations", + "short_name": "JP", + "start_url": "/conversations", + "display": "standalone", + "background_color": "#1a1a1a", + "theme_color": "#1a1a1a", + "icons": [ + { "src": "/assets/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" } + ] +} +"##; + /// A short hex hash of the CSS content, used to cache-bust the stylesheet URL. pub(crate) fn css_version() -> &'static str { static VERSION: OnceLock = OnceLock::new(); diff --git a/crates/plugins/command/serve-web/src/views/detail.rs b/crates/plugins/command/serve-web/src/views/detail.rs index 032f4d30d..2dda58181 100644 --- a/crates/plugins/command/serve-web/src/views/detail.rs +++ b/crates/plugins/command/serve-web/src/views/detail.rs @@ -2,17 +2,18 @@ use maud::{Markup, PreEscaped, html}; -use crate::{render::RenderedEvent, views::layout}; +use crate::{ + render::{self, RenderedEvent}, + views::layout, +}; -/// Render the conversation detail page. -pub(crate) fn render(title: &str, events: &[RenderedEvent]) -> Markup { - layout::page(title, html! { - header class="page-header" { - a href="/conversations" class="back" { "← Conversations" } - h1 { (title) } - } - main class="conversation-detail" { - @for event in events { +/// Render the conversation's messages. +/// +/// Separate from the page so the poll endpoint can re-render just this list +/// into a live page. +pub(crate) fn messages(events: &[RenderedEvent]) -> Markup { + html! { + @for event in events { @match event { RenderedEvent::TurnSeparator => { hr class="turn-separator"; @@ -59,7 +60,1725 @@ pub(crate) fn render(title: &str, events: &[RenderedEvent]) -> Markup { } } } + } + } +} + +/// An upward arrow, the chat convention for sending. +/// +/// Inline rather than a font glyph or an image: it inherits `currentColor`, +/// needs no request, and cannot arrive after the button it belongs to. +fn send_icon() -> Markup { + html! { + svg + viewBox="0 0 24 24" + width="20" + height="20" + fill="none" + stroke="currentColor" + stroke-width="2.5" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + { + path d="M12 19V5" {} + path d="M5 12l7-7 7 7" {} + } + } +} + +/// A chevron, pointing where the button goes. +/// +/// `doubled` stacks a second one for the ends of the conversation, the usual +/// way to distinguish "as far as this goes" from "one step". +fn chevron(down: bool, doubled: bool) -> Markup { + // Two chevrons drawn at the same offsets, flipped as a whole for direction, so + // the pair stays symmetric rather than being two hand-placed paths. + let rotate = if down { "rotate(180 12 12)" } else { "" }; + + html! { + svg + viewBox="0 0 24 24" + width="18" + height="18" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + { + g transform=(rotate) { + @if doubled { + path d="M6 16l6-6 6 6" {} + path d="M6 9l6-6 6 6" {} + } @else { + path d="M6 15l6-6 6 6" {} + } + } + } + } +} + +/// The toggle for the navigation menu: stacked lines, as for any list of jumps. +fn navigate_icon() -> Markup { + html! { + svg + viewBox="0 0 24 24" + width="18" + height="18" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + aria-hidden="true" + { + path d="M5 7h14" {} + path d="M5 12h14" {} + path d="M5 17h14" {} + } + } +} + +/// Which configurations the next message runs under. +/// +/// A native dialog: the backdrop, focus trapping and Escape are the element's +/// job, and doing them by hand is how they end up subtly wrong. +fn config_modal() -> Markup { + html! { + dialog id="config-modal" class="config-modal" { + form method="dialog" class="config-form" { + h2 { "Configuration" } + p class="config-note" { + "Applies from the next message onward, as " + code { "jp q --cfg" } + " does." + } + + // Filled when the dialog is first opened, so the page does not pay + // for a list most visits never look at. + div id="config-groups" class="config-groups" { + p class="config-note" { "Loading…" } + } + + div class="config-actions" { + button type="submit" value="cancel" { "Cancel" } + button type="submit" value="apply" class="config-apply" { "Apply" } + } + } + } + } +} + +/// Arrows to opposite corners: the usual sign for a larger view of this. +fn expand_icon() -> Markup { + html! { + svg + viewBox="0 0 24 24" width="16" height="16" fill="none" + stroke="currentColor" stroke-width="2" stroke-linecap="round" + stroke-linejoin="round" aria-hidden="true" + { + path d="M9 3H3v6" {} + path d="M3 3l7 7" {} + path d="M15 21h6v-6" {} + path d="M21 21l-7-7" {} + } + } +} + +/// A quotation mark, for pulling a passage into a reply. +fn quote_icon() -> Markup { + html! { + svg + viewBox="0 0 24 24" width="16" height="16" fill="none" + stroke="currentColor" stroke-width="2" stroke-linecap="round" + stroke-linejoin="round" aria-hidden="true" + { + path d="M4 6h16" {} + path d="M4 18h10" {} + path d="M4 12h13" {} + path d="M20 10v8" {} + } + } +} + +/// The composer again, with room to write in. +fn expand_modal() -> Markup { + html! { + dialog id="expand-modal" class="expand-modal" { + form method="dialog" class="expand-form" { + textarea id="expanded" placeholder="Reply to this conversation…" {} + div class="config-actions" { + button type="submit" class="config-apply" { "Done" } + } } } + } +} + +/// The conversation's name, and the means to change it. +/// +/// The heading and the form swap rather than the heading becoming editable: a +/// form brings Enter-to-submit and a real input with it, and a title is short +/// enough that losing the heading's styling for a moment costs nothing. +fn title_bar(id: &str, title: &str) -> Markup { + html! { + h1 id="title" { (title) } + + button type="button" id="rename" title="Rename" aria-label="Rename" { + (pencil_icon()) + } + + form + id="rename-form" + class="rename-form" + method="post" + action={ "/conversations/" (id) "/title" } + hidden + { + input id="title-field" name="title" type="text" value=(title) + autocomplete="off" aria-label="Conversation title"; + button type="submit" title="Save" aria-label="Save" { (tick_icon()) } + button type="button" id="rename-cancel" title="Cancel" aria-label="Cancel" { + (cross_icon()) + } + } + } +} + +/// A pencil, for editing what is beside it. +fn pencil_icon() -> Markup { + html! { + svg + viewBox="0 0 24 24" width="14" height="14" fill="none" + stroke="currentColor" stroke-width="2" stroke-linecap="round" + stroke-linejoin="round" aria-hidden="true" + { + path d="M12 20h9" {} + path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" {} + } + } +} + +/// A tick: accept. +fn tick_icon() -> Markup { + html! { + svg + viewBox="0 0 24 24" width="14" height="14" fill="none" + stroke="currentColor" stroke-width="2.5" stroke-linecap="round" + stroke-linejoin="round" aria-hidden="true" + { path d="M20 6L9 17l-5-5" {} } + } +} + +/// A cross: back out. +fn cross_icon() -> Markup { + html! { + svg + viewBox="0 0 24 24" width="14" height="14" fill="none" + stroke="currentColor" stroke-width="2.5" stroke-linecap="round" + aria-hidden="true" + { + path d="M18 6L6 18" {} + path d="M6 6l12 12" {} + } + } +} + +/// A cog: settings for what comes next. +fn cog_icon() -> Markup { + html! { + svg + viewBox="0 0 24 24" + width="18" + height="18" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + { + circle cx="12" cy="12" r="3" {} + path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-2.9 1.2v.2a2 2 0 1 1-4 0v-.1A1.7 1.7 0 0 0 7 19.4a1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0-1.2-2.9H1a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 2.6 7a1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H7a1.7 1.7 0 0 0 1-1.5V1a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 2.9 1.2l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V7a1.7 1.7 0 0 0 1.5 1H23a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" {} + } + } +} + +/// A barred circle: the sign for "stop that". +fn stop_icon() -> Markup { + html! { + svg + viewBox="0 0 24 24" + width="18" + height="18" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + aria-hidden="true" + { + circle cx="12" cy="12" r="9" {} + path d="M8 12h8" {} + } + } +} + +/// Render a submitted message that hasn't reached the transcript yet. +/// +/// Built from the same parts as a real request — the turn divider, the `You` +/// header, and markdown run through the same renderer — so that when the poll +/// swaps in the persisted event, nothing moves or reflows. +/// Only the dimming distinguishes them. +pub(crate) fn pending(content: &str) -> Markup { + html! { + hr class="turn-separator"; + div class="message user pending" { + div class="role" { "You" } + div class="content" { (PreEscaped(render::markdown_to_html(content))) } + } + } +} + +/// Render the conversation detail page. +/// +/// `running` shows the working indicator from the first paint, so a reload +/// during a turn doesn't look idle. +/// `stoppable` says whether the turn is this server's to interrupt. +/// `first` is the index `events` starts at and `total` how many there are, so +/// the page knows whether older ones exist and where to ask for them. +pub(crate) fn render( + id: &str, + title: &str, + events: &[RenderedEvent], + first: usize, + total: usize, + running: bool, + stoppable: bool, +) -> Markup { + layout::page(title, html! { + header class="page-header" { + a href="/conversations" class="back" { "← Conversations" } + (title_bar(id, title)) + } + + // Holds the transcript and anything that floats over it. The transcript + // is the only scrolling region on the page; everything else is a fixed + // row, which is what keeps the composer put while iOS moves its keyboard + // around — there is no page scroll for the dock to drift against. + div class="stage" { + // Raised by the poller when the server it is talking to is not the + // one this page came from. Floats just under the header rather than + // taking a row, so it never reflows the conversation. + div id="reload" class="reload-banner" hidden { + "The server restarted with a new build. " + a href="" { "Reload" } + " to pick it up." + } + + // Hidden until the page has scrolled to the end, so a long transcript + // is not watched painting from the top. + div id="loading" class="loading-veil" { } + + // Jumps within the conversation, over the transcript's bottom-right. + // + // A `details` rather than a scripted toggle: opening and closing is + // what the element is for, and it keeps working if the script does + // not. The jumps themselves need the script. + details id="nav" class="nav" { + summary title="Navigate" aria-label="Navigate" { (navigate_icon()) } + + div class="nav-menu" { + button type="button" data-nav="top" title="To the top" aria-label="To the top" { + (chevron(false, true)) + } + button type="button" data-nav="prev" title="Previous turn" aria-label="Previous turn" { + (chevron(false, false)) + } + button type="button" data-nav="next" title="Next turn" aria-label="Next turn" { + (chevron(true, false)) + } + button type="button" data-nav="bottom" title="To the bottom" aria-label="To the bottom" { + (chevron(true, true)) + } + } + } + + (config_modal()) + (expand_modal()) + + main id="transcript" class="conversation-detail" { + // Replaced wholesale by the poller when the count changes. + // `first` is where this window starts and `count` where it ends; + // older events are fetched when the reader scrolls back to them. + div id="messages" data-first=(first) data-count=(total) { + (messages(events)) + } + + // A message that has been submitted but hasn't reached the + // transcript yet. The poller fills and clears it. + div id="pending" {} + + // Where the reply will appear, which is where its progress + // belongs. Filled by the poller while a turn runs, and again when + // one fails. + div id="status" class="composer-status" { + @if running { + span class="composer-working" role="status" aria-label="Working" { + i {} i {} i {} + } + @if !stoppable { + span class="composer-hint" { + "Another process is running this turn." + } + } + // Only when this server is the one running the turn: an + // interrupt reaches its own host, and a turn started in a + // terminal belongs to a process this cannot signal. + @if stoppable { + form + class="composer-stop" + method="post" + action={ "/conversations/" (id) "/interrupt" } + { + button type="submit" title="Stop" aria-label="Stop" { + (stop_icon()) + } + } + } + } + } + + // What "the end" means, for scrolling to it. + // + // Everything above it down here comes and goes — the pending copy, + // the status row — and an element with no box cannot be scrolled + // to. This one is always here and always has a height. + div id="end" {} + } + } + + // A row of its own below the transcript, so the input stays reachable in + // a long conversation and the status never scrolls away from the control + // it explains. + div class="composer-dock" { + + // A plain form post: sending a message needs no JavaScript. The + // response is a redirect back here, issued as soon as the turn is + // handed to the host rather than when it finishes. + form id="composer" class="composer" method="post" action={ "/conversations/" (id) "/turn" } { + // Acting on the field below them, so above it and inside the same + // frame rather than off in a corner. + div class="composer-tools" { + button type="button" id="expand" data-label="Expand" aria-label="Expand" { + (expand_icon()) + } + button type="button" id="quote" data-label="Quote selection" aria-label="Quote selection" { + (quote_icon()) + } + button + type="button" + id="open-config" + data-label="Configuration" + aria-label="Configuration for the next message" + { + (cog_icon()) + } + } + + // One row by default, grown by the page while focused. An idle + // composer should cost the conversation as little height as it can. + textarea + name="content" + rows="1" + placeholder="Reply to this conversation…" + required {} + + // Enabled during a turn this server owns — sending then is how you + // interrupt and respond. Disabled for a turn another process holds, + // where the lock would refuse it for as long as that turn runs; the + // status above says so. + button + id="send" + type="submit" + title="Send" + aria-label="Send" + disabled[running && !stoppable] + { + (send_icon()) + } + + // Raised when a save was refused because the draft moved on. + p id="draft-note" class="composer-error" hidden {} + } + + } + + script { (PreEscaped(LIVE_SCRIPT)) } }) } + +/// The page's own behaviour: stick to the bottom, and poll for new events and +/// turn status. +/// +/// All of it is enhancement. +/// The composer is a plain form post and the transcript is server-rendered, so +/// with JavaScript off the page still works — it just needs a manual refresh +/// to show what arrived since it loaded. +/// +/// The poll URL is derived from the page's own path, which keeps this a static +/// string: no per-page formatting, and nothing interpolated into a script tag. +const LIVE_SCRIPT: &str = r" +// The two faces of the send button, matching what the server renders. +// Single-quoted attributes: this script is a Rust string, and a double quote ends +// it. +const SEND_SVG = + ``; + +const CANCEL_SVG = + ``; + +const transcript = document.getElementById('transcript'); +const end = document.getElementById('end'); +const box = document.getElementById('messages'); + +// The window this page holds: `first` is the index of its oldest event, and +// `data-count` the total the conversation has. Older ones are fetched as the +// reader scrolls back to them. +const older = () => Number(box.dataset.first) > 0; +let loadingOlder = false; +const pending = document.getElementById('pending'); +const status = document.getElementById('status'); +const composer = document.getElementById('composer'); +const send = document.getElementById('send'); +const reload = document.getElementById('reload'); +const draftNote = document.getElementById('draft-note'); +let boot = null; + +// Set once the form is on its way, so the draft handlers below stop writing: +// the message belongs to the conversation now, not to the draft. +let submitted = false; + +// Whether the send button is currently offering to pull the message back. +let cancelling = false; + +function setCancelMode(on) { + if (on === cancelling) return; + cancelling = on; + + send.classList.toggle('cancelling', on); + send.title = on ? 'Cancel' : 'Send'; + send.setAttribute('aria-label', send.title); + send.innerHTML = on ? CANCEL_SVG : SEND_SVG; +} + +// The event count as it was when a message was sent, or null when nothing is in +// flight. The field is emptied once the count moves past it, which is the first +// moment the message is known to have been recorded rather than merely accepted. +let clearWhenLanded = null; + +// Sealed while a message is on its way. +// +// The field still holds the text at that point — it is not released until the +// message is recorded — so leaving it editable invites typing into a value that is +// about to be cleared, and leaving Send live invites sending it twice. +function lockComposer(locked) { + input.readOnly = locked; + send.disabled = locked; +} +const base = location.pathname.replace(/\/$/, ''); +const url = base + '/messages'; +const draftUrl = base + '/draft'; + +// This tab's identity, so the server can tell a turn this window started from one +// another window did. `sessionStorage` is per-tab and survives a reload, which is +// the same lifetime a terminal session has. +// +// Never leaves the server: it exists to answer whether a turn is this window's, +// and no other process has any use for that answer. +const clientId = (() => { + try { + let id = sessionStorage.getItem('jp-client'); + if (!id) { + id = Math.random().toString(36).slice(2) + Date.now().toString(36); + sessionStorage.setItem('jp-client', id); + } + return id; + } catch (e) { + // Private browsing, or storage denied. Turns then read as shared, which errs + // toward asking rather than assuming. + return ''; + } +})(); + +// Size the app to what is actually visible. +// +// iOS shrinks the visual viewport for the keyboard without touching the layout +// viewport. Chasing that with a sticky offset always trails by a frame and drifts +// while the page scrolls, because iOS pans the visual viewport during a gesture. +// Sizing the whole app to the visible height instead means the composer is simply +// the last row of a box that fits: nothing to chase, nothing to drift. +const visible = () => (window.visualViewport ? visualViewport.height : innerHeight); + +// How long to keep following after a change, and the single-frame jump above which +// iOS is reporting a destination rather than a slide. +const SETTLE_MS = 600; +const STEP_PX = 24; + +// How much of the layout viewport the keyboard covers. +// +// The layout viewport keeps its full height on iOS while the visual viewport +// shrinks and pans, so the difference between them is the keyboard. +function keyboardInset() { + const vv = window.visualViewport; + if (!vv) return 0; + return Math.max(0, innerHeight - vv.height - vv.offsetTop); +} + +// Publish the keyboard height for the composer to lift itself by. +// +// Compares against what was last written rather than against the previous +// measurement. A change that lands between two frames — or before any loop starts, +// which is what happens when the keyboard closes — is still a change from what is +// on screen, and measuring against the reading would call it settled and leave the +// stale value in place. +let applied = -1; + +function fitApp() { + const inset = keyboardInset(); + if (inset === applied) return; + + // A large jump is iOS reporting the destination rather than the slide. That one + // gets eased; the small ones are the slide itself and are followed exactly. + document.documentElement.classList.toggle( + 'eased', + applied >= 0 && Math.abs(inset - applied) > STEP_PX, + ); + + applied = inset; + document.documentElement.style.setProperty('--kb', inset + 'px'); +} + +// The composer's height, so the transcript can reserve room for it. +// +// Measured rather than assumed, because it changes: the field grows on focus, and +// the status row appears while a turn runs. A fixed element takes no space of its +// own, so without this the last message sits underneath it. +// Rounded, and only when it moves by more than a pixel. +// +// The field's reported height wobbles by a pixel as focus comes and goes, and +// writing that through shrinks the space reserved for the dock — which moves the +// whole conversation down by a pixel for no reason anyone asked for. +const dock = document.querySelector('.composer-dock'); +let dockHeight = 0; + +function fitDock() { + const height = Math.round(dock.getBoundingClientRect().height); + if (Math.abs(height - dockHeight) <= 1) return; + + const wasDown = atBottom(); + dockHeight = height; + document.documentElement.style.setProperty('--dock', height + 'px'); + if (wasDown) toBottom(); +} + +// Follow the keyboard by sampling it, rather than by modelling it. +// +// iOS animates the keyboard over a duration it reports to native code and not to +// the web, using an easing curve Apple has never published. Any transition here is +// therefore a guess at both, and a guess that is close is still visibly out of +// step with the thing it is imitating. +// +// Reading `visualViewport.height` every frame sidesteps the question: whatever the +// curve and duration are, the height is the truth about where the keyboard is now. +// Where iOS reports the slide in steps, this follows the steps; the eased class +// below smooths the case where it reports the end state in one jump instead. +// +// Runs only in bursts around a viewport change, not continuously. +let tracking = 0; +function trackKeyboard() { + const until = performance.now() + SETTLE_MS; + + // Extends the window a running loop already covers rather than starting a second + // one: viewport events arrive in bursts. + if (tracking > 0) { + tracking = until; + return; + } + + tracking = until; + + // Captured once, at the start: whether to hold the newest message against the + // composer is a question about where the reader was before the keyboard moved. + const wasDown = atBottom(); + + const step = () => { + fitApp(); + + // Each frame, because the composer is still moving over the content. + if (wasDown) toBottom(); + + if (performance.now() < tracking) { + requestAnimationFrame(step); + return; + } + + tracking = 0; + if (wasDown) toBottom(); + }; + + requestAnimationFrame(step); +} + +// `resize` on the visual viewport reports the keyboard taking space; `scroll` +// reports it being panned. The window's own `resize` covers the keyboard closing, +// which iOS does not always report on the visual viewport at all. +if (window.visualViewport) { + visualViewport.addEventListener('resize', trackKeyboard); + visualViewport.addEventListener('scroll', trackKeyboard); +} +addEventListener('resize', trackKeyboard); +addEventListener('orientationchange', trackKeyboard); +fitApp(); + +// Stop iOS panning the page away, rather than trying to put it back. +// +// Tapping a field makes iOS focus it and pan the viewport so it clears the +// keyboard, which carries the header off the top of the screen. Focusing the +// field ourselves first, in the capture phase before the native tap flow gets +// there, means iOS finds it already focused and skips the pan entirely. +// +// This is the part that works. Undoing the pan afterwards cannot: scrolling back +// does not stick while the field holds focus, because iOS re-applies it to keep +// the field visible — which is what every earlier attempt here ran into. +// +// `preventScroll` also suppresses the browser's own scroll-into-view. That costs +// nothing here: the composer is a row of a box sized to the visible area, so it +// is never behind the keyboard to begin with. +document.addEventListener('touchstart', (event) => { + const target = event.target; + if (target?.matches?.('textarea, input') && document.activeElement !== target) { + target.focus({ preventScroll: true }); + } +}, { capture: true, passive: true }); + +// Backgrounding the app with the keyboard open leaves the layout wrong on return: +// iOS snapshots the focused state and keeps the page scrolled to hold the field in +// view. Dropping focus is what releases that, and only then does resetting the +// scroll stick. +function blurField() { + const active = document.activeElement; + if (active?.matches?.('textarea, input')) active.blur(); +} + +function restore() { + blurField(); + + if (scrollY !== 0 || scrollX !== 0) scrollTo(0, 0); + const root = document.scrollingElement || document.documentElement; + if (root.scrollTop !== 0) root.scrollTop = 0; + + fitApp(); + + // Coming back to the page is the moment its content is most likely to be + // stale, and a restored page runs no script on the way in: the timer is + // wherever it was left, up to three seconds away. Ask now instead of waiting + // for it. + poll(); +} + +addEventListener('pageshow', restore); +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') blurField(); + else restore(); +}); + +// One line when idle, grown to its content while focused. +// +// An unfocused composer is dead space in a conversation, so it collapses back to a +// single row and gives the height to the transcript. Clearing the inline height +// returns it to the one-row default from the markup, rather than guessing a pixel +// value here. +const input = composer.querySelector('textarea'); + +// One row, measured once: what the field collapses back to when it is not being +// typed in. +const restHeight = (() => { + input.style.height = 'auto'; + const height = input.scrollHeight; + input.style.height = height + 'px'; + return height; +})(); +function fitInput() { + + // `auto` first so the field can shrink as well as grow, then the final height, + // both in one synchronous block: the browser paints once, at the end. Reading + // and restoring in between is what made this flicker. + // + // Always an explicit height, never cleared. The field's natural height differs + // from its measured one-row height by a fraction of a pixel, so letting it fall + // back on blur resizes the dock — which reserves space for itself, so the whole + // conversation shifts by a pixel every time focus comes and goes. + const current = input.style.height; + + let wanted; + if (document.activeElement === input) { + input.style.height = 'auto'; + wanted = Math.min(input.scrollHeight, visible() / 3) + 'px'; + } else { + wanted = restHeight + 'px'; + } + + // Nothing to do, and nothing to scroll. Most keystrokes land here: a line only + // wraps occasionally, and re-scrolling on every character is what made typing + // shove the conversation up and down. + if (wanted === current) { + input.style.height = current; + return; + } + + const wasDown = atBottom(); + input.style.height = wanted; + if (wasDown) toBottom(); +} +input.addEventListener('input', fitInput); + +// Cmd+Enter sends, as in every other composer. +// +// `requestSubmit` rather than `submit`: it raises the submit event, which is what +// posts in the background and keeps the text until the message lands. `submit` +// would bypass all of that and navigate. +input.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' || !(event.metaKey || event.ctrlKey)) return; + + event.preventDefault(); + composer.requestSubmit(); +}); + +// Which configurations the next message runs under. +// +// Kept here rather than on the server: nothing is applied until a message is +// sent, so this is a choice in progress, not state the conversation has. +const configModal = document.getElementById('config-modal'); +const configGroups = document.getElementById('config-groups'); +let chosenConfigs = new Set(); +let configsLoaded = false; + +document.getElementById('open-config').addEventListener('click', () => { + nav.open = false; + configModal.showModal(); + loadConfigs(); +}); + +async function loadConfigs() { + if (configsLoaded) return; + + try { + const r = await fetch('/configs'); + if (!r.ok) throw new Error(r.status); + + const entries = await r.json(); + configsLoaded = true; + configGroups.textContent = ''; + + if (entries.length === 0) { + const empty = document.createElement('p'); + empty.className = 'config-note'; + empty.textContent = 'No configurations found on the load paths.'; + configGroups.append(empty); + return; + } + + // Grouped by namespace, relying on the host's sort by segment: entries in one + // namespace share a prefix, so they arrive together. + let group = null; + let namespace = null; + + for (const entry of entries) { + if (group === null || entry.namespace !== namespace) { + namespace = entry.namespace; + group = document.createElement('fieldset'); + const legend = document.createElement('legend'); + legend.textContent = namespace || 'General'; + group.append(legend); + configGroups.append(group); + } + + const label = document.createElement('label'); + label.className = 'config-option'; + + const box = document.createElement('input'); + box.type = 'checkbox'; + box.value = entry.segment; + box.checked = chosenConfigs.has(entry.segment); + + const name = document.createElement('span'); + name.textContent = entry.name; + + label.append(box, name); + group.append(label); + } + } catch (e) { + configGroups.textContent = ''; + const failed = document.createElement('p'); + failed.className = 'composer-error'; + failed.textContent = 'Could not read the available configurations.'; + configGroups.append(failed); + } +} + +// Cancel leaves the previous choice alone; apply replaces it with what is ticked. +configModal.addEventListener('close', () => { + if (configModal.returnValue !== 'apply') return; + + chosenConfigs = new Set( + Array.from(configGroups.querySelectorAll('input:checked')).map(box => box.value), + ); + + document.getElementById('open-config').classList.toggle('active', chosenConfigs.size > 0); +}); + +// A larger field for a longer reply. +// +// The same value, not a second draft: the small field is the one that gets sent, +// so this copies in on open and back out on close. +const expanded = document.getElementById('expanded'); +const expandModal = document.getElementById('expand-modal'); + +document.getElementById('expand').addEventListener('click', () => { + expanded.value = input.value; + expandModal.showModal(); + expanded.focus(); + expanded.setSelectionRange(expanded.value.length, expanded.value.length); +}); + +expandModal.addEventListener('close', () => { + input.value = expanded.value; + fitInput(); + saveDraft(); +}); + +// Quote what is selected. +// +// Back to markdown rather than plain text: the transcript is rendered markdown, so +// a quote of it should read as what was written, not as its rendering flattened. +// A subset — the block and inline elements the renderer emits — and anything else +// falls through to its text. +function toMarkdown(node) { + if (node.nodeType === Node.TEXT_NODE) return node.textContent; + if (node.nodeType !== Node.ELEMENT_NODE) return ''; + + const inner = () => Array.from(node.childNodes).map(toMarkdown).join(''); + + switch (node.tagName) { + case 'BR': return '\n'; + case 'P': return inner() + '\n\n'; + case 'PRE': return '```\n' + node.textContent.replace(/\n$/, '') + '\n```\n\n'; + case 'CODE': return node.closest('pre') ? node.textContent : '`' + inner() + '`'; + case 'STRONG': case 'B': return '**' + inner() + '**'; + case 'EM': case 'I': return '*' + inner() + '*'; + case 'DEL': return '~~' + inner() + '~~'; + case 'A': return '[' + inner() + '](' + (node.getAttribute('href') ?? '') + ')'; + case 'LI': return '- ' + inner().trim() + '\n'; + case 'UL': case 'OL': return inner() + '\n'; + case 'BLOCKQUOTE': + return inner().trim().split('\n').map(line => '> ' + line).join('\n') + '\n\n'; + case 'H1': case 'H2': case 'H3': case 'H4': case 'H5': case 'H6': + return '#'.repeat(Number(node.tagName[1])) + ' ' + inner() + '\n\n'; + case 'HR': return '---\n\n'; + default: return inner(); + } +} + +document.getElementById('quote').addEventListener('click', () => { + const selection = getSelection(); + if (!selection || selection.isCollapsed) return; + + // The selection as its own tree, so partial elements come back whole rather + // than as the text between two points. + const fragment = selection.getRangeAt(0).cloneContents(); + const markdown = Array.from(fragment.childNodes).map(toMarkdown).join('').trim(); + if (!markdown) return; + + const quoted = markdown.split('\n').map(line => ('> ' + line).trimEnd()).join('\n'); + + // Appended, so quoting twice builds up rather than replacing. + input.value = input.value ? input.value.replace(/\s*$/, '\n\n') + quoted + '\n\n' : quoted + '\n\n'; + fitInput(); + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + saveDraft(); +}); + +// Renaming, in place. +// +// The heading and the field swap rather than the heading becoming editable: a +// form gets Enter-to-submit and a real input for free, and the title is short +// enough that losing the heading's styling for a moment costs nothing. +const heading = document.getElementById('title'); +const renameForm = document.getElementById('rename-form'); +const titleField = document.getElementById('title-field'); +const renameButton = document.getElementById('rename'); + +function showRename(editing) { + heading.hidden = editing; + renameButton.hidden = editing; + renameForm.hidden = !editing; + + if (editing) { + titleField.value = heading.textContent.trim(); + titleField.focus(); + titleField.select(); + } +} + +renameButton.addEventListener('click', () => showRename(true)); +document.getElementById('rename-cancel').addEventListener('click', () => showRename(false)); + +// Escape backs out; Enter commits. Both are handled here rather than left to the +// form, because a form inside a header is not reliably submitted by Enter and +// Escape would otherwise reach the dialog machinery instead. +titleField.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + showRename(false); + return; + } + + if (event.key === 'Enter') { + event.preventDefault(); + renameForm.requestSubmit(); + } +}); + +renameForm.addEventListener('submit', async (event) => { + event.preventDefault(); + + const title = titleField.value.trim(); + + try { + const r = await fetch(renameForm.action, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + accept: 'application/json', + }, + body: new URLSearchParams({ title }), + }); + if (!r.ok) throw new Error(r.status); + + // Applied here rather than reloading, so the transcript and the scroll stay + // where they are. + heading.textContent = title || 'Untitled'; + document.title = (title || 'Untitled') + ' - JP'; + showRename(false); + } catch (e) { + titleField.setCustomValidity('Could not rename this conversation.'); + titleField.reportValidity(); + titleField.setCustomValidity(''); + } +}); + +// Stopping posts in the background, like everything else here. +// +// The control is a real form so it works without script, but letting it navigate +// means a full reload — and the page that comes back still shows the turn as +// running, because the lock is held until it has finished unwinding. That reads +// as the button having done nothing. +// +// Delegated, so it covers both the form the server renders and the one the poller +// builds. A handler on the form itself may already have asked for confirmation +// and been declined; that shows up as the event being cancelled, and is left +// alone. +status.addEventListener('submit', async (event) => { + const form = event.target.closest('.composer-stop'); + if (!form || event.defaultPrevented) return; + + event.preventDefault(); + + try { + await fetch(form.action, { + method: 'POST', + headers: { accept: 'application/json' }, + }); + } catch (e) { + // The poll reports what actually happened either way. + } + + poll(); +}); + +// The header is a way back to the top, matching the platform gesture this page +// cannot receive: with the document pinned to the window, there is no window +// scroll position for iOS to reset when the status bar is tapped. +// +// Ignores clicks on the links inside it, which have somewhere else to go. +document.querySelector('.page-header').addEventListener('click', (event) => { + // The links and the rename controls inside it have their own jobs. + if (event.target.closest('a, button, form')) return; + + transcript.scrollTop = 0; +}); + +// Older events, fetched as the reader scrolls back to them. +// +// The page holds a window rather than the whole conversation: a long one is +// thousands of nodes, and painting them all is what made scrolling crawl. +// +// Prepending moves everything down by the height of what was added, so the scroll +// position is corrected by that much — otherwise the reader is thrown backwards +// by exactly the amount they just gained. +async function loadOlder(all) { + if (loadingOlder || !older()) return false; + loadingOlder = true; + + try { + const r = await fetch( + url + '?before=' + box.dataset.first + (all ? '&all=1' : ''), + ); + if (!r.ok) return false; + + const d = await r.json(); + if (d.html === undefined) return false; + + const before = transcript.scrollHeight; + box.insertAdjacentHTML('afterbegin', d.html); + box.dataset.first = d.from; + transcript.scrollTop += transcript.scrollHeight - before; + + return true; + } catch (e) { + return false; + } finally { + loadingOlder = false; + } +} + +// Everything older, in one request rather than a window at a time: a conversation +// of several thousand events is dozens of round trips that way, and the reader is +// left watching the scrollbar twitch. +const loadAllOlder = () => loadOlder(true); + +// Fetched well before the reader arrives, so scrolling back at a normal pace +// never meets the top of what is loaded. A window is large enough that this +// rarely fires twice in a row. +transcript.addEventListener('scroll', () => { + if (transcript.scrollTop < 3000) loadOlder(false); +}, { passive: true }); + +// Touch platforms take the whole conversation up front. +// +// Windowing exists because painting a long transcript is slow on a desktop +// browser; on touch it never was, and there the fetching is the only thing the +// reader would notice. So they get what they had: everything, once, and no pauses +// while scrolling back. +if (!matchMedia('(hover: hover) and (pointer: fine)').matches) { + addEventListener('load', () => loadAllOlder()); +} + +// Jumps between turns. +// +// A turn starts at its separator, so those are the anchors. `prev` and `next` are +// relative to what is at the top of the view rather than to a remembered position, +// which keeps the buttons honest after scrolling by hand. +const nav = document.getElementById('nav'); + +function turnStarts() { + return Array.from(transcript.querySelectorAll('.turn-separator')); +} + +function jump(where) { + if (where === 'top') { + // The whole conversation, then the top of it. Anything less would land at the + // top of the window rather than the top of the conversation, which is not what + // the button says. + loadAllOlder().then(() => { transcript.scrollTop = 0; }); + return; + } + + if (where === 'bottom') { + toBottom(); + return; + } + + const starts = turnStarts(); + if (starts.length === 0) return; + + // Offsets within the scroller, which is what `scrollTop` is measured against. + const tops = starts.map(el => el.offsetTop - transcript.offsetTop); + + // A few pixels of slack, so a jump that lands a hair past a separator does not + // count as already being below it. + const here = transcript.scrollTop + 2; + + const target = where === 'next' + ? tops.find(top => top > here) + : tops.filter(top => top < here - 4).pop(); + + if (target !== undefined) transcript.scrollTop = target; +} + +nav.addEventListener('click', (event) => { + const button = event.target.closest('[data-nav]'); + if (!button) return; + + jump(button.dataset.nav); +}); + +// Deliberately no close-on-outside-click: the menu is for jumping around a +// conversation, and every jump is a click on the thing being navigated. Closing +// on those would mean reopening it between each one. + +// The transcript is the scroller, not the window. +const atBottom = () => + transcript.scrollTop + transcript.clientHeight >= transcript.scrollHeight - 80; +// Scroll to the end. +// +// An element is scrolled into view rather than `scrollTop` set to `scrollHeight`, +// because that height is a lie while messages further up are still skipped: it is +// built from their estimates, so setting it lands short and the view stops an +// event or two above the newest. +const toBottom = () => { + // The anchor, not the last child: the last child is the status row, which is + // `display: none` whenever there is nothing to say, and scrolling a box-less + // element into view does nothing at all. + // + // The anchor always has a box, and is not a message — so it is never one of the + // elements whose height is being estimated. Aiming at it is the one way to reach + // the true end while the extent is still a guess. + end.scrollIntoView({ block: 'end' }); +}; + +// Stay at the end until it stops moving. +// +// One scroll is not enough after the transcript is replaced. Every message is +// recreated, so every one of them is unseen again and reports the placeholder +// height instead of its own; the extent collapses, the scroll lands on that false +// end, and then the messages near the viewport are laid out for real and the true +// end moves away below. From the reader's seat the view drifts upward, which is +// the opposite of what was asked for. +// +// So: scroll, look at whether the extent changed, and go again until it holds +// still. It converges quickly, because each pass realises the messages it just +// scrolled past. +// +// Bounded in time rather than in passes, because a slow frame should not end the +// chase early, and an extent that never settles must not spin forever. +let settling = 0; + +function stayAtBottom() { + const until = performance.now() + 600; + + // A pass already running just gets more time, rather than a second pass + // racing it. + if (settling > 0) { + settling = until; + return; + } + + settling = until; + let previous = -1; + + const step = () => { + toBottom(); + + const height = transcript.scrollHeight; + const held = height === previous; + previous = height; + + if (!held && performance.now() < settling) { + requestAnimationFrame(step); + return; + } + + settling = 0; + }; + + requestAnimationFrame(step); +} + +// Registered here rather than at the declaration: `fitDock` reads the scroll +// helpers above, which are not initialised until this point. +if (window.ResizeObserver) new ResizeObserver(fitDock).observe(dock); + +fitInput(); +fitDock(); +toBottom(); + +// Reveal once the conversation is in place. +// +// A long transcript paints top-down over seconds, so without this the reader +// watches it stream past from the beginning and then jump to the end. The overlay +// covers that, and comes off after a frame in which the scroll has been applied. +// Settled, not applied: messages out of view are skipped until scrolled near and +// report an estimated height until then, so scrolling to the end lands short, the +// messages there are laid out for real, and the end moves. Repeating until the +// height stops changing converges on the actual bottom, and the veil covers it. +function reveal() { + // The same chase the poller uses after a swap: on first paint no message has + // been measured either, so the end moves for the same reason. + stayAtBottom(); + + // Uncovered once that has had its window, so the settling happens behind the + // veil rather than in front of the reader. + setTimeout(() => document.documentElement.classList.add('ready'), 650); +} + +if (document.readyState === 'complete') reveal(); +else addEventListener('load', reveal); + +// A cap, so a page that never fires `load` — a stalled image, a slow font — is +// still usable. Better to reveal a conversation mid-scroll than to hold a blank +// screen over a working page. +setTimeout(() => document.documentElement.classList.add('ready'), 3000); + +// Both edges, and before the first viewport event: the keyboard starts moving on +// focus and on blur, and iOS may report nothing until it has finished. Without the +// blur half, the column stays at its keyboard-open height after the keyboard has +// gone. +input.addEventListener('focus', () => { fitInput(); trackKeyboard(); }); +input.addEventListener('blur', () => { fitInput(); trackKeyboard(); }); + +// Pre-emptively, before iOS snapshots the page with the field still focused. +addEventListener('pagehide', blurField); + +// Draft sync. +// +// The same file `jp query` uses, so a message can be started in a terminal and +// finished here, or the reverse, and a reload never loses what was typed. +// +// Writes are conditional on the revision last read: if the terminal changed the +// draft in the meantime the host refuses, hands back what is on disk, and this +// says so rather than overwriting it. Losing typing is the thing being avoided, +// so a refusal is the correct outcome, not a failure. +let revision = null; +let saving = false; + +// The newest content waiting for an in-flight save to finish. A boolean would +// lose it: the retry would re-read the field, which is wrong for a save that was +// asked to store something specific. +let queued = null; + +// Bounds the automatic re-save below, so a draft that keeps being cleared under +// us cannot turn into a request loop. +let retried = false; + +async function loadDraft() { + try { + const r = await fetch(draftUrl); + if (!r.ok) return; + const d = await r.json(); + revision = d.revision ?? null; + + // Never clobber something already being typed — a slow read must not win + // against the person at the keyboard. + if (d.content && !input.value) { + input.value = d.content; + fitInput(); + } + } catch (e) { + // No draft is a normal state; a failed read is not worth a message. + } +} + +// `content` defaults to what is in the field. Passing it explicitly is how the +// submit path clears the stored draft without touching the field — emptying the +// textarea during submit makes the form post an empty message, because the +// browser serialises it after the handler runs. +async function saveDraft(content) { + const text = content ?? input.value; + + // Nothing here and nothing recorded means nothing to say. Writing anyway would + // assert there is no draft, against one another device just wrote, and come back + // as a conflict about text this one never had. + if (!text && revision === null) return; + + if (saving) { queued = text; return; } + saving = true; + + try { + const r = await fetch(draftUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + // Survives the navigation a submit triggers. + keepalive: true, + body: JSON.stringify({ content: text, revision }), + }); + if (!r.ok) return; + const d = await r.json(); + revision = d.revision ?? null; + + // A draft that has gone *empty* underneath us is not somebody else's edit: + // the host clears it when it turns a message into a request, which leaves + // this page holding a revision for a file that no longer exists. Adopt the + // new revision and put the text back, rather than reporting a conflict that + // has no other party. + if (d.conflict && !d.content) { + draftNote.hidden = true; + + if (input.value && !retried) { + retried = true; + queued = input.value; + } + } else if (d.conflict) { + draftNote.textContent = + 'This draft was changed elsewhere. Yours is kept here; the other version ' + + 'is on disk.'; + draftNote.hidden = false; + } else { + draftNote.hidden = true; + retried = false; + } + } catch (e) { + // Offline or mid-restart. The next keystroke tries again. + } finally { + saving = false; + if (queued !== null) { + const next = queued; + queued = null; + saveDraft(next); + } + } +} + +let saveTimer = null; +input.addEventListener('input', () => { + clearTimeout(saveTimer); + saveTimer = setTimeout(() => saveDraft(), 600); +}); + +// Leaving the field, or the page, is the last chance to keep what is there — +// unless it has just been sent, in which case saving would resurrect it. +input.addEventListener('blur', () => { if (!submitted) saveDraft(); }); +addEventListener('pagehide', () => { if (!submitted) saveDraft(); }); + +// Send without navigating. +// +// A form post would reload the page: the transcript is rebuilt, the scroll jumps, +// the draft is re-read, and the composer loses focus and its height — all to show +// a message the poller was about to bring in anyway. Posting in the background +// leaves the page exactly as it was. +// +// The form still works without JavaScript; the handler is what suppresses the +// navigation, and the endpoint answers both shapes. +composer.addEventListener('submit', async (event) => { + event.preventDefault(); + + // In cancel mode the button pulls the message back rather than sending one. + // + // No confirmation: this page started the turn moments ago, which is what `own` + // means. Stopping a turn someone else started asks first, from the indicator's + // stop button. + if (cancelling) { + try { + await fetch(location.pathname.replace(/\/$/, '') + '/interrupt', { + method: 'POST', + headers: { accept: 'application/json' }, + }); + } catch (e) { + // The poll reports what actually happened either way. + } + poll(); + return; + } + + const content = input.value.trim(); + if (!content) return; + + submitted = true; + clearTimeout(saveTimer); + lockComposer(true); + + // Left in the field on purpose, and cleared only once the message is in the + // transcript. A turn can be refused after the request has been accepted — the + // conversation may be locked by another process — and clearing on send would + // destroy the message on the way to finding that out. + const landedAbove = Number(box.dataset.count); + + try { + const response = await fetch(composer.action, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + accept: 'application/json', + }, + body: (() => { + const params = new URLSearchParams({ content, client: clientId }); + // One entry per choice: the same shape `--cfg` takes, repeated. + for (const segment of chosenConfigs) params.append('cfg', segment); + return params; + })(), + }); + + const body = await response.json(); + + // Refused, not failed: the text stays where it is and the reason is shown. + if (!response.ok) { + draftNote.textContent = body.error ?? 'The message was not sent.'; + draftNote.hidden = false; + lockComposer(false); + submitted = false; + return; + } + + // Rendered by the server from the message just sent, so it is the same markup + // the transcript will carry and the swap moves nothing. + draftNote.hidden = true; + showPending(body.pending); + + // The chase, not one scroll: the ghost is a message like any other, and is no + // more measured than the rest. + stayAtBottom(); + } catch (e) { + lockComposer(false); + submitted = false; + return; + } + + // Free for the next message, including one meant to interrupt this turn. + submitted = false; + clearWhenLanded = landedAbove; + + poll(); +}); + +loadDraft(); + +// The server renders this, so it matches the real request exactly rather than +// approximating it in the DOM. +function showPending(html) { + if (pending.dataset.html === (html ?? '')) return; + pending.dataset.html = html ?? ''; + pending.innerHTML = html ?? ''; +} + +// Which disclosure blocks are open, so a swap doesn't collapse what is being +// read. The transcript only ever grows, so position is a stable key: blocks +// appended by the swap start closed, and everything before them keeps its state. +function openBlocks() { + return Array.from(box.querySelectorAll('details')).map(d => d.open); +} + +function restoreBlocks(open) { + box.querySelectorAll('details').forEach((d, i) => { + if (open[i]) d.open = true; + }); +} + +function showStatus(running, error, stopMode) { + const stoppable = stopMode === 'own' || stopMode === 'shared'; + if (error) { + status.dataset.running = 'false'; + status.textContent = ''; + const p = document.createElement('p'); + p.className = 'composer-error'; + p.textContent = error; + status.append(p); + return; + } + + const state = String(running) + ':' + String(stopMode); + if (state === status.dataset.running) return; + status.dataset.running = state; + status.textContent = ''; + if (running) { + const s = document.createElement('span'); + s.className = 'composer-working'; + s.role = 'status'; + s.ariaLabel = 'Working'; + s.append(...[0, 1, 2].map(() => document.createElement('i'))); + status.append(s); + + // Offered only for a turn this server is running: an interrupt reaches its + // own host, and a turn started elsewhere is another process's to stop. + if (stopMode === 'unreachable') { + const why = document.createElement('span'); + why.className = 'composer-hint'; + why.textContent = + 'Another process is running this turn; it can only be stopped there.'; + status.append(why); + } + + if (stoppable) { + const stop = document.createElement('form'); + stop.className = 'composer-stop'; + stop.method = 'post'; + stop.action = base + '/interrupt'; + + // Somebody else's work: stoppable, since this server is running it, but + // not without asking. Their window has no say in it and no warning that + // it happened. + if (stopMode === 'shared') { + stop.addEventListener('submit', (event) => { + const ok = confirm( + 'This turn was started in another window. Stop it anyway?', + ); + if (!ok) event.preventDefault(); + }); + } + + const button = document.createElement('button'); + button.type = 'submit'; + button.title = 'Stop'; + button.setAttribute('aria-label', 'Stop'); + // The same barred circle the server renders, so the swap is invisible. + // Single-quoted attributes: this whole script is a Rust string, and a double + // quote would end it. + button.innerHTML = + ``; + + stop.append(button); + status.append(stop); + } + } + + // The indicator sits at the end of the conversation, so appearing or going + // away changes its height. + if (atBottom()) toBottom(); +} + +// Polls overlap: the timer's and the one fired right after a submit. A slow +// earlier response arriving after a newer one would put the older state back, +// blanking a working indicator that had just appeared. Only the newest applies. +let pollSeq = 0; + +async function poll() { + const seq = ++pollSeq; + + try { + // The count we already have, so the answer can leave the transcript out when + // it has not changed. Rendering it is the whole conversation's markdown, and + // most polls change nothing. + const r = await fetch( + url + '?count=' + box.dataset.count + '&client=' + encodeURIComponent(clientId), + ); + if (!r.ok || seq !== pollSeq) return; + const d = await r.json(); + if (seq !== pollSeq) return; + + // A different server means this page's markup and styles are stale. Data + // recovers by itself; the page cannot. + if (boot === null) { + boot = d.boot; + } else if (d.boot !== boot) { + reload.hidden = false; + } + + // Taken before anything is inserted. Asking afterwards always says no: the + // content has grown by then, so the scroll position is no longer near the + // end even though it was a moment ago. + const wasDown = atBottom(); + + // Present only when the server had something the page does not. + // + // `from` says whether it continues the transcript or replaces it. Continuing + // is the normal case, and it leaves every message already on the page alone: + // their open blocks stay open, their measured heights stay measured, and the + // scroll position means the same thing before and after. + if (d.html !== undefined) { + box.dataset.count = d.count; + + const first = Number(box.dataset.first); + + if (d.from < first) { + // Older than anything held, which means the transcript was rewritten + // under us — compacted, or edited on disk. Nothing can be carried across, + // so the open blocks are restored by position. + const open = openBlocks(); + box.innerHTML = d.html; + box.dataset.first = d.from; + restoreBlocks(open); + } else { + // Everything from `from` onward is replaced, not appended. + // + // Usually that is nothing but new events on the end. It is more when the + // tail is still moving: a tool call shows its request first and its result + // later, and the entry that has to change is one the page already holds. + // With calls running in parallel that reaches back to the earliest one + // still waiting, so settled calls after it are rewritten too. + // + // Open blocks are captured across the whole transcript, not just the part + // being kept: the replaced events come back in the same order, so their + // positions still line up — and a disclosure the reader opened inside a + // finished tool call must not snap shut once a second because an earlier + // call is still running. + const keep = d.from - first; + const open = openBlocks(); + + while (box.children.length > keep) box.lastElementChild.remove(); + box.insertAdjacentHTML('beforeend', d.html); + restoreBlocks(open); + } + } + + // The message reached the transcript, so the field can let go of it. + if (clearWhenLanded !== null && d.count > clearWhenLanded) { + clearWhenLanded = null; + input.value = ''; + fitInput(); + saveDraft(''); + } + + // A refused turn leaves the text where it is, to be sent again or edited. + if (d.error) clearWhenLanded = null; + + showPending(d.pending); + + // The ghost and the indicator mean different things and must not both be up: + // the ghost says the request has not been taken yet, the dots say a reply is + // being written. Together they read as an answer to a message that has not + // arrived. + // `stop` is a mode, not a flag: `own`, `shared`, `none` or `unreachable`. + // `showStatus` wants the mode, because it renders differently for each; the + // rest here only needs to know whether stopping is possible at all. + const stoppable = d.stop === 'own' || d.stop === 'shared'; + + const awaitingSend = Boolean(d.pending); + showStatus(!awaitingSend && d.running, d.error, d.stop); + + // While the ghost is up, Send offers to pull the message back instead — the + // window in which a typo is still worth catching. Once it lands, the button + // returns to Send and the indicator takes over the stopping. + setCancelMode(awaitingSend && stoppable); + + // The field is held while the message is in flight, but the button is not: it + // is the way out of that state. + input.readOnly = clearWhenLanded !== null; + send.disabled = cancelling ? false : d.running && !stoppable; + + // Anything newly arrived needs the chase, appended or not: a message that has + // not been measured reports a placeholder height, and for a tall one that is a + // large undershoot — scroll to that end and the real height then pushes the + // end below the fold. A short one overshoots instead, which is why single-line + // tool calls always looked fine. + // + // Everything else has not moved the extent, so one scroll is enough. + if (wasDown) { + if (d.html !== undefined) stayAtBottom(); + else toBottom(); + } + } catch (e) { + // A failed poll is not worth reporting: the next one is a second away. + } +} + +// Attentive while a turn is live, lazy when idle. +status.dataset.running = String(!!status.querySelector('.composer-working')) + + ':' + String(!!status.querySelector('.composer-stop')); +// The flag is `running:stoppable`, so match the prefix rather than the whole. +(function tick() { + const live = () => status.dataset.running.startsWith('true'); + poll().finally(() => setTimeout(tick, live() ? 1000 : 3000)); +})(); +addEventListener('focus', poll); +"; diff --git a/crates/plugins/command/serve-web/src/views/layout.rs b/crates/plugins/command/serve-web/src/views/layout.rs index e3ca37cc4..ac275ad34 100644 --- a/crates/plugins/command/serve-web/src/views/layout.rs +++ b/crates/plugins/command/serve-web/src/views/layout.rs @@ -4,23 +4,71 @@ use maud::{DOCTYPE, Markup, html}; use crate::style; +/// How a page handles being taller than the window. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Scroll { + /// The document scrolls, as a plain web page does. + /// + /// Gets the platform's scrolling behaviour for free — on iOS that includes + /// tapping the status bar to return to the top, which is not an event a + /// page can subscribe to. + /// It only works when there is a window scroll position for the system to + /// reset. + Document, + + /// The document is fixed to the window and something inside it scrolls. + /// + /// Required wherever a virtual keyboard is involved: with no page scroll + /// there is nothing for iOS to pan when a field is focused, which is what + /// keeps the header from sliding off the top. + /// The cost is the platform gestures above. + Inner, +} + /// Wrap page content in the common HTML shell. +pub(crate) fn page(title: &str, body: Markup) -> Markup { + shell(title, Scroll::Inner, body) +} + +/// [`page`], for a page that lets the document scroll. +pub(crate) fn scrolling_page(title: &str, body: Markup) -> Markup { + shell(title, Scroll::Document, body) +} + #[expect( clippy::needless_pass_by_value, reason = "maud templates consume Markup" )] -pub(crate) fn page(title: &str, body: Markup) -> Markup { +fn shell(title: &str, scroll: Scroll, body: Markup) -> Markup { html! { (DOCTYPE) html lang="en" { head { meta charset="utf-8"; - meta name="viewport" content="width=device-width, initial-scale=1"; + // `viewport-fit=cover` so the safe-area insets below have + // something to report on a notched screen. + meta name="viewport" + content="width=device-width, initial-scale=1, viewport-fit=cover"; title { (title) " - JP" } + + // Installed to a home screen, this runs without browser chrome + // and keeps its own history, which is what makes it usable as an + // app rather than a bookmark. + meta name="apple-mobile-web-app-capable" content="yes"; + meta name="apple-mobile-web-app-title" content="JP"; + meta name="apple-mobile-web-app-status-bar-style" + content="black-translucent"; + meta name="mobile-web-app-capable" content="yes"; + meta name="theme-color" content="#1a1a1a"; + + link rel="icon" type="image/svg+xml" href="/assets/icon.svg"; + link rel="apple-touch-icon" href="/assets/icon.svg"; + link rel="manifest" href="/manifest.webmanifest"; + link rel="stylesheet" href=(format!("/assets/style.css?v={}", style::css_version())); } - body { + body class=[(scroll == Scroll::Document).then_some("scrolls")] { (body) } } diff --git a/crates/plugins/command/serve-web/src/views/list.rs b/crates/plugins/command/serve-web/src/views/list.rs index af2a6be78..096878e32 100644 --- a/crates/plugins/command/serve-web/src/views/list.rs +++ b/crates/plugins/command/serve-web/src/views/list.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use jp_plugin::message::ConversationSummary; -use maud::{Markup, html}; +use maud::{Markup, PreEscaped, html}; use crate::views::layout; @@ -15,34 +15,190 @@ pub(crate) fn render(conversations: &[ConversationSummary]) -> Markup { let mut sorted: Vec<&ConversationSummary> = conversations.iter().collect(); sorted.sort_by_key(|c| std::cmp::Reverse(c.last_activated_at)); - layout::page("Conversations", html! { - header class="page-header" { + // The document scrolls here, unlike the conversation view: there is no + // composer and no keyboard, so nothing needs the page pinned — and letting it + // scroll normally is what makes the platform's own gestures work, including + // tapping the status bar to return to the top. + layout::scrolling_page("Conversations", html! { + // The count travels with the page so it can ask later whether anything + // has been added since, without re-reading the list to find out. + header class="page-header" data-count=(sorted.len()) { h1 { "Conversations" } + a href="/conversations/new" class="new-conversation-link" { "New" } } - main class="conversation-list" { - @if sorted.is_empty() { + + script { (PreEscaped(LIST_SCRIPT)) } + @if sorted.is_empty() { + main class="conversation-list" { p class="empty" { "No conversations yet." } - } @else { + } + } @else { + // A row of its own above the list, so it stays put while the list + // scrolls under it. + div class="list-search" { + input + id="filter" + type="search" + placeholder="Filter by title…" + autocomplete="off" + aria-label="Filter conversations by title"; + } + main class="conversation-list" { ul { @for entry in &sorted { - li { - a href=(format!("/conversations/{}", entry.id)) { - span class="title" { - (entry.title.as_deref().unwrap_or("Untitled")) + // The row is a horizontal scroller with two snap points: + // the entry, and the action behind its right edge. Swiping + // is then the browser's own scrolling — momentum, rubber + // band and all — rather than touch handlers imitating it. + li data-id=(entry.id) { + div class="row-track" { + a class="row-entry" href=(format!("/conversations/{}", entry.id)) { + span class="title" { + (entry.title.as_deref().unwrap_or("Untitled")) + } + time class="timestamp" + datetime=(entry.last_activated_at.to_rfc3339()) { + (format_relative_time(entry.last_activated_at)) + } } - time class="timestamp" - datetime=(entry.last_activated_at.to_rfc3339()) { - (format_relative_time(entry.last_activated_at)) + + // A plain form, so this works with no script at + // all once the row is scrolled aside. + form + class="row-actions" + method="post" + action=(format!("/conversations/{}/archive", entry.id)) + { + button type="submit" class="archive" { "Archive" } } } } } } + + // Shown by the filter when it hides every entry. + p id="no-matches" class="empty" hidden { "No matching conversations." } } + script { (PreEscaped(FILTER_SCRIPT)) } } }) } +/// Keeps the list current, and the header useful. +/// +/// Refreshed on returning to the app rather than on a pull, which is the +/// gesture this would otherwise want: installed to a home screen there is no +/// browser chrome to host a pull-to-refresh, and the version a page can build +/// has no access to the haptic that makes the real one feel like anything. +/// Coming back to a list that is already current is better than a gesture that +/// asks for it. +/// +/// Only when the count has moved, so a page already showing everything keeps +/// its scroll position and its filter rather than being thrown away to arrive +/// at the same list. +const LIST_SCRIPT: &str = r" +const header = document.querySelector('.page-header'); + +async function reloadIfStale() { + try { + const r = await fetch('/conversations/count'); + if (!r.ok) return; + + const { count } = await r.json(); + if (String(count) !== header.dataset.count) location.reload(); + } catch (e) { + // Offline, or the server is restarting. The next return tries again. + } +} + +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') reloadIfStale(); +}); + +// The header is a way back to the top on platforms that do not do it themselves. +// Ignores the links inside it, which have somewhere else to go. +header.addEventListener('click', (event) => { + if (!event.target.closest('a')) scrollTo(0, 0); +}); + +// Archiving asks first: a swipe is easy to make by accident, and a conversation +// is not something to lose to a stray gesture. +// +// Handled here rather than on the form so the confirmation is one dialog for the +// whole list rather than one per row. +document.addEventListener('submit', async (event) => { + const form = event.target.closest('.row-actions'); + if (!form) return; + + event.preventDefault(); + + const row = form.closest('li'); + const title = row.querySelector('.title').textContent.trim(); + if (!confirm('Archive ' + title + '?')) return; + + try { + const r = await fetch(form.action, { + method: 'POST', + headers: { accept: 'application/json' }, + }); + if (!r.ok) throw new Error(r.status); + + // Removed rather than reloaded: the rest of the list is unchanged, and a + // reload would lose the filter and the scroll position. + row.remove(); + header.dataset.count = String(Number(header.dataset.count) - 1); + } catch (e) { + alert('Could not archive that conversation.'); + } +}); + +// A tap on a row that is swiped open should close it rather than follow the +// link, which is what every list with this gesture does. +document.addEventListener('click', (event) => { + const entry = event.target.closest('.row-entry'); + if (!entry) return; + + const track = entry.closest('.row-track'); + if (track.scrollLeft > 4) { + event.preventDefault(); + track.scrollTo({ left: 0, behavior: 'smooth' }); + } +}); +"; + +/// Hide the entries whose title doesn't contain what was typed. +/// +/// Enhancement, and only ever subtractive: with JavaScript off the field is +/// inert and the full list is still there. +/// +/// Matching reads the rendered title rather than a copy of it, so an untitled +/// conversation matches on the "Untitled" the reader can actually see. +const FILTER_SCRIPT: &str = r" +const field = document.getElementById('filter'); +const entries = [...document.querySelectorAll('.conversation-list li')]; +const noMatches = document.getElementById('no-matches'); + +const apply = () => { + const needle = field.value.trim().toLowerCase(); + let shown = 0; + + for (const entry of entries) { + const title = entry.querySelector('.title').textContent.toLowerCase(); + const match = title.includes(needle); + entry.hidden = !match; + if (match) shown++; + } + + noMatches.hidden = shown > 0; +}; + +field.addEventListener('input', apply); + +// Browsers restore a field's value on a back navigation without firing `input`, +// which would otherwise leave the text sitting above an unfiltered list. +apply(); +"; + /// Format a timestamp as a human-readable relative string. fn format_relative_time(dt: DateTime) -> String { let now = Utc::now(); @@ -70,3 +226,7 @@ fn format_relative_time(dt: DateTime) -> String { dt.format("%Y-%m-%d").to_string() } + +#[cfg(test)] +#[path = "list_tests.rs"] +mod tests; diff --git a/crates/plugins/command/serve-web/src/views/list_tests.rs b/crates/plugins/command/serve-web/src/views/list_tests.rs new file mode 100644 index 000000000..e9637e257 --- /dev/null +++ b/crates/plugins/command/serve-web/src/views/list_tests.rs @@ -0,0 +1,55 @@ +use chrono::{DateTime, Utc}; +use jp_plugin::message::ConversationSummary; + +use super::*; + +fn summary(id: &str, title: Option<&str>) -> ConversationSummary { + ConversationSummary { + id: id.to_owned(), + title: title.map(ToOwned::to_owned), + last_activated_at: "2025-01-01T00:00:00Z" + .parse::>() + .expect("fixed timestamp parses"), + pinned_at: None, + events_count: 0, + } +} + +#[test] +fn renders_filter_field_and_entries() { + let conversations = vec![ + summary("0001", Some("Add a search bar")), + summary("0002", None), + ]; + + let html = render(&conversations).into_string(); + + assert!(html.contains(r#"id="filter""#), "no filter field: {html}"); + assert!( + html.contains(r#"id="no-matches""#), + "no empty state: {html}" + ); + assert!(html.contains("Add a search bar"), "entry missing: {html}"); + assert!(html.contains("Untitled"), "untitled entry missing: {html}"); +} + +/// The field filters the list that is already on the page, so an empty list has +/// nothing to filter and would leave the script reaching for elements that were +/// never rendered. +#[test] +fn omits_filter_field_when_there_are_no_conversations() { + let html = render(&[]).into_string(); + + assert!(html.contains("No conversations yet."), "{html}"); + assert!( + !html.contains(r#"id="filter""#), + "filter field shown: {html}" + ); + // The filter's script specifically. The page carries others regardless of how + // many conversations there are, so "no script at all" would assert something + // this test is not about. + assert!( + !html.contains("getElementById('filter')"), + "filter script emitted: {html}" + ); +} diff --git a/crates/plugins/command/serve-web/src/views/mod.rs b/crates/plugins/command/serve-web/src/views/mod.rs index cb4ddf594..d28197356 100644 --- a/crates/plugins/command/serve-web/src/views/mod.rs +++ b/crates/plugins/command/serve-web/src/views/mod.rs @@ -3,3 +3,4 @@ pub(crate) mod detail; pub(crate) mod layout; pub(crate) mod list; +pub(crate) mod new; diff --git a/crates/plugins/command/serve-web/src/views/new.rs b/crates/plugins/command/serve-web/src/views/new.rs new file mode 100644 index 000000000..7788459ee --- /dev/null +++ b/crates/plugins/command/serve-web/src/views/new.rs @@ -0,0 +1,115 @@ +//! The form for starting a conversation. + +use jp_plugin::message::ConfigEntry; +use maud::{Markup, html}; + +use super::layout; + +/// Render the new-conversation form. +/// +/// `configs` are grouped by namespace in the order the host listed them, which +/// is sorted by segment — so a namespace's entries arrive together and the +/// groups come out alphabetically. +/// +/// `error` is shown above the form when a previous attempt was refused; the +/// fields keep what was typed so nothing has to be entered twice. +pub(crate) fn render( + configs: &[ConfigEntry], + content: &str, + title: &str, + selected: &[String], + error: Option<&str>, +) -> Markup { + layout::page("New conversation", html! { + header class="page-header" { + a href="/conversations" class="back" { "← Conversations" } + h1 { "New conversation" } + } + + main class="conversation-detail" { + @if let Some(error) = error { + p class="composer-error" { (error) } + } + + form class="new-conversation" method="post" action="/conversations/new" { + label { + span class="field-label" { "Title" } + input + type="text" + name="title" + value=(title) + placeholder="Optional; named from the first turn if left blank"; + } + + @for group in group_by_namespace(configs) { + fieldset { + legend { (group.label()) } + @for entry in group.entries { + label class="config-option" { + input + type="checkbox" + name="cfg" + value=(entry.segment) + checked[selected.contains(&entry.segment)]; + span { (entry.name) } + } + } + } + } + + label { + span class="field-label" { "Message" } + textarea + name="content" + rows="5" + placeholder="What do you want to ask?" + required { (content) } + } + + div { + button type="submit" { "Start" } + } + } + } + }) +} + +/// Configurations sharing a namespace, in the order the host listed them. +struct Group<'a> { + namespace: &'a str, + entries: Vec<&'a ConfigEntry>, +} + +impl Group<'_> { + /// The heading for the group, naming the load-path directory it came from. + /// + /// Entries at the load path's root have no namespace to show, so they are + /// labelled generically rather than with an empty heading. + fn label(&self) -> &str { + if self.namespace.is_empty() { + "General" + } else { + self.namespace + } + } +} + +/// Split a sorted list into runs sharing a namespace. +/// +/// Relies on the host's sort by segment: entries in one namespace share a +/// prefix, so they are already adjacent and no grouping map is needed. +fn group_by_namespace(configs: &[ConfigEntry]) -> Vec> { + let mut groups: Vec> = Vec::new(); + + for entry in configs { + match groups.last_mut() { + Some(group) if group.namespace == entry.namespace => group.entries.push(entry), + _ => groups.push(Group { + namespace: &entry.namespace, + entries: vec![entry], + }), + } + } + + groups +} diff --git a/crates/plugins/command/ticket/src/main_tests.rs b/crates/plugins/command/ticket/src/main_tests.rs index ff2ea03b4..f6754e147 100644 --- a/crates/plugins/command/ticket/src/main_tests.rs +++ b/crates/plugins/command/ticket/src/main_tests.rs @@ -1,6 +1,6 @@ use camino_tempfile::Utf8TempDir; use clap::CommandFactory; -use jp_plugin::message::WorkspaceInfo; +use jp_plugin::message::{OutputFormat, WorkspaceInfo}; use super::*; @@ -188,6 +188,7 @@ fn init_at(version: u32, root: &Utf8Path, args: &[&str]) -> HostToPlugin { options: serde_json::Map::new(), args: args.iter().map(|arg| (*arg).to_owned()).collect(), log_level: 0, + output_format: OutputFormat::default(), }) } diff --git a/justfile b/justfile index 0a1a6369b..a6f6987bf 100644 --- a/justfile +++ b/justfile @@ -3429,8 +3429,9 @@ serve-tools CONTEXT TOOL: # recipe, so every `jp query` that uses bookworm tools picks up the latest # local source automatically. [group('tools')] -serve-bookworm: _build-bookworm - @$(cargo metadata --format-version 1 | jq -r .build_directory)/release/bookworm mcp +serve-bookworm: # _build-bookworm + /Users/jean/.cargo/bin/bookworm mcp + # @$(cargo metadata --format-version 1 | jq -r .build_directory)/release/bookworm mcp [private] @_build-bookworm: From b96dae5bb21e4fc7c294d69e0b41f2d712663ac5 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Wed, 16 Sep 2026 21:54:45 +0200 Subject: [PATCH 02/15] review feedback Signed-off-by: Jean Mertz --- crates/plugins/command/serve-web/README.md | 65 +++-- .../plugins/command/serve-web/src/client.rs | 50 ++-- .../plugins/command/serve-web/src/render.rs | 24 ++ .../plugins/command/serve-web/src/routes.rs | 253 +++++++++++++++--- .../command/serve-web/src/routes_tests.rs | 199 ++++++++++++++ .../plugins/command/serve-web/src/style.css | 18 ++ .../command/serve-web/src/views/detail.rs | 132 ++++++++- .../command/serve-web/src/views/list.rs | 74 ++++- .../command/serve-web/src/views/list_tests.rs | 49 ++++ .../command/serve-web/src/views/new.rs | 36 ++- 10 files changed, 790 insertions(+), 110 deletions(-) create mode 100644 crates/plugins/command/serve-web/src/routes_tests.rs diff --git a/crates/plugins/command/serve-web/README.md b/crates/plugins/command/serve-web/README.md index 913525a81..471ffec23 100644 --- a/crates/plugins/command/serve-web/README.md +++ b/crates/plugins/command/serve-web/README.md @@ -34,21 +34,23 @@ with the first. ## Protocol -Needs protocol 7 (`REQUIRED_PROTOCOL`). +Needs protocol 8 (`REQUIRED_PROTOCOL`). The host refuses an older pairing at the handshake rather than failing later, so a stale `jp` alongside a fresh plugin is an error message and not a mystery. -| Message | Direction | Used for | -| -------------------- | --------- | ------------------------------------------------ | -| `list_conversations` | → host | The conversation index | -| `read_events` | → host | One conversation's transcript and title | -| `list_configs` | → host | The configurations a new conversation can name | -| `query` | → host | Start a turn, or start a conversation | -| `created` | ← host | The id of a conversation just created | -| `query_complete` | ← host | That turn finished | -| `interrupt` | → host | Stop the turn on one named conversation | -| `read_draft` | → host | The message being composed, as the CLI stores it | -| `write_draft` | → host | Save it back, conditional on a revision | +| Message | Direction | Used for | +| ---------------------- | --------- | ------------------------------------------------ | +| `list_conversations` | → host | The conversation index | +| `read_events` | → host | One conversation's transcript, title and lock | +| `list_configs` | → host | The configurations a new conversation can name | +| `query` | → host | Start a turn, or start a conversation | +| `created` | ← host | The id of a conversation just created | +| `query_complete` | ← host | That turn finished | +| `interrupt` | → host | Stop the turn on one named conversation | +| `read_draft` | → host | The message being composed, as the CLI stores it | +| `write_draft` | → host | Save it back, conditional on a revision | +| `archive_conversation` | → host | Move one conversation to the archive | +| `set_title` | → host | Rename one conversation | Starting a conversation is answered twice: `created` as soon as there is somewhere to send the reader, and `query_complete` when the first turn ends. @@ -59,9 +61,19 @@ quickly would otherwise arrive before anything was listening for it. There is no push channel yet, so the page polls `/conversations/{id}/messages` every second while a turn is running and every three when it isn't. -The endpoint returns an event count and the rendered transcript; the page swaps -its contents only when the count moves, so reading isn't interrupted on every -tick. +The page says how much of the transcript it holds and the endpoint answers with +the rest, so a tick that brings nothing new costs one small response and no +re-render. + +While a turn is running, the newest entry is re-sent on every tick if it is one +that can still change. +A tool call is rendered when it is requested and gains its result later, and a +run of assistant text is rendered as one block that the next flush adds to — +neither of which moves the count, so counting alone would leave the page holding +the first version of either. +An entry that is finished the moment it appears, such as the request itself, is +not re-sent; waiting for the first token is the longest stretch of a turn, and +nothing changes on the page during it. The host re-reads the conversation from disk on each request, which means a turn you started in a terminal shows up in the browser too, without a restart. @@ -89,7 +101,6 @@ server-rendered. `/status` exists for whoever supervises the process: restarting to pick up a new build aborts a turn in flight, so a supervisor polls it and waits for `busy` to go false. -`just serve-web-watch` does exactly that. ## Security @@ -100,14 +111,18 @@ runs whatever tools the conversation allows. Binding to a non-loopback address hands that to the network. The plugin warns on startup when you do. -## Development +Writing requests are refused when they come from a page on another origin, which +is checked from `Sec-Fetch-Site` and `Origin`. +Loopback is no defence on its own here: a form post is not subject to a +preflight, so any site a browser visits can submit one to `127.0.0.1` and start +a turn, and the same-origin policy only stops it reading the answer. +A request that carries neither header — `curl`, a script, another tool — is +allowed, since no browser can be made to omit both. -```sh -just serve-web-watch --bind 0.0.0.0 --port 3001 -``` +## Development -Rebuilds on any change under `crates/` and restarts once no turn is running. -A plain file watcher can't be used here: a turn started from the browser runs -inside the host process the plugin is attached to, so restarting on save aborts -whatever the assistant was in the middle of — including the assistant editing -these files. +A file watcher that restarts on save can't be used here: a turn started from the +browser runs inside the host process the plugin is attached to, so restarting on +save aborts whatever the assistant was in the middle of — including the +assistant editing these files. +Poll `/status` and restart only once `busy` is false. diff --git a/crates/plugins/command/serve-web/src/client.rs b/crates/plugins/command/serve-web/src/client.rs index 862cacfd3..6ca1e3d91 100644 --- a/crates/plugins/command/serve-web/src/client.rs +++ b/crates/plugins/command/serve-web/src/client.rs @@ -33,15 +33,6 @@ pub type SharedWriter = Arc>>; /// forever, which would otherwise stall graceful shutdown. const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); -/// How long a delegated turn is given before the request is abandoned. -/// -/// A turn runs the whole agent loop: the model thinks, tools run, the model -/// thinks again. -/// Minutes are normal, so this is generous — it exists to stop a lost response -/// from pinning a browser connection open forever, not to bound how long the -/// assistant may take. -const QUERY_TIMEOUT: Duration = Duration::from_mins(15); - /// A protocol client that talks to the JP host over stdin/stdout. /// /// Cloneable via `Arc` internally — pass it into axum state directly. @@ -62,14 +53,20 @@ pub struct TurnOutcome { impl TurnOutcome { /// Wait for the turn to finish. /// - /// Takes as long as the turn does, which can be minutes. + /// Takes as long as the turn does, which is however long the assistant + /// takes: an agent loop that calls tools between thoughts can run for the + /// better part of an hour. + /// Waits without a deadline for that reason. + /// The wait is already bounded by the connection — the reader drops every + /// waiter when the host's stdout closes — so a deadline here could only + /// report a failure for a turn that is still running, and the host would go + /// on holding the conversation after this said it had stopped. pub async fn finished(self) -> Result<(), ClientError> { - match tokio::time::timeout(QUERY_TIMEOUT, self.rx).await { - Ok(Ok(HostToPlugin::QueryComplete(_))) => Ok(()), - Ok(Ok(HostToPlugin::Error(e))) => Err(ClientError::Host(e.message)), - Ok(Ok(other)) => Err(ClientError::Unexpected(format!("{other:?}"))), - Ok(Err(_)) => Err(ClientError::ChannelClosed), - Err(_) => Err(ClientError::Timeout), + match self.rx.await { + Ok(HostToPlugin::QueryComplete(_)) => Ok(()), + Ok(HostToPlugin::Error(e)) => Err(ClientError::Host(e.message)), + Ok(other) => Err(ClientError::Unexpected(format!("{other:?}"))), + Err(_) => Err(ClientError::ChannelClosed), } } } @@ -151,6 +148,9 @@ impl PluginClient { /// them back with [`Self::read_events`]. /// The host owns the agent loop, so this resolves the model, calls the /// provider, and runs tools without the plugin seeing any of it. + /// + /// Waits without a deadline, for the reason [`TurnOutcome::finished`] + /// gives. pub async fn query( &self, conversation: &str, @@ -167,7 +167,7 @@ impl PluginClient { content: content.to_owned(), }); - match self.request_within(&id, &msg, QUERY_TIMEOUT).await? { + match self.request_within(&id, &msg, None).await? { HostToPlugin::QueryComplete(_) => Ok(()), HostToPlugin::Error(e) => Err(ClientError::Host(e.message)), other => Err(ClientError::Unexpected(format!("{other:?}"))), @@ -342,15 +342,15 @@ impl PluginClient { /// leaves nothing to remove, so the cleanup here targets only the /// transport-error paths. async fn request(&self, id: &str, msg: &PluginToHost) -> Result { - self.request_within(id, msg, REQUEST_TIMEOUT).await + self.request_within(id, msg, Some(REQUEST_TIMEOUT)).await } - /// [`Self::request`], with a deadline of the caller's choosing. + /// [`Self::request`], with a deadline of the caller's choosing, or none. async fn request_within( &self, id: &str, msg: &PluginToHost, - timeout: Duration, + timeout: Option, ) -> Result { let rx = self.register(id); @@ -430,10 +430,18 @@ pub enum ClientError { /// Await a pending response, failing with [`ClientError`] on a closed channel /// or timeout instead of blocking forever. +/// +/// Without a timeout the wait ends only with the answer or with the host: the +/// reader drops every waiter when stdout closes, which resolves this as +/// [`ClientError::ChannelClosed`]. async fn await_response( rx: oneshot::Receiver, - timeout: Duration, + timeout: Option, ) -> Result { + let Some(timeout) = timeout else { + return rx.await.map_err(|_| ClientError::ChannelClosed); + }; + tokio::time::timeout(timeout, rx) .await .map_err(|_| ClientError::Timeout)? diff --git a/crates/plugins/command/serve-web/src/render.rs b/crates/plugins/command/serve-web/src/render.rs index 9745f6053..efb760cbc 100644 --- a/crates/plugins/command/serve-web/src/render.rs +++ b/crates/plugins/command/serve-web/src/render.rs @@ -45,6 +45,30 @@ pub(crate) fn settled_upto(events: &[RenderedEvent]) -> usize { .unwrap_or(events.len()) } +/// Whether the newest entry can still change where it stands. +/// +/// A tool call gains its result after it has been rendered, and a run of +/// assistant text or reasoning renders as one block that the next flush adds +/// to. +/// Neither moves the count, so a caller that has already counted the entry has +/// no way to learn that it changed, and has to be sent it again. +/// +/// A request, a structured response and a turn separator are finished the +/// moment they appear. +/// Waiting for the first token of a reply is the longest stretch of a turn, and +/// the transcript ends in the request throughout it, so the distinction is +/// worth making. +pub(crate) fn tail_can_change(events: &[RenderedEvent]) -> bool { + matches!( + events.last(), + Some( + RenderedEvent::ToolCall { .. } + | RenderedEvent::AssistantMessage { .. } + | RenderedEvent::Reasoning { .. } + ) + ) +} + /// Whether the conversation is waiting on the assistant. /// /// True when the last thing in the transcript is the user's message, or a tool diff --git a/crates/plugins/command/serve-web/src/routes.rs b/crates/plugins/command/serve-web/src/routes.rs index 08bd1a9b1..165caf097 100644 --- a/crates/plugins/command/serve-web/src/routes.rs +++ b/crates/plugins/command/serve-web/src/routes.rs @@ -8,15 +8,16 @@ use std::{ use axum::{ Form, Json, Router, - extract::{Path, Query, State}, - http::{StatusCode, header}, + extract::{Path, Query, Request, State}, + http::{HeaderMap, Method, StatusCode, header}, + middleware::{self, Next}, response::{IntoResponse, Redirect, Response}, }; use jp_plugin::message::LockState; use maud::Markup; use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, warn}; use crate::{ client::{ClientError, PluginClient}, @@ -57,6 +58,7 @@ enum TurnStatus { /// point. Running { pending: Option, + /// Which client asked for it, when one said. /// /// Kept here rather than on the lock: this distinction never leaves the @@ -64,6 +66,14 @@ enum TurnStatus { /// Another peer only needs to know the turn is this server's, which the /// lock already says. client: Option, + + /// How long the transcript was when the message was submitted, when the + /// submitter said. + /// + /// What tells `pending` it can go: the request is recorded as soon as + /// the transcript is longer than this, whether or not the assistant has + /// already begun answering it. + sent_at: Option, }, /// It failed, and nobody has been told yet. @@ -138,8 +148,8 @@ pub(crate) async fn serve( axum::routing::get(read_draft).post(write_draft), ) .route( - "/conversations/count", - axum::routing::get(conversation_count), + "/conversations/digest", + axum::routing::get(conversation_digest), ) .route( "/conversations/{id}/archive", @@ -151,6 +161,7 @@ pub(crate) async fn serve( .route("/assets/style.css", axum::routing::get(serve_css)) .route("/assets/icon.svg", axum::routing::get(serve_icon)) .route("/manifest.webmanifest", axum::routing::get(serve_manifest)) + .layer(middleware::from_fn(same_origin_only)) .with_state(state); let local_addr = listener.local_addr().ok(); @@ -167,6 +178,74 @@ pub(crate) async fn serve( .map_err(|e| format!("server error: {e}")) } +/// Refuse a write that a page on some other origin asked for. +/// +/// A form post needs no preflight, so any page a browser visits can submit one +/// here and start a turn, which spends tokens and runs whatever tools the +/// conversation allows. +/// The same-origin policy stops that page reading the answer, not sending the +/// request, and binding to loopback does not help: the request comes from the +/// user's own browser, which is already inside. +/// +/// Reads are left alone. +/// They are as exposed as the port is, which the startup warning and the README +/// already say, and a `GET` is where a supervisor and a `curl` live. +async fn same_origin_only(request: Request, next: Next) -> Response { + let writing = !matches!(*request.method(), Method::GET | Method::HEAD); + + if writing && !same_origin(request.headers()) { + warn!( + method = %request.method(), + path = %request.uri().path(), + "Refused a write from another origin", + ); + + return ( + StatusCode::FORBIDDEN, + "This request came from another site.\n", + ) + .into_response(); + } + + next.run(request).await +} + +/// Whether a request came from one of this server's own pages. +/// +/// `Sec-Fetch-Site` is the browser's own answer and is taken where it is given. +/// `Origin` against `Host` is the fallback for a browser too old to send it: a +/// cross-site form post carries the submitting page's origin, which is not this +/// server's. +/// +/// A request carrying neither is allowed. +/// `curl`, a script, or another tool sends no such header, and no browser can +/// be made to omit both — so refusing here would lock out every non-browser +/// caller to stop nothing. +fn same_origin(headers: &HeaderMap) -> bool { + if let Some(site) = headers + .get("sec-fetch-site") + .and_then(|value| value.to_str().ok()) + { + return site == "same-origin" || site == "none"; + } + + let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else { + return true; + }; + + // Whatever the browser was told to connect to, which is the authority half + // of the origin its own pages carry. Its absence with an `Origin` present + // leaves nothing to compare against, and guessing is not worth it. + headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .is_some_and(|host| { + origin + .split_once("://") + .is_some_and(|(_, authority)| authority == host) + }) +} + async fn index() -> Redirect { debug!("GET / -> redirect to /conversations"); Redirect::permanent("/conversations") @@ -228,6 +307,10 @@ struct TurnForm { content: String, cfg: Vec, client: Option, + + /// How much of the transcript the submitting page held, which is what the + /// provisional copy of this message is retired against. + count: Option, } impl TurnForm { @@ -241,6 +324,7 @@ impl TurnForm { // Without this the turn is recorded unattributed, and the page // that started it is told the turn is somebody else's. "client" => form.client = Some(value.into_owned()), + "count" => form.count = value.parse().ok(), _ => {} } } @@ -330,6 +414,7 @@ async fn start_turn( .insert(id.clone(), TurnStatus::Running { pending: Some(content.clone()), client: form.client.clone(), + sent_at: form.count, }); let client = state.client.clone(); @@ -476,6 +561,7 @@ async fn start_conversation( TurnStatus::Running { pending: None, client: form.client.clone(), + sent_at: None, }, ); @@ -586,24 +672,34 @@ fn wants_json(headers: &axum::http::HeaderMap) -> bool { .is_some_and(|accept| accept.contains("application/json")) } -/// How many conversations there are. +/// What the conversation list currently amounts to. #[derive(Debug, Serialize)] -struct ConversationCount { - count: usize, +struct ConversationDigest { + digest: String, } -/// How many conversations there are. +/// A fingerprint of the conversation list. +/// +/// Enough for a page to tell whether the list it is showing is still the list, +/// without re-rendering one it already has. /// -/// Enough for a page to tell whether its copy of the list is still the whole -/// list, without asking for the list itself. -async fn conversation_count( +/// A fingerprint rather than a count, because most of what a page would want to +/// redraw for leaves the count alone: a rename, a conversation being used, an +/// archive and a creation that happen to balance. +/// It costs nothing extra — answering at all means reading every +/// conversation's metadata, which is where a title comes from anyway. +async fn conversation_digest( State(state): State, -) -> Result, AppError> { +) -> Result, AppError> { state .client .list_conversations() .await - .map(|list| Json(ConversationCount { count: list.len() })) + .map(|list| { + Json(ConversationDigest { + digest: views::list::digest(&list), + }) + }) .map_err(|e| AppError::Internal(e.to_string())) } @@ -787,12 +883,14 @@ async fn messages( ) -> Result, AppError> { let resp = read_conversation(&state, &id).await?; let rendered = render::render_events(&resp.data); - // A pending message is only worth showing until the transcript carries it. - let landed = render::awaiting_response(&rendered); - let view = take_turn_status(&state, &id, landed); // Walking backwards: a window of what came before what the caller holds. + // + // Read without consuming. A history fetch happens while the same page is + // polling, and this answer carries no failure and no provisional message, so + // taking either here would deliver it to nobody. if let Some(before) = query.before { + let view = peek_turn_status(&state, &id); let before = before.min(rendered.len()); let from = if query.all.is_some_and(|all| all != 0) { 0 @@ -813,17 +911,22 @@ async fn messages( })); } - // What the caller already has, when that is a prefix of what is here. A count - // beyond the end means the transcript was rewritten under it — compacted, or - // edited on disk — and the only safe answer is the tail, from scratch. - let from = query - .count - .filter(|&count| count <= rendered.len()) - .unwrap_or_else(|| rendered.len().saturating_sub(WINDOW)) - // Never past an event that can still change. A tool call is rendered when - // it is requested and gains its result later, so sending only what comes - // after it would leave the caller holding the question forever. - .min(render::settled_upto(&rendered)); + // The lock is the authority on whether a turn is running. Inferring it from a + // transcript ending in a request cannot tell a live turn from one that + // failed, and got that wrong in the direction that blocks the composer for a + // conversation nothing is working on. + // + // `view.running` still counts, for the moment between this server starting a + // turn and the host taking the lock. + let view = take_turn_status(&state, &id, &rendered); + let running = view.running || resp.lock.is_held(); + + let from = answer_from( + query.count, + rendered.len(), + render::settled_upto(&rendered), + running && render::tail_can_change(&rendered), + ); let stale = from != rendered.len(); @@ -835,20 +938,42 @@ async fn messages( .pending .as_deref() .map(|content| views::detail::pending(content).into_string()), - // The lock is the authority on whether a turn is running. Inferring it - // from a transcript ending in a request cannot tell a live turn from one - // that failed, and got that wrong in the direction that blocks the - // composer for a conversation nothing is working on. - // - // `view.running` still counts, for the moment between this server - // starting a turn and the host taking the lock. stop: stop_mode(&view, resp.lock, query.client.as_deref()), boot: state.boot.clone(), - running: view.running || resp.lock.is_held(), + running, error: view.error, })) } +/// Where the answer to a poll has to start. +/// +/// `held` is how much the caller says it has rendered and `settled` where the +/// first entry that can still change begins, so ordinarily the answer starts at +/// whichever is lower and carries only what the caller is missing. +/// A `held` beyond the end means the transcript was rewritten under the caller +/// — compacted, or edited on disk — and the only safe answer is the tail, +/// from scratch. +/// +/// `tail_unsettled` takes one more off the top, for a newest entry that can +/// change without the count moving — a tool call that gains its result, a +/// block of assistant text that the next flush adds to. +/// Counting alone leaves the caller holding the first version of either, and no +/// later poll corrects it, because by then the count has moved past the entry +/// that changed. +fn answer_from(held: Option, total: usize, settled: usize, tail_unsettled: bool) -> usize { + let held = held + .filter(|&held| held <= total) + .unwrap_or_else(|| total.saturating_sub(WINDOW)); + + let final_upto = if tail_unsettled { + settled.min(total.saturating_sub(1)) + } else { + settled + }; + + held.min(final_upto) +} + /// What stopping the running turn would take, for the client that is asking. /// /// Three cases, because "this server can reach it" and "you started it" are not @@ -874,13 +999,32 @@ fn stop_mode(view: &TurnView, lock: LockState, asker: Option<&str>) -> StopMode /// /// A failure is reported once: leaving it in place would have every later poll /// re-raise an error the reader has already seen. -/// The pending message is dropped as soon as `landed` says the transcript has -/// the request, so the page stops showing its provisional copy. -fn take_turn_status(state: &AppState, id: &str, landed: bool) -> TurnView { +/// The provisional copy of a submitted message is dropped once `rendered` +/// carries the request. +fn take_turn_status(state: &AppState, id: &str, rendered: &[render::RenderedEvent]) -> TurnView { let mut turns = state.turns.lock().expect("turns lock poisoned"); match turns.get_mut(id) { - Some(TurnStatus::Running { pending, client }) => { + Some(TurnStatus::Running { + pending, + client, + sent_at, + }) => { + // Counted rather than read off the end of the transcript. A fast + // first flush can persist the request and an answer to it between + // two polls, and a transcript ending in assistant output says + // nothing about whether the request below it is the one submitted + // here — which left the provisional copy up beside the real one for + // the rest of the turn. + // + // A submission that named no count — a plain form post, or a page + // from an older build — has only the end of the transcript to go + // on. + let landed = sent_at.map_or_else( + || render::awaiting_response(rendered), + |sent| rendered.len() > sent, + ); + if landed { pending.take(); } @@ -914,6 +1058,31 @@ fn take_turn_status(state: &AppState, id: &str, landed: bool) -> TurnView { } } +/// A conversation's turn state, leaving everything where it is. +/// +/// For a read that is not the live view. +/// A failure is delivered once, so it has to be taken by the poll that drives +/// the indicator and not by whatever else the page happens to be asking for at +/// the time. +fn peek_turn_status(state: &AppState, id: &str) -> TurnView { + let turns = state.turns.lock().expect("turns lock poisoned"); + + match turns.get(id) { + Some(TurnStatus::Running { client, .. }) => TurnView { + running: true, + error: None, + pending: None, + client: client.clone(), + }, + Some(TurnStatus::Failed(_)) | None => TurnView { + running: false, + error: None, + pending: None, + client: None, + }, + } +} + /// A page whose content changes while it is open, marked as never reusable. /// /// Without this a browser is free to show the copy it already has — on a @@ -1058,3 +1227,7 @@ impl IntoResponse for AppError { } } } + +#[cfg(test)] +#[path = "routes_tests.rs"] +mod tests; diff --git a/crates/plugins/command/serve-web/src/routes_tests.rs b/crates/plugins/command/serve-web/src/routes_tests.rs new file mode 100644 index 000000000..cca119e23 --- /dev/null +++ b/crates/plugins/command/serve-web/src/routes_tests.rs @@ -0,0 +1,199 @@ +use axum::http::{HeaderMap, HeaderName}; +use serde_json::json; + +use super::*; + +/// A request carrying exactly these headers and nothing else. +fn headers(pairs: &[(&str, &str)]) -> HeaderMap { + let mut headers = HeaderMap::new(); + + for (name, value) in pairs { + headers.insert( + HeaderName::from_bytes(name.as_bytes()).expect("a valid header name"), + value.parse().expect("a valid header value"), + ); + } + + headers +} + +#[test] +fn a_page_posting_to_its_own_server_is_allowed() { + assert!(same_origin(&headers(&[ + ("sec-fetch-site", "same-origin"), + ("origin", "http://127.0.0.1:3000"), + ("host", "127.0.0.1:3000"), + ]))); +} + +/// The case the check exists for. +/// +/// A form post needs no preflight, so a page on any site can submit one to this +/// server. +/// Refusing it is the only thing that stops a site the reader happens to visit +/// from starting a turn on their machine. +#[test] +fn a_form_post_from_another_site_is_refused() { + assert!(!same_origin(&headers(&[ + ("sec-fetch-site", "cross-site"), + ("origin", "https://example.test"), + ("host", "127.0.0.1:3000"), + ]))); +} + +/// Typing the address, or following a bookmark. +/// There is no page behind it to be acting on anyone's behalf. +#[test] +fn a_navigation_with_no_origin_behind_it_is_allowed() { + assert!(same_origin(&headers(&[("sec-fetch-site", "none")]))); +} + +/// A browser too old to send `Sec-Fetch-Site` still sends `Origin`, and the +/// authority it names is either this server or somebody else. +#[test] +fn an_origin_that_is_not_this_server_is_refused() { + assert!(!same_origin(&headers(&[ + ("origin", "https://example.test"), + ("host", "127.0.0.1:3000"), + ]))); +} + +#[test] +fn an_origin_matching_the_host_is_allowed() { + assert!(same_origin(&headers(&[ + ("origin", "http://localhost:3000"), + ("host", "localhost:3000"), + ]))); +} + +/// `curl`, a script, another tool. +/// No browser can be made to omit both headers, so refusing here would lock out +/// every non-browser caller and stop nothing. +#[test] +fn a_caller_that_sends_neither_header_is_allowed() { + assert!(same_origin(&headers(&[("host", "127.0.0.1:3000")]))); +} + +#[test] +fn a_caller_holding_the_whole_transcript_is_sent_nothing() { + // `from == total` is what the handler reads as "nothing to say". + assert_eq!(answer_from(Some(4), 4, 4, false), 4); +} + +/// The regression this guards: an entry that changes in place. +/// +/// A tool call is rendered when it is requested and gains its result later, and +/// consecutive assistant text renders as one block that grows. +/// Either way the count stays where it was, so a caller that trusts the count +/// holds the first version forever — no later poll corrects it, because by +/// then the count has moved past the entry that changed. +#[test] +fn an_unsettled_tail_is_resent_to_a_caller_that_already_has_it() { + assert_eq!(answer_from(Some(4), 4, 4, true), 3); +} + +#[test] +fn a_settled_tail_leaves_an_up_to_date_caller_alone() { + assert_eq!(answer_from(Some(4), 4, 4, false), 4); +} + +/// Waiting for the first token is the longest stretch of a turn, and the +/// transcript ends in the request for all of it. +/// Re-sending a request that cannot change would rebuild that entry once a +/// second for nothing. +#[test] +fn a_request_at_the_end_of_the_transcript_is_settled() { + let waiting = [json!({"type": "chat_request", "content": "go on then"})]; + + assert!(!render::tail_can_change(&render::render_events(&waiting))); +} + +#[test] +fn a_tool_call_and_a_block_of_text_are_both_unsettled() { + let called = [ + json!({"type": "chat_request", "content": "run it"}), + json!({"type": "tool_call_request", "id": "t1", "name": "ls", "arguments": {}}), + ]; + + assert!(render::tail_can_change(&render::render_events(&called))); + + let answering = [ + json!({"type": "chat_request", "content": "run it"}), + json!({"type": "chat_response", "message": "here is"}), + ]; + + assert!(render::tail_can_change(&render::render_events(&answering))); +} + +/// An unsettled entry wins over the newest one: a tool call still waiting on +/// its result is where the answer has to start, however much came after it. +#[test] +fn an_unsettled_entry_is_resent_from_where_it_starts() { + assert_eq!(answer_from(Some(9), 9, 4, true), 4); +} + +/// A count past the end means the transcript was rewritten underneath the +/// caller — compacted, or edited on disk — so the only safe answer is the +/// tail. +#[test] +fn a_count_beyond_the_end_falls_back_to_the_tail() { + assert_eq!(answer_from(Some(500), 300, 300, false), 100); +} + +#[test] +fn a_caller_that_says_nothing_is_sent_the_tail() { + assert_eq!(answer_from(None, 300, 300, false), 100); +} + +#[test] +fn an_empty_transcript_has_nothing_to_resend() { + assert_eq!(answer_from(Some(0), 0, 0, true), 0); +} + +/// The same thing end to end, against what the renderer actually produces. +/// +/// The count is identical before and after the result arrives, because +/// `tool_call_response` renders into the call it answers rather than beside it. +/// A caller that counted the unanswered call is therefore up to date by the +/// only measure it has, and still holding a tool call with no result. +#[test] +fn a_tool_result_reaches_a_caller_that_already_counted_the_call() { + let asked = [ + json!({"type": "chat_request", "content": "run it"}), + json!({"type": "tool_call_request", "id": "t1", "name": "ls", "arguments": {}}), + ]; + + let held = render::render_events(&asked).len(); + assert_eq!(held, 2); + + let answered = [ + json!({"type": "chat_request", "content": "run it"}), + json!({"type": "tool_call_request", "id": "t1", "name": "ls", "arguments": {}}), + json!({"type": "tool_call_response", "id": "t1", "content": "a.txt"}), + ]; + + let rendered = render::render_events(&answered); + assert_eq!( + rendered.len(), + held, + "the result renders into the call, so the count cannot report it" + ); + + let from = answer_from( + Some(held), + rendered.len(), + render::settled_upto(&rendered), + render::tail_can_change(&rendered), + ); + + assert_eq!( + from, 1, + "the answer starts at the tool call, so its result reaches the caller" + ); + + let html = views::detail::messages(&rendered[from..]).into_string(); + assert!( + html.contains("a.txt"), + "what is sent carries the result: {html}" + ); +} diff --git a/crates/plugins/command/serve-web/src/style.css b/crates/plugins/command/serve-web/src/style.css index 346ee9545..8906bba48 100644 --- a/crates/plugins/command/serve-web/src/style.css +++ b/crates/plugins/command/serve-web/src/style.css @@ -1116,6 +1116,24 @@ html.ready .loading-veil { overflow-wrap: anywhere; } +/* The two ways out of a refused draft save, beside the note that explains it. + Quiet: this is a question, not an alarm. */ +.draft-resolve { + margin-left: 6px; + padding: 2px 8px; + font: inherit; + font-size: 0.75rem; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; +} + +.draft-resolve:hover { + background: var(--bg-alt); +} + /* Turn separator */ .turn-separator { border: none; diff --git a/crates/plugins/command/serve-web/src/views/detail.rs b/crates/plugins/command/serve-web/src/views/detail.rs index 2dda58181..b3bb72bfa 100644 --- a/crates/plugins/command/serve-web/src/views/detail.rs +++ b/crates/plugins/command/serve-web/src/views/detail.rs @@ -565,6 +565,20 @@ let boot = null; // the message belongs to the conversation now, not to the draft. let submitted = false; +// Whether the field is holding a message that has been sent but not yet recorded. +// +// The text stays where it is until the transcript carries it, because a turn can +// still be refused after the request was accepted — so for that stretch the field +// holds something that is neither a draft nor gone. +// +// Both halves are needed. `submitted` covers the post itself and is cleared as +// soon as it returns, which is seconds before the message appears; +// `clearWhenLanded` covers the wait for it to appear. +// +// `clearWhenLanded` is declared below and read only when this is called, which is +// never during the script's own evaluation. +const holdingSent = () => submitted || clearWhenLanded !== null; + // Whether the send button is currently offering to pull the message back. let cancelling = false; @@ -870,10 +884,17 @@ const configGroups = document.getElementById('config-groups'); let chosenConfigs = new Set(); let configsLoaded = false; -document.getElementById('open-config').addEventListener('click', () => { +document.getElementById('open-config').addEventListener('click', async () => { nav.open = false; configModal.showModal(); - loadConfigs(); + await loadConfigs(); + + // The boxes outlive the choice: the list is built once, and what is chosen is + // spent when a message is sent. Re-reading it on every open keeps the ticks + // saying what the next message will actually run under. + for (const box of configGroups.querySelectorAll('input[type=checkbox]')) { + box.checked = chosenConfigs.has(box.value); + } }); async function loadConfigs() { @@ -1393,6 +1414,19 @@ async function loadDraft() { // textarea during submit makes the form post an empty message, because the // browser serialises it after the handler runs. async function saveDraft(content) { + // A message that has been sent but not yet recorded is not a draft, however + // much it looks like one sitting there in the field. Storing it would write + // back the draft the host deleted when it turned the message into a request, + // and the next visit to this conversation would offer to send it again. + // + // Here rather than at each caller: blur, `pagehide`, the expanded editor + // closing and a quote being inserted all save what is in the field, and the + // window this guards against is open for as long as the host takes to record + // the request. + // `saveDraft('')` is how the draft is cleared once the message does land, and + // that is named content, so it still goes through. + if (content === undefined && holdingSent()) return; + const text = content ?? input.value; // Nothing here and nothing recorded means nothing to say. Writing anyway would @@ -1413,26 +1447,36 @@ async function saveDraft(content) { }); if (!r.ok) return; const d = await r.json(); - revision = d.revision ?? null; // A draft that has gone *empty* underneath us is not somebody else's edit: // the host clears it when it turns a message into a request, which leaves // this page holding a revision for a file that no longer exists. Adopt the // new revision and put the text back, rather than reporting a conflict that // has no other party. + // + // Not while a sent message is still in the field, though — that is the same + // deletion, and writing the text back there is what would offer it again on + // the next visit. if (d.conflict && !d.content) { + revision = d.revision ?? null; draftNote.hidden = true; - if (input.value && !retried) { + if (input.value && !retried && !holdingSent()) { retried = true; queued = input.value; } } else if (d.conflict) { - draftNote.textContent = - 'This draft was changed elsewhere. Yours is kept here; the other version ' - + 'is on disk.'; - draftNote.hidden = false; + // The revision deliberately stays where it was, which keeps every later + // save refused too. + // + // Adopting the one that came back would make the next keystroke's save + // match, succeed, and quietly overwrite the text this refusal exists to + // protect — while the note on screen still said it was safe on disk. + // Which version wins is a thing to be asked for, not something a debounce + // decides. + showConflict(d.content, d.revision ?? null); } else { + revision = d.revision ?? null; draftNote.hidden = true; retried = false; } @@ -1448,16 +1492,61 @@ async function saveDraft(content) { } } +// Say the save was refused, and offer the two ways out of it. +// +// Both versions survive until one is chosen: theirs is on disk, ours is in the +// field, and nothing overwrites either in the meantime. +// `theirRevision` is what either choice has to write against, since it names +// what is actually there now. +function showConflict(theirs, theirRevision) { + draftNote.textContent = 'This draft was changed elsewhere. Nothing was saved. '; + draftNote.hidden = false; + + const take = document.createElement('button'); + take.type = 'button'; + take.className = 'draft-resolve'; + take.textContent = 'Take theirs'; + take.addEventListener('click', () => { + // No write: the field now holds exactly what is on disk, so there is + // nothing to store, only a revision to catch up with. + input.value = theirs; + fitInput(); + resolveConflict(theirRevision); + }); + + const keep = document.createElement('button'); + keep.type = 'button'; + keep.className = 'draft-resolve'; + keep.textContent = 'Keep mine'; + keep.addEventListener('click', () => { + resolveConflict(theirRevision); + saveDraft(); + }); + + draftNote.append(take, keep); +} + +// Accept what is on disk as the version being edited from. +// +// The one place the revision moves after a refusal, and it takes somebody +// pressing a button. A save that follows this is an answer to the question the +// refusal asked; a save that beat it to the revision would just be an accident. +function resolveConflict(theirRevision) { + revision = theirRevision; + retried = false; + draftNote.textContent = ''; + draftNote.hidden = true; +} + let saveTimer = null; input.addEventListener('input', () => { clearTimeout(saveTimer); saveTimer = setTimeout(() => saveDraft(), 600); }); -// Leaving the field, or the page, is the last chance to keep what is there — -// unless it has just been sent, in which case saving would resurrect it. -input.addEventListener('blur', () => { if (!submitted) saveDraft(); }); -addEventListener('pagehide', () => { if (!submitted) saveDraft(); }); +// Leaving the field, or the page, is the last chance to keep what is there. +input.addEventListener('blur', () => saveDraft()); +addEventListener('pagehide', () => saveDraft()); // Send without navigating. // @@ -1510,7 +1599,14 @@ composer.addEventListener('submit', async (event) => { accept: 'application/json', }, body: (() => { - const params = new URLSearchParams({ content, client: clientId }); + // `count` is what the provisional copy of this message is retired + // against: the request is recorded as soon as the transcript grows past + // it, which the server cannot work out from the transcript alone. + const params = new URLSearchParams({ + content, + client: clientId, + count: String(landedAbove), + }); // One entry per choice: the same shape `--cfg` takes, repeated. for (const segment of chosenConfigs) params.append('cfg', segment); return params; @@ -1546,6 +1642,16 @@ composer.addEventListener('submit', async (event) => { submitted = false; clearWhenLanded = landedAbove; + // The choice is spent, because `--cfg` is a change from this message onward + // rather than a setting for one message. The host merges what was named into + // the conversation's own configuration and records the difference, so naming + // it again next turn layers it over a configuration that already has it — and + // for an appending field, such as a system prompt a configuration adds to, + // that appends a second copy and the conversation keeps it for every turn + // after. + chosenConfigs.clear(); + document.getElementById('open-config').classList.remove('active'); + poll(); }); diff --git a/crates/plugins/command/serve-web/src/views/list.rs b/crates/plugins/command/serve-web/src/views/list.rs index 096878e32..488e8a80f 100644 --- a/crates/plugins/command/serve-web/src/views/list.rs +++ b/crates/plugins/command/serve-web/src/views/list.rs @@ -3,9 +3,45 @@ use chrono::{DateTime, Utc}; use jp_plugin::message::ConversationSummary; use maud::{Markup, PreEscaped, html}; +use sha2::{Digest as _, Sha256}; use crate::views::layout; +/// A fingerprint of the list as it is shown. +/// +/// Covers what a page would redraw for: which conversations there are, what +/// they are called, and when each was last used — which is also what orders +/// them. +/// +/// Sorted before hashing, so this says nothing about the order the host happens +/// to list them in. +/// A conversation moving to the top still changes it, through the timestamp +/// that moved it. +pub(crate) fn digest(conversations: &[ConversationSummary]) -> String { + let mut entries: Vec = conversations + .iter() + .map(|entry| { + format!( + "{}\u{1f}{}\u{1f}{}", + entry.id, + entry.title.as_deref().unwrap_or_default(), + entry.last_activated_at.to_rfc3339(), + ) + }) + .collect(); + + entries.sort(); + + let mut hasher = Sha256::new(); + for entry in entries { + hasher.update(entry.as_bytes()); + hasher.update([0x1e]); + } + + let hash = format!("{:x}", hasher.finalize()); + hash[..16].to_owned() +} + /// Render the conversation list page. /// /// Takes the summaries directly from the protocol response. @@ -20,9 +56,9 @@ pub(crate) fn render(conversations: &[ConversationSummary]) -> Markup { // scroll normally is what makes the platform's own gestures work, including // tapping the status bar to return to the top. layout::scrolling_page("Conversations", html! { - // The count travels with the page so it can ask later whether anything - // has been added since, without re-reading the list to find out. - header class="page-header" data-count=(sorted.len()) { + // The fingerprint travels with the page so it can ask later whether the + // list has moved on, without re-reading the list to find out. + header class="page-header" data-digest=(digest(conversations)) { h1 { "Conversations" } a href="/conversations/new" class="new-conversation-link" { "New" } } @@ -93,19 +129,28 @@ pub(crate) fn render(conversations: &[ConversationSummary]) -> Markup { /// Coming back to a list that is already current is better than a gesture that /// asks for it. /// -/// Only when the count has moved, so a page already showing everything keeps +/// Only when the list has actually moved on, so a page already showing it keeps /// its scroll position and its filter rather than being thrown away to arrive /// at the same list. const LIST_SCRIPT: &str = r" const header = document.querySelector('.page-header'); +// What the server says the list amounts to. +// +// A fingerprint rather than a count: renaming a conversation, or using one, +// leaves the count exactly where it was, and so would an archive and a creation +// between the same two visits. +async function listDigest() { + const r = await fetch('/conversations/digest'); + if (!r.ok) throw new Error(r.status); + + const { digest } = await r.json(); + return digest; +} + async function reloadIfStale() { try { - const r = await fetch('/conversations/count'); - if (!r.ok) return; - - const { count } = await r.json(); - if (String(count) !== header.dataset.count) location.reload(); + if (await listDigest() !== header.dataset.digest) location.reload(); } catch (e) { // Offline, or the server is restarting. The next return tries again. } @@ -146,7 +191,16 @@ document.addEventListener('submit', async (event) => { // Removed rather than reloaded: the rest of the list is unchanged, and a // reload would lose the filter and the scroll position. row.remove(); - header.dataset.count = String(Number(header.dataset.count) - 1); + + // The page now matches a list it has not seen, so its fingerprint has to + // catch up — otherwise the next return here reloads to show the removal it + // is already showing. Asked for rather than computed, since the page holds + // no list to compute one from. + try { + header.dataset.digest = await listDigest(); + } catch (e) { + // Left stale, which costs one reload on the next return and nothing else. + } } catch (e) { alert('Could not archive that conversation.'); } diff --git a/crates/plugins/command/serve-web/src/views/list_tests.rs b/crates/plugins/command/serve-web/src/views/list_tests.rs index e9637e257..9ce705ac7 100644 --- a/crates/plugins/command/serve-web/src/views/list_tests.rs +++ b/crates/plugins/command/serve-web/src/views/list_tests.rs @@ -15,6 +15,55 @@ fn summary(id: &str, title: Option<&str>) -> ConversationSummary { } } +/// The case a count misses: the list is the same length and reads differently. +#[test] +fn renaming_a_conversation_changes_the_digest() { + let before = [ + summary("0001", Some("Add a search bar")), + summary("0002", None), + ]; + let after = [ + summary("0001", Some("Add a filter field")), + summary("0002", None), + ]; + + assert_ne!(digest(&before), digest(&after)); +} + +/// The other case a count misses: one conversation archived and another started +/// between two visits leaves the list exactly as long as it was. +#[test] +fn swapping_one_conversation_for_another_changes_the_digest() { + let before = [summary("0001", Some("Add a search bar"))]; + let after = [summary("0002", Some("Add a search bar"))]; + + assert_ne!(digest(&before), digest(&after)); +} + +/// The protocol promises no order, and the page sorts for itself, so the order +/// the host happens to answer in must not read as a change. +#[test] +fn the_digest_ignores_the_order_the_host_lists_them_in() { + let one = summary("0001", Some("Add a search bar")); + let two = summary("0002", Some("Fix the poller")); + + assert_eq!( + digest(&[one.clone(), two.clone()]), + digest(&[two, one]), + "the same list in a different order is the same list" + ); +} + +#[test] +fn an_unchanged_list_keeps_its_digest() { + let conversations = [ + summary("0001", Some("Add a search bar")), + summary("0002", None), + ]; + + assert_eq!(digest(&conversations), digest(&conversations)); +} + #[test] fn renders_filter_field_and_entries() { let conversations = vec![ diff --git a/crates/plugins/command/serve-web/src/views/new.rs b/crates/plugins/command/serve-web/src/views/new.rs index 7788459ee..941a1723b 100644 --- a/crates/plugins/command/serve-web/src/views/new.rs +++ b/crates/plugins/command/serve-web/src/views/new.rs @@ -1,7 +1,7 @@ //! The form for starting a conversation. use jp_plugin::message::ConfigEntry; -use maud::{Markup, html}; +use maud::{Markup, PreEscaped, html}; use super::layout; @@ -32,6 +32,11 @@ pub(crate) fn render( } form class="new-conversation" method="post" action="/conversations/new" { + // Filled by the script below with this tab's identity, so the + // turn this starts is recorded as belonging to the page that is + // about to be redirected to it. + input type="hidden" id="client" name="client"; + label { span class="field-label" { "Title" } input @@ -70,10 +75,39 @@ pub(crate) fn render( button type="submit" { "Start" } } } + + script { (PreEscaped(CLIENT_SCRIPT)) } } }) } +/// Carry this tab's identity into the form. +/// +/// The conversation this starts is answered with a redirect, and the page that +/// lands there asks the server whether the running turn is its own. +/// A turn recorded without a client belongs to nobody, so that page is told the +/// turn is somebody else's and asks before stopping the turn it just started +/// itself. +/// +/// Enhancement, and correct either way: with no script the field stays empty, +/// the turn is unattributed, and that is the truth — a page that cannot store +/// an identity has none to claim a turn with. +const CLIENT_SCRIPT: &str = r" +// The same per-tab key the conversation page reads, because the page that has to +// recognise this turn is the one this redirects to, in this same tab. +try { + let id = sessionStorage.getItem('jp-client'); + if (!id) { + id = Math.random().toString(36).slice(2) + Date.now().toString(36); + sessionStorage.setItem('jp-client', id); + } + document.getElementById('client').value = id; +} catch (e) { + // Private browsing, or storage denied. The turn stays unattributed, which + // reads as shared and errs toward asking. +} +"; + /// Configurations sharing a namespace, in the order the host listed them. struct Group<'a> { namespace: &'a str, From 8a45dd5237edf5411adf4d410fc3359658f6bad6 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Wed, 16 Sep 2026 21:57:56 +0200 Subject: [PATCH 03/15] fixup! review feedback Signed-off-by: Jean Mertz --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c874e67fa..b40cb125d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3914,9 +3914,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", @@ -3951,9 +3951,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", From c8662facbd0467f0d030a667ccf039cd0e9ff52c Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Mon, 24 Aug 2026 22:02:54 +0200 Subject: [PATCH 04/15] feat(cli, task): Title conversations started by a plugin A conversation created by a delegated plugin query kept whatever title the plugin passed, and nothing else. Started from the web UI, where no title is passed at all, every conversation stayed "Untitled" forever, while the same prompt through `jp query` would have been named from its leading heading or by the title model. The delegated path takes the same two routes as `jp query`. A leading markdown heading is written to the conversation before the turn starts; anything else hands `conversation.title.generate` a task that runs alongside the turn, so the name lands in the list while the answer is still streaming. A title model that fails is logged and skipped rather than taking the turn down with it. `TitleGeneratorTask::generate` is the entry point for a caller that already holds the conversation lock: it asks the model and hands the title back instead of writing it, which the delegated path needs because the lock belongs to the turn. Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd/plugin/dispatch.rs | 108 +++++++++++++++++++-- crates/jp_cli/src/cmd/query.rs | 8 +- crates/jp_task/src/task/title_generator.rs | 10 ++ 3 files changed, 114 insertions(+), 12 deletions(-) diff --git a/crates/jp_cli/src/cmd/plugin/dispatch.rs b/crates/jp_cli/src/cmd/plugin/dispatch.rs index 7ff21bb79..101d81514 100644 --- a/crates/jp_cli/src/cmd/plugin/dispatch.rs +++ b/crates/jp_cli/src/cmd/plugin/dispatch.rs @@ -47,6 +47,7 @@ use jp_plugin::{ }; use jp_printer::{OutputFormat, Printer}; use jp_storage::backend::{FsStorageBackend, Projection}; +use jp_task::task::TitleGeneratorTask; use jp_workspace::{ConversationLock, LockResult, Workspace, session::Session}; use serde_json::Value; use tokio::{sync::mpsc, task::JoinSet}; @@ -55,7 +56,9 @@ use tracing::{debug, error, info, trace, warn}; use super::registry; use crate::{ Ctx, KeyValueOrPath, cmd, - cmd::query::{PendingStreamTrim, TurnInputs, interrupt::reply_edit_mode}, + cmd::query::{ + NewTitle, PendingStreamTrim, TurnInputs, interrupt::reply_edit_mode, resolve_new_title, + }, config_pipeline::{build_partial_over, config_search_roots}, ctx::McpServerScope, editor::{draft_query_text, draft_revision, report_editor_failure}, @@ -754,10 +757,16 @@ async fn run_query( "Running a delegated query.", ); + let chat_request = ChatRequest { + content: request.content, + author: config.user.name.clone(), + ..ChatRequest::default() + }; + // Swapped around collecting only, because that is the part that reads the // context. The turn itself carries the config it was given. let host_config = ctx.swap_config(Arc::clone(&config)); - let prepared = prepare_turn(ctx, config, &lock, request.content).await; + let prepared = prepare_turn(ctx, Arc::clone(&config), &lock, chat_request.clone()).await; ctx.swap_config(host_config); let (inputs, stream) = match prepared { @@ -765,13 +774,21 @@ async fn run_query( Err(error) => return failed(error.to_string()), }; + let title_task = resolve_title(&config, &lock, &stream, &chat_request); + // Hand the turn to its own task. It owns everything it needs and the lock owns // itself, so nothing here is borrowed for the minutes a turn can take, which // is what keeps the message loop answering reads while it runs. let stdin = Arc::clone(stdin); turns.spawn(async move { - let outcome = inputs.run(&lock, stream, turn_interrupt).await; + // Alongside the turn rather than after it: the two are independent + // requests, and whoever is looking at a list of conversations wants a + // name for this one long before the answer arrives. + let (outcome, ()) = tokio::join!( + inputs.run(&lock, stream, turn_interrupt), + write_generated_title(title_task, &lock), + ); // Reported through tracing rather than to the terminal. These are facts // about the host, not content: the turn's output belongs to the @@ -931,7 +948,7 @@ async fn prepare_turn( ctx: &mut Ctx, config: Arc, lock: &ConversationLock, - content: String, + chat_request: ChatRequest, ) -> Result<(TurnInputs, ConversationStream), cmd::Error> { // The client was built from the config this host read at startup. A provider // added to the workspace since then is otherwise unknown to it, and starting @@ -948,12 +965,6 @@ async fn prepare_turn( .configure_active_mcp_servers(forced_tool, McpServerScope::Shared) .await?; - let chat_request = ChatRequest { - content, - author: config.user.name.clone(), - ..ChatRequest::default() - }; - // The message has moved from draft to request, so the draft is done. Clearing // it here rather than from the caller gives it one owner: a client that // cleared its own draft would be racing its debounced save, and losing. @@ -1024,6 +1035,83 @@ async fn prepare_turn( Ok((inputs, stream)) } +/// Decide how a conversation nobody has named gets a title from its first +/// message. +/// +/// A leading markdown heading is written straight to the conversation. +/// Anything else needs the model, and comes back as a task for the caller to +/// run. +/// Returns `None` when the conversation already has a title, already has +/// events, or the configuration asks for neither route. +fn resolve_title( + config: &AppConfig, + lock: &ConversationLock, + stream: &ConversationStream, + chat_request: &ChatRequest, +) -> Option { + if lock.metadata().title.is_some() || !stream.is_empty() { + return None; + } + + match resolve_new_title( + config.conversation.title.from_heading, + config.conversation.title.generate.auto, + &chat_request.content, + ) { + NewTitle::FromHeading(title) => { + debug!(conversation = %lock.id(), "Titling from the prompt's leading heading."); + lock.as_mut() + .update_metadata(|meta| meta.title = Some(title)); + None + } + NewTitle::Generate => { + // The title model is configured separately from the assistant's, so + // a broken one must not take the turn down with it. + let mut events = stream.clone(); + events.start_turn(chat_request.clone()); + + match TitleGeneratorTask::new(lock.id(), events, config, false) { + Ok(task) => Some(task), + Err(error) => { + warn!(%error, "Skipping title generation."); + None + } + } + } + NewTitle::Skip => None, + } +} + +/// Run a title task and record what it produced. +/// +/// Writes through the turn's own lock, so the name is on disk as soon as the +/// model answers rather than when the turn ends. +async fn write_generated_title(task: Option, lock: &ConversationLock) { + let Some(task) = task else { + return; + }; + + let title = match task.generate().await { + Ok(Some(title)) => title, + Ok(None) => { + warn!(conversation = %lock.id(), "The title model answered without a title."); + return; + } + Err(error) => { + warn!(%error, conversation = %lock.id(), "Failed to generate a title."); + return; + } + }; + + debug!(conversation = %lock.id(), %title, "Generated a conversation title."); + + let mut conv = lock.as_mut(); + conv.update_metadata(|meta| meta.title = Some(title)); + if let Err(error) = conv.flush() { + warn!(%error, "Failed to persist the generated title."); + } +} + /// Flatten an error and its sources into one line. /// /// JP's error types label a category and carry the cause underneath, so the diff --git a/crates/jp_cli/src/cmd/query.rs b/crates/jp_cli/src/cmd/query.rs index 1c59dca42..571c0b345 100644 --- a/crates/jp_cli/src/cmd/query.rs +++ b/crates/jp_cli/src/cmd/query.rs @@ -2242,7 +2242,7 @@ enum QuerySource { /// How a new conversation's title is set from its first prompt, before the turn /// runs. #[derive(Debug, PartialEq)] -enum NewTitle { +pub(crate) enum NewTitle { /// Use this text, taken verbatim from a leading markdown heading. FromHeading(String), @@ -2259,7 +2259,11 @@ enum NewTitle { /// background generation is chosen when `generate_auto` is enabled. /// The two flags are independent: disabling generation does not disable /// heading-derived titles. -fn resolve_new_title(from_heading: bool, generate_auto: bool, content: &str) -> NewTitle { +pub(crate) fn resolve_new_title( + from_heading: bool, + generate_auto: bool, + content: &str, +) -> NewTitle { if from_heading && let Some(title) = jp_md::heading::leading_heading(content) { return NewTitle::FromHeading(title); } diff --git a/crates/jp_task/src/task/title_generator.rs b/crates/jp_task/src/task/title_generator.rs index e54c97d1f..c263c841a 100644 --- a/crates/jp_task/src/task/title_generator.rs +++ b/crates/jp_task/src/task/title_generator.rs @@ -56,6 +56,16 @@ impl TitleGeneratorTask { }) } + /// Ask the model for a title and return it. + /// + /// For a caller that already holds the conversation lock and can write the + /// title itself. + /// Returns `None` when the model answered without a usable title. + pub async fn generate(mut self) -> Result, Box> { + self.update_title().await?; + Ok(self.title) + } + async fn update_title(&mut self) -> Result<(), Box> { trace!(conversation_id = %self.conversation_id, "Updating conversation title."); From bfac7bc6be26371bb38fc69f09aeec6b9c4f0bba Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Mon, 24 Aug 2026 22:07:12 +0200 Subject: [PATCH 05/15] refactor(serve-web): Move page scripts into `.js` files The three page scripts lived in raw Rust string literals, where nothing could look at them. A stray double quote ended the literal, a redeclaration between two scripts on the same page went unnoticed, and the first sign of either was a blank page and a browser console. The scripts also had to avoid double quotes entirely, which is why every inline SVG attribute is single-quoted. Each script is a file next to the view that serves it, pulled in with `include_str!`, so the bytes reaching the browser are unchanged. A build script parses them with `oxc` and applies the spec's early-error rules, grouped per page because classic scripts share one global lexical scope: a name declared in `list.js` collides with the same name in `filter.js`, and neither file is wrong on its own. Diagnostics are reported as `cargo::error` directives, so a broken script fails `cargo build` with a line and column. Signed-off-by: Jean Mertz --- Cargo.lock | 471 +++++- Cargo.toml | 4 + crates/plugins/command/serve-web/Cargo.toml | 6 + crates/plugins/command/serve-web/build.rs | 74 + .../command/serve-web/src/views/detail.js | 1354 ++++++++++++++++ .../command/serve-web/src/views/detail.rs | 1361 +---------------- .../command/serve-web/src/views/filter.js | 23 + .../command/serve-web/src/views/list.js | 85 + .../command/serve-web/src/views/list.rs | 114 +- 9 files changed, 1980 insertions(+), 1512 deletions(-) create mode 100644 crates/plugins/command/serve-web/build.rs create mode 100644 crates/plugins/command/serve-web/src/views/detail.js create mode 100644 crates/plugins/command/serve-web/src/views/filter.js create mode 100644 crates/plugins/command/serve-web/src/views/list.js diff --git a/Cargo.lock b/Cargo.lock index b40cb125d..95a73a8d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,6 +56,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -374,11 +380,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -447,6 +453,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "byteorder" version = "1.5.0" @@ -523,6 +535,15 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.56" @@ -661,6 +682,19 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "compact_str" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "static_assertions", + "zmij", +] + [[package]] name = "comrak" version = "0.52.0" @@ -671,7 +705,7 @@ dependencies = [ "entities", "finl_unicode", "jetscii", - "phf", + "phf 0.13.1", "phf_codegen", "rustc-hash 2.1.1", "smallvec", @@ -752,6 +786,12 @@ version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + [[package]] name = "cpp_demangle" version = "0.5.1" @@ -819,7 +859,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "crossterm_winapi", "derive_more", "document-features", @@ -860,7 +900,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.13.1", "smallvec", ] @@ -1083,6 +1123,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dragonbox_ecma" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd8e701084c37e7ef62d3f9e453b618130cbc0ef3573847785952a3ac3f746bf" + [[package]] name = "dtoa" version = "1.0.11" @@ -1625,6 +1671,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", "foldhash", ] @@ -1651,7 +1698,7 @@ checksum = "de550515ae03ff01fb033658945ba393c8db391297978a1f988ecb436e072f87" dependencies = [ "html5ever", "markup5ever_rcdom", - "phf", + "phf 0.13.1", ] [[package]] @@ -2002,7 +2049,7 @@ name = "inquire" version = "0.9.1" source = "git+https://github.com/JeanMertz/inquire?branch=merged#93ecb2750bc244b2af69b4294d6809957ec7adf0" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "crossterm", "dyn-clone", "unicode-segmentation", @@ -2036,7 +2083,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg-if", "libc", ] @@ -2072,11 +2119,20 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jetscii" @@ -2111,6 +2167,10 @@ dependencies = [ "form_urlencoded", "jp_plugin", "maud", + "oxc_allocator", + "oxc_parser", + "oxc_semantic", + "oxc_span", "pretty_assertions", "serde", "serde_json", @@ -2799,7 +2859,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "libc", ] @@ -2980,9 +3040,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" @@ -3057,7 +3117,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -3069,7 +3129,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -3085,6 +3145,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -3094,6 +3160,25 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3200,6 +3285,231 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "oxc_allocator" +version = "0.146.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064fce871c5cb07e557049ed27c8ca2aa530db797f73424c67e4bb8cfe61175e" +dependencies = [ + "allocator-api2", + "hashbrown 0.17.1", + "oxc_data_structures", + "rustc-hash 2.1.1", +] + +[[package]] +name = "oxc_ast" +version = "0.146.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a999fd4494b604fc0328fc43443bc1298722500587d87d7e993118bd82c65d7b" +dependencies = [ + "bitflags 2.13.1", + "oxc_allocator", + "oxc_ast_macros", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_estree", + "oxc_regular_expression", + "oxc_span", + "oxc_str", + "oxc_syntax", +] + +[[package]] +name = "oxc_ast_macros" +version = "0.146.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d715e3c300c95b1d797b05526567161fd86fcac2ac68235101c61b736f216cde" +dependencies = [ + "phf 0.14.0", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "oxc_ast_visit" +version = "0.146.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b9d77b5c27575ef7d624de1f226caeca3c0d3fcc2347bdaa788d5c4a85512d" +dependencies = [ + "oxc_allocator", + "oxc_ast", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_data_structures" +version = "0.146.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d15a23b57a931fda6bc9a4fdc3fbabcac6a2edfd0b7216cb7c81432f0e9f54d9" + +[[package]] +name = "oxc_diagnostics" +version = "0.146.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d0ce1ec51b07b6501eecedd5ab50835b7ebe5c8463cada3b2e6d984d0943af" +dependencies = [ + "bytecount", + "cow-utils", + "itoa", + "memchr", + "owo-colors", + "oxc_span", + "percent-encoding", + "smallvec", + "textwrap", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "oxc_ecmascript" +version = "0.146.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5d3d07165b7aadcd021f62cd95fb7d62cb6977b8bbfb42fa12adbfb7e75257" +dependencies = [ + "dragonbox_ecma", + "itoa", + "num-bigint", + "num-traits", + "oxc_ast", + "oxc_data_structures", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_estree" +version = "0.146.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7af86a59b7aeb2845ffea2cc21ae259053865b175e9ac936a7d72f2744abe5" + +[[package]] +name = "oxc_index" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "191884bee6c3744909a51acc7d78d4ae370d817b25875b10642f632327b6296e" +dependencies = [ + "nonmax", + "serde", +] + +[[package]] +name = "oxc_parser" +version = "0.146.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311c29dfdf55ea8bf065cf300b3d0dca5fe1c0fb8059c9ba70a6c98cce75306e" +dependencies = [ + "bitflags 2.13.1", + "cow-utils", + "memchr", + "num-bigint", + "num-traits", + "oxc_allocator", + "oxc_ast", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash 2.1.1", + "seq-macro", +] + +[[package]] +name = "oxc_regular_expression" +version = "0.146.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31839a373ad02a37fad54244a3eb710f9a94b5e2e16ca0e1d28a37b4c75b8ebc" +dependencies = [ + "bitflags 2.13.1", + "oxc_allocator", + "oxc_ast_macros", + "oxc_diagnostics", + "oxc_span", + "oxc_str", + "phf 0.14.0", + "rustc-hash 2.1.1", + "unicode-id-start", +] + +[[package]] +name = "oxc_semantic" +version = "0.146.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366191309f606847936aade62707b06683ed5e0b0c8cb66c27710a61f64a50f1" +dependencies = [ + "itertools 0.15.0", + "memchr", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_index", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash 2.1.1", + "self_cell", + "smallvec", +] + +[[package]] +name = "oxc_span" +version = "0.146.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c355ae1fa3e865c28cb8a85735ecafd1bb6340a5b880c638d127c1f2d61a98a5" +dependencies = [ + "compact_str", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_str", +] + +[[package]] +name = "oxc_str" +version = "0.146.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba45ef9efa8296e647aa37b2b21936cb520953031edc1ef6352281bbea22faa" +dependencies = [ + "compact_str", + "hashbrown 0.17.1", + "oxc_allocator", + "oxc_estree", +] + +[[package]] +name = "oxc_syntax" +version = "0.146.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0970d9be099a082711b574d58add258549580a70fe6546b99a067bbd7ad4a264" +dependencies = [ + "bitflags 2.13.1", + "cow-utils", + "dragonbox_ecma", + "nonmax", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_index", + "oxc_span", + "oxc_str", + "phf 0.14.0", + "unicode-id-start", +] + [[package]] name = "parking" version = "2.2.1" @@ -3306,8 +3616,19 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", "serde", ] @@ -3317,8 +3638,8 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", ] [[package]] @@ -3328,7 +3649,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", ] [[package]] @@ -3337,8 +3668,21 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.114", @@ -3353,6 +3697,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -3467,7 +3820,7 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "num-traits", "rand", "rand_chacha", @@ -3632,7 +3985,7 @@ version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", ] [[package]] @@ -3654,7 +4007,7 @@ dependencies = [ "chrono", "crossterm", "fd-lock", - "itertools", + "itertools 0.13.0", "nu-ansi-term", "serde", "strip-ansi-escapes", @@ -3873,7 +4226,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "libsqlite3-sys", @@ -3905,7 +4258,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -4143,7 +4496,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc198e42d9b7510827939c9a15f5062a0c913f3371d765977e586d2fe6c16f4a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "core-foundation", "core-foundation-sys", "libc", @@ -4166,12 +4519,12 @@ version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "feef350c36147532e1b79ea5c1f3791373e61cbd9a6a2615413b3807bb164fb7" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cssparser", "derive_more", "log", "new_debug_unreachable", - "phf", + "phf 0.13.1", "phf_codegen", "precomputed-hash", "rustc-hash 2.1.1", @@ -4179,6 +4532,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -4189,6 +4548,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.228" @@ -4497,9 +4862,18 @@ checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "smawk" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" [[package]] name = "socket2" @@ -4543,7 +4917,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.13.1", "precomputed-hash", ] @@ -4553,8 +4927,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", ] @@ -4751,6 +5125,11 @@ name = "textwrap" version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] [[package]] name = "thiserror" @@ -5038,7 +5417,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -5230,12 +5609,24 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +[[package]] +name = "unicode-id-start" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" version = "0.1.24" @@ -5253,9 +5644,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -5507,7 +5898,7 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" dependencies = [ - "phf", + "phf 0.13.1", "phf_codegen", "string_cache", "string_cache_codegen", diff --git a/Cargo.toml b/Cargo.toml index f19a73dd1..2dc2fc4e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,6 +105,10 @@ object = { version = "0.39" } # which is the last release on reqwest 0.12. ollama-rs = { git = "https://github.com/JeanMertz/ollama-rs", rev = "6270148236d2d8e199580ee8f5a529868134c0a5", default-features = false } openai_responses = { version = "0.1", default-features = false } +oxc_allocator = { version = "0.146", default-features = false } +oxc_parser = { version = "0.146", default-features = false } +oxc_semantic = { version = "0.146", default-features = false } +oxc_span = { version = "0.146", default-features = false } parking_lot = { version = "0.12", default-features = false, features = ["arc_lock"] } paste = { version = "1", default-features = false } percent-encoding = { version = "2", default-features = false } diff --git a/crates/plugins/command/serve-web/Cargo.toml b/crates/plugins/command/serve-web/Cargo.toml index 24cafaa8f..810e11516 100644 --- a/crates/plugins/command/serve-web/Cargo.toml +++ b/crates/plugins/command/serve-web/Cargo.toml @@ -27,6 +27,12 @@ tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["ansi", "env-filter", "fmt", "std"] } +[build-dependencies] +oxc_allocator = { workspace = true } +oxc_parser = { workspace = true } +oxc_semantic = { workspace = true } +oxc_span = { workspace = true } + [dev-dependencies] pretty_assertions = { workspace = true, features = ["std"] } tracing = { workspace = true, features = ["std"] } diff --git a/crates/plugins/command/serve-web/build.rs b/crates/plugins/command/serve-web/build.rs new file mode 100644 index 000000000..664327c6a --- /dev/null +++ b/crates/plugins/command/serve-web/build.rs @@ -0,0 +1,74 @@ +//! Fails the build when an embedded script cannot run. +//! +//! The scripts are served verbatim inside `