diff --git a/Cargo.lock b/Cargo.lock index 62e2ff126..ce1d69cab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -254,6 +254,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -267,6 +268,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", "tokio", "tower", @@ -2048,9 +2052,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 25ff41b9c..4b75d1644 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,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.17", 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 } # glob = { version = "0.3", default-features = false } diff --git a/crates/jp_cli/src/cmd/plugin/dispatch.rs b/crates/jp_cli/src/cmd/plugin/dispatch.rs index 95bc86a78..4cb0810ec 100644 --- a/crates/jp_cli/src/cmd/plugin/dispatch.rs +++ b/crates/jp_cli/src/cmd/plugin/dispatch.rs @@ -36,11 +36,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; use jp_workspace::{ConversationLock, LockResult, Workspace, session::Session}; use relative_path::RelativePath; @@ -254,6 +255,7 @@ pub(crate) async fn run_plugin( options, args: args.to_vec(), log_level, + output_format: output_format(ctx.printer.format()), }); let PluginProcess { @@ -411,6 +413,19 @@ fn stop_plugin(stdin: &Mutex, shutdown_sent: &AtomicBool, child_id: 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 { @@ -1398,13 +1413,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 4525260d9..1ca63ef4f 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. @@ -279,6 +375,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 7acfe17c2..232cf1829 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/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 71697b0a3..1b3da375a 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 { @@ -67,6 +160,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..8054b0143 --- /dev/null +++ b/crates/plugins/command/serve-web/src/views/list_tests.rs @@ -0,0 +1,54 @@ +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"), + 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 47bb65056..e243585bb 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::*; @@ -59,6 +59,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(), }) }