diff --git a/CHANGELOG.md b/CHANGELOG.md index c4a2935b..375029ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,96 @@ # Changelog +## 2.2.0 (unreleased) + +- **Your own model servers.** **Settings › Accounts › Your own endpoints** + connects Ollama, LM Studio, a llama.cpp or vLLM server, xAI, OpenAI or any + OpenAI-compatible API. **Find models** lists what the server offers; the + models you tick appear in the picker (servers on this computer under *On + this computer*, others under *API keys*, off in offline mode) and run with + ShadowCode's own agent. **Test** and **Remove** sit beside each one. A typed + key is saved on this computer only when you add models, under a name chosen + for the service (`api.deepseek.com` uses `DEEPSEEK_API_KEY`), so two services + never overwrite each other's key; a remote device never makes the desktop + send a saved key to an address it typed. New routes `POST + /api/providers/models`, `POST /api/models/endpoints` and `POST + /api/models/remove`; `GET /api/picker` lists these rows with `custom: true`. +- **Settings › Agent.** Steps per task, the token budget and autonomy profile, + tool time limit and output size, history shortening and summaries, provider + retries, fix attempts, the stuck check, subagent limits, checkpoints and the + commands that count as checks can be changed without editing `config.yaml`. + Each setting shows what it does, its range and default, and has a Reset; + out-of-range values are caught before saving and a refused save shows the + engine's reason. Only the settings you changed are saved, so a project's own + agent settings are never copied into your profile. +- **Find in conversation.** `Ctrl+F` (or *Find in conversation* in the command + palette) searches the open conversation's prompts, answers and notes, older + saved messages included, and shows *3 of 12*. `Enter` / `Shift+Enter` and + `F3` / `Shift+F3` step through matches, opening older messages as needed; + matches on screen are highlighted (the row is outlined where the webview + cannot highlight text). A file editor and the terminal keep their own + `Ctrl+F`. +- **Search finds words in answers.** Task search in the sidebar and the Tasks + tab also matches the agent's answers, and a result whose title doesn't show + the words shows the message or answer that matched, with the words marked. + `GET /api/sessions?q=` rows carry `match: {field, snippet, start, end}`. +- **A smarter command palette.** `Ctrl+K` matches letters in order, so *rev ch* + finds **Review changes**; word starts rank first and matched letters are + bold. Typing also lists matching **Conversations** and **Projects** to open, + and with nothing typed **Recent** shows the last five commands you ran. + Screen readers follow the highlighted entry; `Page Up`/`Page Down` and + `Home`/`End` move through the list. +- **Text size.** `Ctrl+=` (or `Ctrl++`), `Ctrl+-` and `Ctrl+0` zoom the whole + window from 80% to 175%, with the new size shown briefly; it is saved as + `ui.zoom` and the window reopens at that size. The desktop zooms its webview; + in a browser over remote access the terminal and editor still put the + pointer on the right text. A phone or browser using remote access keeps its + own size in its own storage, starting at 100%, and never changes the + desktop's. **Settings › Appearance** gains a Text size + control, and theme and text size now change as you pick them: leaving + without saving puts back what you had. The palette offers *Theme: same as the + system*, *light* and *dark* (the old toggle could never get back to System) + and *Zoom in*, *Zoom out* and *Reset zoom*. +- **Message times.** Prompts and answers show when they were sent, quietly at + their edge ("14:32", "Yesterday 14:32", or the date), with the full date and + time on hover; a quick reply keeps its time hidden until hovered. Times come + from the conversation's record. +- **All of a tool's output.** Long output in the activity timeline and in hook + cards offers **Show all (N lines)** / **Show less** and **Copy**, which copies + all of it; very long output shows its last 2,000 lines and says so. +- **Composer `/` menu.** `Enter` picks the highlighted command, as `Tab` and + the `@` menu do, and the next `Enter` sends it. Commands whose name or + description contains what you typed follow the name matches. +- **Notifications wait while you read them.** A notification stays while the + pointer is on it or focus is inside it, then finishes its remaining time. +- **Screen readers hear milestones, not every update.** Only the status bar's + state is announced, and the conversation is a named log that isn't read out + while it streams. One announcement says when an answer finished, a task + failed or stopped, or an action needs approval; approval cards are named for + their action. +- The page title names the open conversation, with a dot while a task runs, + so a browser tab on a paired device says what it shows. +- Deleting an automation asks in the app's own dialog. The drawer's tabs are a + tab list (arrow keys, Home/End). *Latest activity* jumps instead of gliding + when the system asks for reduced motion. +- **The theme follows the desktop by default.** `ui.theme` now defaults to + `system` in the engine and `config.example.yaml`, as the window and + onboarding already assumed; a profile that never chose a theme no longer + opens light on a dark desktop. Saved choices are unchanged. +- `docs/NATIVE_ROUTING.md` no longer points at a *Custom model…* control that + does not exist. +- **Subagent settings are checked when saved.** A `subagents` value that is + misspelled, the wrong type or out of range (for example `max_parallel: 20`) + is refused with the allowed range, instead of being clamped or ignored + without a word. A `config.yaml` saved earlier still loads as before. +- **Settings › Agent shows what it saves.** It showed the open project's own + `agent` values (from `.shadow/config/config.yaml`) as if they were the + profile's, while saving to the profile. It now shows and compares against + the profile's values and notes under a setting when the project uses + another, for example "This project uses 200.". `GET`/`PUT /api/config` + return `agent_profile` and `agent_project` for this. +- **Stop at a spending limit** always ends the task as *cancelled*; it could + end as *failed* when the answer reached the task before the engine did. + ## 2.1.0: ARM64, a signed APT repository and a safer installer - **Signed APT repository.** Each release's authenticated `.deb` is indexed in diff --git a/config.example.yaml b/config.example.yaml index 73c1a458..e3acc3ad 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -60,6 +60,7 @@ sandbox: - .deno # Project checkpoints around shell commands and subscription turns (Rewind). +# Settings › Agent edits these. checkpoints: shell: true vendor: true @@ -82,6 +83,7 @@ spending: logging: level: info +# ShadowCode's own agent (Settings › Agent edits these). agent: max_steps: 64 # model turns per task, 1-1000 tool_timeout_sec: 60 # 1-3600 @@ -157,6 +159,7 @@ hooks: approved: [] # {workspace, path, hash} # Exact checks a task may run to verify its work (shell policy still applies). +# Settings › Agent › Checks edits the list. verification: commands: [] @@ -167,7 +170,7 @@ guardian: allow_prepare_patch: false ui: - theme: light # system | light | dark + theme: system # system (follow the desktop) | light | dark notify: true # desktop notifications at all notify_approval: true # a task asks for approval notify_failed: true # a task failed @@ -176,6 +179,7 @@ ui: notify_sound: false # ask the notification server to play its sound # Subagents: children a task starts with `spawn_agent` (native loop only). +# Settings › Agent edits these. subagents: enabled: true max_parallel: 4 # children of one task running at once, 1-8 diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 614f8108..82c472a4 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -466,8 +466,10 @@ need a Unix build. ShadowCode 1.x ships for Linux, where all of them exist. | `POST` | `/api/mcp/servers/delete` | stable | allowed | [Extensions](#extensions) | | `POST` | `/api/memory` | stable | allowed | [Slash commands and memory](#slash-commands-and-memory) | | `GET` | `/api/models` | stable | allowed | [Accounts and models](#accounts-and-models) | +| `POST` | `/api/models/endpoints` | stable | allowed | [Accounts and models](#accounts-and-models) | | `GET` | `/api/models/outcomes` | experimental | allowed | [Accounts and models](#accounts-and-models) | | `POST` | `/api/models/register` | stable | allowed | [Accounts and models](#accounts-and-models) | +| `POST` | `/api/models/remove` | stable | allowed | [Accounts and models](#accounts-and-models) | | `POST` | `/api/models/select` | stable | allowed | [Accounts and models](#accounts-and-models) | | `POST` | `/api/models/test` | stable | allowed | [Accounts and models](#accounts-and-models) | | `GET` | `/api/onboarding` | stable | allowed | [Settings and health](#settings-and-health) | @@ -493,6 +495,7 @@ need a Unix build. ShadowCode 1.x ships for Linux, where all of them exist. | `POST` | `/api/projects/trust` | stable | allowed | [Conversations](#conversations) | | `GET` | `/api/providers` | experimental | allowed | [Accounts and models](#accounts-and-models) | | `GET` | `/api/providers/detect` | experimental | allowed | [Accounts and models](#accounts-and-models) | +| `POST` | `/api/providers/models` | stable | allowed | [Accounts and models](#accounts-and-models) | | `GET` | `/api/remote` | stable | refused | [Remote access](#remote-access) | | `PUT` | `/api/remote` | stable | refused | [Remote access](#remote-access) | | `POST` | `/api/remote/devices/revoke` | stable | refused | [Remote access](#remote-access) | @@ -644,7 +647,12 @@ SessionRow = Session + {compare_id, compare_lane, worktree_task, worktree_source - `GET /api/sessions?q=&workspace=&limit=&include_compare=&include_subagents=` → `{sessions: SessionRow[]}`, newest `updated_at` first. `q` matches the - title, folder or any task prompt. `limit` default 100, max 10 000. + title, folder, any task prompt or any task's answer (`summary`), ignoring + the case of ASCII letters. With `q`, a row whose title does not contain it + carries `match: {field: "prompt"|"answer", snippet, start, end}`: an + excerpt (about 40 characters before and 80 after, on one line, `…` where + cut) of the newest prompt or answer that matched, with the match at + character offsets `start`–`end`. `limit` default 100, max 10 000. Compare lanes and subagent conversations are hidden unless `include_compare=true|1` / `include_subagents=true|1`. `workspace=` also lists conversations running in that project's worktree tasks. @@ -1907,7 +1915,10 @@ See [SUBAGENTS.md](SUBAGENTS.md). - `GET /api/config` → the effective configuration for the selected project plus derived, read-only fields: `permissions.vendor_notes: {native, codex, claude, cursor, grok, antigravity, network}` - and `network.offline`. Sections: `model`, `permissions`, `agent`, `ui`, + and `network.offline`, plus `agent_profile` (the profile's own `agent` + values, which `PUT` saves) and `agent_project` (the `agent` keys a trusted + project's `.shadow/config/config.yaml` changes, with the values it uses; + `{}` when none). `PUT` returns both too. Sections: `model`, `permissions`, `agent`, `ui`, `onboarding`, `routing`, `mcp`, `hooks`, `verification`, `trusted_workspaces`, `guardian`, `cli_agents`, `local_engine`, `network`, `sandbox`, `checkpoints`, `limits`, `updates`, and, when saved, `code_intel` @@ -2309,6 +2320,40 @@ Row = {id: "cli:"|"openrouter"|"local", kind: "subscription"|"api_key"|" `running` from detection. `GET /api/providers/detect?refresh=1` (experimental) → `{providers}`: local servers found (cached 30 s). +### Your own endpoints + +OpenAI-compatible servers the user adds (Ollama, LM Studio, a llama.cpp or +vLLM server, xAI, OpenAI or any compatible API). `provider` is one of +`ollama`, `local` (LM Studio), `llamacpp`, `vllm`, `grok` or +`openai_compatible`. An `endpoint` given as a bare host gains `/v1`; one +with a user name, password, query or fragment is refused. `api_key_env` +defaults to the preset's key name, and for `openai_compatible` on a named +host to `_API_KEY` (`api.deepseek.com` → `DEEPSEEK_API_KEY`; +`api.openai.com` → `OPENAI_API_KEY`). + +- `POST /api/providers/models {provider, endpoint?, api_key?, api_key_env?}` + → `{ok, provider, label, endpoint, api_key_env, key_saved, local, models: + [{id, name, size_bytes, context_limit, capabilities, detail}]}`: the models + the server lists (`/api/tags` for Ollama, otherwise `GET {endpoint}/models`; + 10 s, at most 2 MB). Nothing is saved. The typed `api_key` is sent as a + bearer token; without one the saved key is used, except from a remote + device. Offline, only servers on this computer are reached. +- `POST /api/models/endpoints {provider, endpoint?, models: [name], api_key?, + api_key_env?, context_limit?, vision?}` → `{ok, models: [ModelConfig]}`: + registers each model (id `model:` of provider, endpoint and name) and + saves a typed `api_key` under `api_key_env`. At most 100 models per call. +- `POST /api/models/remove {id}` → `{ok, removed}`: removes a model added for + an endpoint (and older registry rows for the same server and model). The + `model:` default in `config.yaml` and other registry rows are refused. + Saved keys are kept. + +`GET /api/picker` lists these models with `custom: true` and their +`endpoint`. A model on this computer (loopback) is a `local` row named +` · This computer`; any other is an `api` row (`inference: "cloud"`, +billed by the provider, `unavailable` offline). A row whose key was saved +and is now missing is `sign_in`. Tasks on them run ShadowCode's own agent +loop. + ### Local models `GET /api/local-models` → `LocalCatalog`: diff --git a/docs/NATIVE_ROUTING.md b/docs/NATIVE_ROUTING.md index 7424fcfa..cd43ba8c 100644 --- a/docs/NATIVE_ROUTING.md +++ b/docs/NATIVE_ROUTING.md @@ -58,8 +58,8 @@ private secret storage; they are not included in task routing events. Use registry IDs for API configuration. A bare model name is accepted only when it resolves unambiguously, unless it is already an exact saved ID. Add a model -through **Custom model…** or configure it in Settings before assigning it to a -route. The native service also supports `POST /api/models/register` through IPC +through **Settings › Accounts › Your own endpoints** (any OpenAI-compatible +server) or `shadowcode models --use` before assigning it to a route. The native service also supports `POST /api/models/register` through IPC for integrations that need to register a model without changing the default. ## Changes and failures diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 8e649d6a..d899aabd 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -45,7 +45,8 @@ conversation. It can start one, or several at once with `tasks`. vendor CLI subagent's process. - **Limits.** At most 4 subagents run at the same time, and one task can start 16 in total. Subagents cannot start their own subagents. A subagent stops - after its step limit (the definition's `max_turns`, or 24). + after its step limit (the definition's `max_turns`, or 24). These are the + defaults; change them in **Settings › Agent › Subagents**. - **Models.** A subagent uses, in order: the model the call names, its definition's `model`, its role's model (`explore`, `plan`, `review`, and write agents as `implement`), or the parent task's model. Any picker id @@ -279,7 +280,8 @@ the agent; you can still run it with `/skill `. ## Settings -`config.yaml`: +**Settings › Agent › Subagents** changes the `subagents` group, with each +setting's range and default. By hand, in `config.yaml`: ```yaml subagents: diff --git a/docs/SUBSCRIPTIONS.md b/docs/SUBSCRIPTIONS.md index 591d59b6..b53c72bf 100644 --- a/docs/SUBSCRIPTIONS.md +++ b/docs/SUBSCRIPTIONS.md @@ -206,9 +206,9 @@ never uses it once a turn has started or when images are attached. changed although the agent didn't report editing them (a command it ran, or another program). Changes you make outside ShadowCode's editor during the turn can't be told apart from the agent's and are rewound with them. - The vendor's own conversation isn't told about a rewind. Set - `checkpoints.vendor: false` to turn this off (the editor then waits for - the turn to end). + The vendor's own conversation isn't told about a rewind. To turn this off, + clear **Before subscription turns** in **Settings › Agent › Checkpoints** + (`checkpoints.vendor: false`); the editor then waits for the turn to end. - **Usage.** Tokens and cost the CLI reports are counted once per model call, and also when the turn fails, hits the plan limit or is stopped. - **Switching providers.** A move between providers hands over at most 12,000 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index b2f26b6e..0837e390 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -38,6 +38,24 @@ the same three choices until a model is ready. The sidebar groups conversations by project. **New task** (`Ctrl+N`) starts a new conversation in the current project. +## The command palette + +`Ctrl+K` (or the search button at the top right) opens the command palette. +Type a few letters of what you want: they only need to appear in order, so +*rev ch* finds **Review changes** and *cmp* finds **Compare models on this +task**. Matches at the start of words rank first, and the matched letters are +shown in bold. + +- With something typed, the palette also lists **Conversations** whose title + matches (up to eight, with their project beside them) and **Projects** to + open. Choosing a conversation opens it, just as clicking it in the sidebar + does. Conversations without a title yet are not listed. +- With nothing typed, **Recent** shows the last five commands you ran from + the palette on this computer, then every other command. +- `↑` / `↓` move through the list, `Page Up` / `Page Down` move a page, + `Home` / `End` go to the first or last entry, `Enter` runs the highlighted + one and `Esc` closes the palette. + ## Edit files and inspect context Open a file from the project Files list to edit it with code highlighting, line @@ -72,6 +90,7 @@ groups: - **API keys**: OpenRouter models, billed per token to your OpenRouter account. Add a key in **Settings › Accounts › OpenRouter**; until then the group offers *Add an OpenRouter API key…*. See [OpenRouter](OPENROUTER.md). + Models from your own cloud endpoints (below) join this group too. Each row shows Local or Cloud, its availability, a *Vision* or *Chat only* badge where it applies, and its usage line. Search filters rows. Vendors with @@ -87,6 +106,27 @@ A row that isn't ready still opens something useful: *Sign in* opens ShadowCode stores the choice for the conversation and uses it as the project's default for new conversations. +### Your own endpoints + +**Settings › Accounts › Your own endpoints** connects any server that speaks +the OpenAI chat API: Ollama, LM Studio, a llama.cpp or vLLM server, xAI, OpenAI, +or another compatible service. Choose **Add an endpoint…**, pick the server +(its usual address is filled in), type an API key if it needs one, and choose +**Find models**. Tick the models you want and choose **Add to the picker**. + +- A server on this computer joins **On this computer**; any other joins + **API keys** and is off in offline mode. Tasks on them run ShadowCode's own + agent, with the same tools, permissions and approvals as local models. +- A typed key is saved on this computer only when you add models, under a + name chosen for the address (`api.deepseek.com` uses `DEEPSEEK_API_KEY`), + so two services never overwrite each other's key. **More options** changes + the name and marks models that can read images. +- If a server doesn't list its models, add one by its exact name. +- **Test** sends one short message and shows how long the answer took. + **Remove** takes a model out of the picker; its saved key stays. +- Cloud endpoints bill you directly. ShadowCode shows a cost only when the + server reports one. + ### How each model did in this project After tasks finish, each row that ran in this project shows a short history: @@ -135,7 +175,10 @@ non-default reasoning effort stays marked on the **More** button. and how the selected vendor applies it. - **Slash commands.** Type `/` to browse them. `/plan` and `/review` start read-only tasks. `/model` opens the picker. Claude Code commands in - `.claude/commands/` appear here too. + `.claude/commands/` appear here too. Commands whose names start with what + you typed come first, then ones whose name or description contains it. + `↑`/`↓` move through the list; `Enter` or `Tab` puts the highlighted + command in the composer, and the next `Enter` sends it. - **Agents.** Start a message with `@explore`, `@plan`, `@review`, `@general` or a project agent's name to run that subagent first. Type `@` to list them. - **Files and folders.** Type `@` anywhere, then part of a path, to pick a @@ -174,6 +217,15 @@ Hover a message you sent for its actions: - **Retry** sends the same message again in this conversation. - **Copy** copies the text. Answers have **Copy** and **Fork from here**. +Each message and answer shows when it was sent, small at its edge: *14:32* +today, *Yesterday 14:32*, the date and time earlier this year (*3 Oct, +09:15*) and only the date before that, in your system's time format. Hover +it for the full date and time. A reply that follows +within a few minutes keeps its time out of the way until you hover the +message, so a quick exchange shows the time once. Times come from the +conversation's record, so a fork keeps the original times; a message with +no recorded time shows none. + ## Watch it work A note above the answer names the model that ran, for example @@ -185,6 +237,16 @@ verification. Expand an item to see its output. Vendor tool names (for example Codex command executions, or Claude's `Bash` and `Edit`) are grouped the same way as ShadowCode's own tools. +Long output shows its start; **Show all (N lines)** shows the rest and +**Show less** folds it again. Very long output shows its last 2,000 lines +(and says so), which keeps the window responsive. **Copy**, in the corner of +the output when you hover or tab to it, copies all of it, including any part +not shown. + +With a screen reader, the answer is not read out while it streams. Instead +ShadowCode says when the answer has finished, when a task failed or was +stopped, and when an action needs your approval. + ### Approvals When the agent wants to do something that needs permission, an approval card @@ -261,7 +323,8 @@ at once. Each run shows as a card in the conversation; click it for the result and changed files, or **Open transcript** for its own conversation. A write subagent's changes reach your project only when the main agent applies its diff, with your usual edit approval. Approvals a subagent needs -appear in this conversation, labelled with its name. +appear in this conversation, labelled with its name. How many run at once, +how many a task may start and their step limit are in **Settings › Agent**. ### Roles: a model for each step @@ -324,8 +387,9 @@ reply. approvals, without selecting a model or sending another model request. Your unsent message stays in the composer. Output and a new check result appear in the conversation; earlier results remain in history. Commands - have a five-minute maximum, or your shorter configured tool timeout. A - passing exit code confirms that check, not every requirement of the task. + have a five-minute maximum, or your shorter tool time limit (**Settings › + Agent**). A passing exit code confirms that check, not every requirement + of the task. - **Review changes** appears only when files changed. It opens the task's review across the whole window: only the files this task changed, each compared with how it was before the task. Files are grouped riskiest @@ -358,9 +422,10 @@ reply. subscription turn, ShadowCode takes a checkpoint of the project. In a Git repository it is a hidden commit under `refs/shadowcode/checkpoints/`; your branch, index and staged changes are untouched. In a folder without Git it - is a copy, for folders up to 5,000 files and 64 MB. A larger folder - shows *Rewind does not cover this command*. Stop the task first (for a - subscription, wait for the turn to end). Rewind asks first and lists the + is a copy, for folders up to 5,000 files and 64 MB (change both in + **Settings › Agent**). A larger folder shows *Rewind does not cover this + command*. Stop the task first (for a subscription, wait for the turn to + end). Rewind asks first and lists the files that will change. Afterwards a divider marks the conversation (*Rewound to here · 2 files restored*) and the notification offers **Undo**, which puts the files back as they were just before the rewind @@ -792,6 +857,39 @@ and `.env` files suggest. Nothing runs until you save it. again, unless you closed it yourself (`Ctrl+B`). - `Alt+↑` / `Alt+↓` open the previous or next conversation in the list; `Ctrl+Tab` goes back to the one you had open before. +- **Search tasks** finds words in conversation titles, in your messages and + in the agent's answers. When the title doesn't show the words, the result + shows the message or answer that matched, with the words highlighted. +- The page title names the open conversation (*Fix the login form — + ShadowCode*), with a dot in front while a task runs in it, so a browser + tab on a paired device (see [Use it from your phone](#use-it-from-your-phone)) + says which conversation it shows. + +## Appearance and text size + +**Settings › Appearance** chooses the theme (*System* follows your +computer's light or dark setting) and the text size, from 80% to 175%. Both +change as you pick them, so you see the result before you keep it: **Save** +keeps them, and leaving the page or Settings without saving puts back what +you had. **Reset to 100%** goes back to the normal size. + +A phone or browser using remote access keeps its own text size, starting at +100%: changing it there never changes the desktop's, and the desktop's never +changes it. + +Anywhere in the window: + +- `Ctrl+=` (or `Ctrl++`) makes everything larger, `Ctrl+-` smaller, and + `Ctrl+0` goes back to 100%. The new size shows for a moment at the top + right and is saved straight away. +- Inside a terminal these keys belong to the shell, and in a dialog to the + dialog. +- The command palette (`Ctrl+K`) has *Theme: same as the system*, *Theme: + light* and *Theme: dark*, and *Zoom in*, *Zoom out* and *Reset zoom to + 100%*. + +The keyboard shortcuts (`?`) list these keys too. The theme and size are +part of your settings, so a browser using remote access gets them as well. ## Notifications @@ -841,6 +939,46 @@ from the model's prices; models on this computer show `$0 · local`. Click it for input, output and cached tokens, cost, model requests and the last compaction. +## Agent settings + +**Settings › Agent** changes how ShadowCode's own agent works with models on +this computer, API and OpenRouter models, without editing `config.yaml`. +Each setting says what it does, its range and its default. **Reset** puts +one setting back and **Reset all to defaults** the whole page; nothing +changes until you **Save**. A value out of range is marked under its field +and Save waits until it's fixed. If ShadowCode still refuses a save, the +reason is shown above Save. + +- **Limits.** Steps per task (64 by default), the token budget per task + (1,000,000), the autonomy profile, how long one tool call may run (60 + seconds), how much of a tool's output the agent reads (256,000 bytes), and + whether read-only tools of one turn run together. A profile can lower the + token budget, never raise it: *Conservative* stops a task at 64,000 + tokens, *Normal* at 1,000,000 and *Extended* at 4,000,000; *Unlimited* and + *Custom* use the budget as set. +- **Long conversations.** When history is shortened (at 0.7, 70% of the + context), whether the model summarizes what was removed, and how long to + wait for that summary. +- **Recovery.** Provider retries and the wait before the first one, how many + times a task is sent back when it stopped without doing or checking the + work, and the pause when the agent seems stuck. +- **Subagents.** Whether the agent may start subagents, how many run at once + (4) and per task (16), how deep they nest (1: no grandchildren; 0 turns + them off), and the step limit of one whose definition sets none (24). +- **Checkpoints.** Checkpoints before shell commands and before subscription + turns (both on; Rewind needs them), how many are kept per repository + (200), and the largest folder without Git that is copied (5,000 files, + 64 MB). +- **Checks.** Commands that count as checks of a task's work when they run + exactly as written in the project folder, such as `cargo test --offline`. + They ask for approval like any command, and ShadowCode never runs them on + its own. See [verification receipts](VERIFICATION_RECEIPTS.md). + +The page saves only the settings you changed. Subscriptions use only its +checkpoint settings. A trusted project can change Limits, Long conversations +and Recovery for itself in `.shadow/config/config.yaml` (the `agent` group). +To edit by hand, every key is in [config.example.yaml](../config.example.yaml). + ## Code intelligence With a local or OpenRouter model, ShadowCode helps the agent understand the @@ -1014,6 +1152,13 @@ Subscription CLIs use their own sandboxes, not this one. starts a new conversation at a response. A very long page shows its latest 150 items first; **Show … earlier items** adds more without moving what you are reading. +- **Find in conversation** (`Ctrl+F`, or *Find in conversation* in the + `Ctrl+K` command palette) searches the whole conversation's prompts, + answers and notes, older messages included, ignoring case, and shows + *3 of 12*. `Enter` / `Shift+Enter` (or `F3` / `Shift+F3`) go to the next or + previous match, opening older messages and earlier items as needed; every + match on screen is highlighted. `Esc` closes it. In a file editor or the + terminal, `Ctrl+F` stays theirs. - **Interrupted tasks.** If ShadowCode exits during a task, the task is marked *interrupted*. **Continue task** writes a recovery request for you. Shell commands and file edits are never replayed automatically. @@ -1030,7 +1175,8 @@ Subscription CLIs use their own sandboxes, not this one. stays in the conversation, so later messages start from it. For models with less than 8K of context, or when the summary fails or takes longer than a minute, a built-in digest is used instead. The full history always stays in - the app. Turn summaries off with `agent.summary_compaction: false`. + the app. Turn summaries off in **Settings › Agent** + (`agent.summary_compaction: false`). - **Shorten on purpose, keep what matters.** Type `/compact` to shorten the conversation when you send your next message, however long it is: every earlier step except the latest answer becomes the summary. Type @@ -1045,7 +1191,7 @@ Subscription CLIs use their own sandboxes, not this one. files and **Only change these**) or **Stop**. Tasks nobody can answer the card for (automations, subagents, and tasks started from an editor or the terminal UI) don't pause: the agent is told to try another way. Turn it - off with `agent.stuck_check: false`. + off in **Settings › Agent** (`agent.stuck_check: false`). - **Heads-ups when a task finishes.** If a task skipped or deleted tests, removed assertions, changed CI or hook settings, switched off a lint or type check in the code, or rewrote test snapshots, the conversation says @@ -1058,15 +1204,17 @@ Subscription CLIs use their own sandboxes, not this one. about file by file, so the conversation names any other file a command changed. Subscriptions can't be stopped mid-turn, so after each turn the conversation names any file they changed elsewhere; **Review** can undo - it. This check needs project checkpoints (`checkpoints.shell` for - commands, `checkpoints.vendor` for subscriptions, both on by default). + it. This check needs project checkpoints (**Settings › Agent › + Checkpoints**: `checkpoints.shell` for commands, `checkpoints.vendor` for + subscriptions, both on by default). Without one, for example in a large folder that isn't under Git, the conversation says the commands or the turn weren't checked. - **Busy or dropped providers.** A request that fails with a rate limit, an overloaded or failing provider, or a connection that drops mid-answer is - sent again, up to three times (`agent.model_retries`), waiting longer each - time or as long as the provider asks. The conversation shows it, for - example *Provider busy, retrying (2 of 5) in 4 s…*, and then *The provider + sent again, up to three times (**Provider retries** in **Settings › + Agent**, `agent.model_retries`), waiting longer each time or as long as + the provider asks. The conversation shows it, for + example *Provider busy, retrying (2 of 3) in 4 s…*, and then *The provider was busy; it answered after 2 retries.* A reply that was cut off is thrown away and replaced; tools only run after a complete reply, so nothing runs twice. Errors such as a wrong key or an unknown model are shown at once. If diff --git a/docs/VERIFICATION_RECEIPTS.md b/docs/VERIFICATION_RECEIPTS.md index 0ce893ca..a4ea81ea 100644 --- a/docs/VERIFICATION_RECEIPTS.md +++ b/docs/VERIFICATION_RECEIPTS.md @@ -15,7 +15,8 @@ originating project/conversation prevents stale submission or attachment. A command is a configured check only when it is launched through the existing explicit test-task action, exactly matches a command discovered from project manifests in the project root, or exactly matches a user-configured `verification.commands` entry in application configuration. Execution still uses the existing trust, shell approval, sandbox, and lifecycle-hook path. Merely containing `test`, `lint`, or a runner name is not sufficient. Options and subdirectory commands that do not exactly match remain ordinary execution evidence; they can be run explicitly as checks through the test-task action. -The optional application configuration field is: +The optional application configuration field is edited under **Checks** in +**Settings › Agent**, or by hand: ```yaml verification: diff --git a/native/core/src/config.rs b/native/core/src/config.rs index 53c905a3..1df33f94 100644 --- a/native/core/src/config.rs +++ b/native/core/src/config.rs @@ -337,7 +337,7 @@ impl Default for Config { // Every key the app reads from the untyped groups has its default // here, so `shadowcode config KEY VALUE` can set it and // config.example.yaml documents it (tests/config_keys.rs). - ui: json!({"theme":"light","notify":true,"notify_approval":true,"notify_failed":true,"notify_limit":true,"notify_finished":true,"notify_sound":false}), + ui: json!({"theme":"system","notify":true,"notify_approval":true,"notify_failed":true,"notify_limit":true,"notify_finished":true,"notify_sound":false}), onboarding: json!({"completed":false,"workspace":""}), routing: json!({"enabled":false,"planner":"","coder":"","reviewer":"","tester":"","architecture":"","small_edits":"","vision":"","local":"","suggest_from_history":false}), mcp: json!({"servers":[],"approved":[],"share_with_cli_agents":true}), @@ -606,6 +606,9 @@ impl Config { } pub fn patch(paths: &AppPaths, values: Value) -> Result { ensure!(values.is_object(), "Configuration patch must be an object"); + if let Some(subagents) = values.get("subagents") { + crate::subagents::validate_change(subagents)?; + } Self::update(paths, |config| { let mut current = serde_json::to_value(&*config)?; merge(&mut current, values); diff --git a/native/core/src/control.rs b/native/core/src/control.rs index dfb75d54..ca4a5089 100644 --- a/native/core/src/control.rs +++ b/native/core/src/control.rs @@ -303,10 +303,12 @@ pub fn sweep_stale_sockets(directory: &Path, keep: &Path, min_age: Duration) -> let Ok(before) = fs::symlink_metadata(&path) else { continue; }; + // A time just ahead of `now` (clock steps, timestamp granularity) + // counts as age zero rather than as never old enough. let old_enough = before .modified() .ok() - .and_then(|m| now.duration_since(m).ok()) + .map(|m| now.duration_since(m).unwrap_or(Duration::ZERO)) .is_some_and(|age| age >= min_age); if !before.file_type().is_socket() || before.uid() != uid() || !old_enough { continue; @@ -588,6 +590,13 @@ mod tests { drop(StdListener::bind(&foreign_name).unwrap()); let _live = StdListener::bind(&live).unwrap(); fs::write(¬_socket, b"x").unwrap(); + // Another test forking at the moment of bind can hold a copy of a + // listener until its child execs; wait until both are really closed. + let started = std::time::Instant::now(); + while !(socket_refuses(&dead) && socket_refuses(&own)) { + assert!(started.elapsed() < Duration::from_secs(10)); + std::thread::sleep(Duration::from_millis(20)); + } // Young sockets are left alone (an engine may be between bind and listen). assert!(sweep_stale_sockets(dir.path(), &own, Duration::from_secs(3600)).is_empty()); @@ -605,4 +614,47 @@ mod tests { assert!(socket_refuses(&own)); assert!(!socket_refuses(&live)); } + + #[test] + fn a_socket_dated_ahead_of_the_clock_counts_as_age_zero() { + use std::os::unix::ffi::OsStrExt; + let dir = tempfile::tempdir().unwrap(); + let dead = dir.path().join(format!("{}.sock", "a".repeat(32))); + let own = dir.path().join(format!("{}.sock", "c".repeat(32))); + drop(StdListener::bind(&dead).unwrap()); + let started = std::time::Instant::now(); + while !socket_refuses(&dead) { + assert!(started.elapsed() < Duration::from_secs(10)); + std::thread::sleep(Duration::from_millis(20)); + } + // Date the dead socket a minute ahead of now. + let ahead = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + + Duration::from_secs(60); + let stamp = libc::timespec { + tv_sec: ahead.as_secs() as libc::time_t, + tv_nsec: 0, + }; + let path = std::ffi::CString::new(dead.as_os_str().as_bytes()).unwrap(); + // SAFETY: a valid C path and two initialised timespecs. + let set = unsafe { + libc::utimensat( + libc::AT_FDCWD, + path.as_ptr(), + [stamp, stamp].as_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + assert_eq!(set, 0, "{}", std::io::Error::last_os_error()); + + // Any real minimum age still leaves it alone; zero sweeps it. + assert!(sweep_stale_sockets(dir.path(), &own, Duration::from_secs(1)).is_empty()); + assert!(dead.exists()); + assert_eq!( + sweep_stale_sockets(dir.path(), &own, Duration::ZERO), + vec![dead.clone()] + ); + assert!(!dead.exists()); + } } diff --git a/native/core/src/endpoints.rs b/native/core/src/endpoints.rs new file mode 100644 index 00000000..1506ce81 --- /dev/null +++ b/native/core/src/endpoints.rs @@ -0,0 +1,336 @@ +//! The user's own OpenAI-compatible endpoints (Ollama, LM Studio, a llama.cpp +//! or vLLM server, OpenAI, xAI or any compatible API) as composer picker +//! rows. They are the registry models the user added in Settings › Accounts +//! (`POST /api/models/endpoints` marks them `added_in: "settings"`). Other +//! registry rows stay out: the `model:` fallback in config.yaml, which the +//! engine registers on its own, and models a detection probe merely found. +//! Tasks on these rows run ShadowCode's own agent loop through +//! `model_registry::resolve`, like any registered model. +use crate::{ + config::{runs_on_this_computer, ModelConfig}, + model_registry, models, +}; +use serde_json::{json, Value}; + +/// Providers that are plain OpenAI-compatible servers (`models::preset`). +pub const PROVIDERS: &[&str] = &[ + "ollama", + "local", + "llamacpp", + "vllm", + "openai_compatible", + "grok", +]; + +/// The `metadata.added_in` of models added in Settings. +pub const ADDED_IN: &str = "settings"; + +/// A registry row the user added for a compatible endpoint in Settings: a +/// known provider and an address (the managed local runtime has none). +pub fn is_endpoint_row(row: &Value) -> bool { + PROVIDERS.contains(&row["provider"].as_str().unwrap_or("")) + && !row["endpoint"].as_str().unwrap_or("").is_empty() + && row.pointer("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/metadata/added_in") == Some(&json!(ADDED_IN)) +} + +/// "127.0.0.1:11434", "api.openai.com": where the row sends its requests. +pub fn host(endpoint: &str) -> String { + reqwest::Url::parse(endpoint) + .ok() + .and_then(|url| { + let host = url.host_str()?.to_owned(); + Some(match url.port() { + Some(port) => format!("{host}:{port}"), + None => host, + }) + }) + .unwrap_or_else(|| endpoint.to_owned()) +} + +/// The name a new endpoint's key is saved under. Two different services +/// must not overwrite each other's key, so a generic compatible API on a +/// named host gets a name of its own ("api.deepseek.com" → DEEPSEEK_API_KEY; +/// "api.openai.com" keeps the familiar OPENAI_API_KEY). Presets and +/// addresses without a domain name use the preset's name. +pub fn key_name(provider: &str, endpoint: &str) -> String { + let preset = models::preset(provider)["api_key_env"] + .as_str() + .unwrap_or("OPENAI_API_KEY") + .to_owned(); + if provider != "openai_compatible" { + return preset; + } + let Some(host) = reqwest::Url::parse(endpoint) + .ok() + .and_then(|url| url.domain().map(str::to_owned)) + else { + return preset; + }; + let labels: Vec<&str> = host.split('.').filter(|l| !l.is_empty()).collect(); + if labels.len() < 2 { + return preset; + } + // The label before the public suffix, also for "example.co.uk". + let mut index = labels.len() - 2; + if index > 0 && matches!(labels[index], "co" | "com" | "net" | "org" | "ac" | "gov") { + index -= 1; + } + let name: String = labels[index] + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_uppercase() + } else { + '_' + } + }) + .collect(); + if name.is_empty() || !name.starts_with(|c: char| c.is_ascii_alphabetic()) { + return preset; + } + format!("{name}_API_KEY") +} + +/// Picker rows for the user's endpoints. Rows on this computer join the +/// local group; every other row is an API row, billed by its provider and +/// off in offline mode. `has_key` says whether a key name has a saved value. +pub fn picker_rows( + registry: &[Value], + default_id: &str, + offline: bool, + has_key: impl Fn(&str) -> bool, +) -> Vec { + registry + .iter() + .filter(|row| is_endpoint_row(row)) + .filter_map(|row| Some((row, model_registry::from_row(row).ok()?))) + .map(|(row, model)| picker_row(row, &model, default_id, offline, &has_key)) + .collect() +} + +fn picker_row( + row: &Value, + model: &ModelConfig, + default_id: &str, + offline: bool, + has_key: &impl Fn(&str) -> bool, +) -> Value { + let local = runs_on_this_computer(model); + let label = models::preset(&model.provider)["label"] + .as_str() + .unwrap_or("OpenAI-compatible") + .to_owned(); + let host = host(&model.endpoint); + let key_required = row.pointer("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/metadata/key_required") == Some(&json!(true)); + let (availability, availability_label, reason) = if offline && !local { + ( + "unavailable", + "Unavailable", + "Offline mode: only models on this computer run".to_owned(), + ) + } else if key_required && !has_key(&model.api_key_env) { + ( + "sign_in", + "Add API key", + format!("Add the API key for {host} in Settings › Accounts"), + ) + } else { + ("ready", "Ready", String::new()) + }; + let usage = if local { + json!(crate::cli_agent::usage::UsageSnapshot::local()) + } else { + json!({ + "state": "api_key", + "label": "Your endpoint · billed by the provider", + "detail": [ + format!("Tasks go to {host} with your key; the provider bills each request."), + "ShadowCode shows a cost only when the server reports one.", + format!("Context window: {} tokens", model.context_limit), + ], + "plan": null, + "pool": null, + "pool_shared": false, + "windows": [], + "remaining_percent": null, + "credits": null, + "limit_reached": false, + "last_refresh": null, + "provider_usage_url": null, + }) + }; + let capabilities = &row["metadata"]["capabilities"]; + json!({ + "id": model.default, + "provider": model.provider, + "account": "", + "model": model.name, + "route": "native", + "group": if local { crate::cli_agent::picker::GROUP_LOCAL } else { "api" }, + "name": if local { format!("{} · This computer", model.name) } else { model.name.clone() }, + "subtitle": format!("{label} · {host}"), + "inference": if local { "local" } else { "cloud" }, + "availability": availability, + "availability_label": availability_label, + "reason": reason, + "featured": false, + "vision": capabilities["vision"] == true, + "tools": capabilities["tools"] != false, + "is_default": model.default == default_id, + "usage": usage, + "endpoint": model.endpoint, + "custom": true, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(provider: &str, endpoint: &str, metadata: Value) -> Value { + let name = "qwen3-coder"; + json!({ + "id": model_registry::model_id(provider, endpoint, name), + "name": name, + "provider": provider, + "endpoint": endpoint, + "context_limit": 32768, + "metadata": metadata, + }) + } + + #[test] + fn only_rows_the_user_added_are_endpoints() { + let added = json!({"api_key_env": "OLLAMA_API_KEY", "added_in": "settings"}); + assert!(is_endpoint_row(&row( + "ollama", + "http://127.0.0.1:11434/v1", + added.clone() + ))); + // Found by detection only, or registered by the engine itself (the + // config.yaml fallback): not the user's endpoint rows. + assert!(!is_endpoint_row(&row( + "ollama", + "http://127.0.0.1:11434/v1", + json!({"detected": true}) + ))); + assert!(!is_endpoint_row(&row( + "local", + "http://127.0.0.1:1234/v1", + json!({"api_key_env": "OPENAI_API_KEY", "keep_alive": "30m"}) + ))); + // The managed runtime and other row kinds are listed elsewhere. + assert!(!is_endpoint_row(&row("llamacpp", "", added.clone()))); + assert!(!is_endpoint_row(&row( + "openrouter", + "https://openrouter.ai/api/v1", + added.clone() + ))); + assert!(!is_endpoint_row(&row("mock", "", added))); + } + + #[test] + fn rows_on_this_computer_are_local_and_others_are_api_rows() { + let rows = [ + row( + "ollama", + "http://127.0.0.1:11434/v1", + json!({"api_key_env": "OLLAMA_API_KEY", "added_in": "settings"}), + ), + row( + "openai_compatible", + "https://api.deepseek.com/v1", + json!({"api_key_env": "DEEPSEEK_API_KEY", "added_in": "settings", "key_required": true, "capabilities": {"vision": true}}), + ), + ]; + let picked = picker_rows(&rows, "", false, |_| true); + assert_eq!(picked.len(), 2); + assert_eq!(picked[0]["group"], "local"); + assert_eq!(picked[0]["inference"], "local"); + assert_eq!(picked[0]["name"], "qwen3-coder · This computer"); + assert_eq!(picked[0]["subtitle"], "Ollama · 127.0.0.1:11434"); + assert_eq!(picked[0]["usage"]["state"], "local"); + assert_eq!(picked[0]["availability"], "ready"); + assert_eq!(picked[1]["group"], "api"); + assert_eq!(picked[1]["inference"], "cloud"); + assert_eq!( + picked[1]["subtitle"], + "OpenAI-compatible · api.deepseek.com" + ); + assert_eq!(picked[1]["usage"]["state"], "api_key"); + assert_eq!(picked[1]["vision"], true); + assert_eq!(picked[1]["tools"], true); + assert_eq!(picked[1]["custom"], true); + // The row id is the registry id tasks resolve. + assert_eq!(picked[1]["id"], rows[1]["id"]); + } + + #[test] + fn offline_and_missing_keys_block_only_what_they_must() { + let rows = [ + row( + "ollama", + "http://127.0.0.1:11434/v1", + json!({"api_key_env": "OLLAMA_API_KEY", "added_in": "settings"}), + ), + row( + "grok", + "https://api.x.ai/v1", + json!({"api_key_env": "XAI_API_KEY", "added_in": "settings", "key_required": true}), + ), + ]; + let offline = picker_rows(&rows, "", true, |_| true); + assert_eq!(offline[0]["availability"], "ready"); + assert_eq!(offline[1]["availability"], "unavailable"); + let no_key = picker_rows(&rows, "", false, |_| false); + assert_eq!(no_key[0]["availability"], "ready"); + assert_eq!(no_key[1]["availability"], "sign_in"); + assert!(no_key[1]["reason"].as_str().unwrap().contains("api.x.ai")); + } + + #[test] + fn each_named_service_keeps_its_own_key() { + assert_eq!( + key_name("openai_compatible", "https://api.openai.com/v1"), + "OPENAI_API_KEY" + ); + assert_eq!( + key_name("openai_compatible", "https://api.deepseek.com/v1"), + "DEEPSEEK_API_KEY" + ); + assert_eq!( + key_name("openai_compatible", "https://api.groq.com/openai/v1"), + "GROQ_API_KEY" + ); + assert_eq!( + key_name("openai_compatible", "https://llm.example.co.uk/v1"), + "EXAMPLE_API_KEY" + ); + assert_eq!( + key_name("openai_compatible", "https://my-llm.dev/v1"), + "MY_LLM_API_KEY" + ); + // No domain name: the preset's name. + assert_eq!( + key_name("openai_compatible", "http://192.168.1.5:8000/v1"), + "OPENAI_API_KEY" + ); + assert_eq!(key_name("grok", "https://api.x.ai/v1"), "XAI_API_KEY"); + assert_eq!( + key_name("ollama", "http://127.0.0.1:11434/v1"), + "OLLAMA_API_KEY" + ); + for name in [ + key_name("openai_compatible", "https://api.deepseek.com/v1"), + key_name("openai_compatible", "https://my-llm.dev/v1"), + ] { + assert!(crate::config::valid_secret_name(&name), "{name}"); + } + } + + #[test] + fn hosts_name_the_server() { + assert_eq!(host("http://127.0.0.1:11434/v1"), "127.0.0.1:11434"); + assert_eq!(host("https://api.openai.com/v1"), "api.openai.com"); + } +} diff --git a/native/core/src/engine/spending.rs b/native/core/src/engine/spending.rs index f9068426..48c06c26 100644 --- a/native/core/src/engine/spending.rs +++ b/native/core/src/engine/spending.rs @@ -50,6 +50,10 @@ impl Engine { Check::Reached(kind, limit) => (kind, limit), }; if meter.stopped().is_some() { + // Stop ends the task as cancelled. The answer wakes this gate + // before `decide_spending` cancels the task, so cancel here + // too rather than finish as a failure. + running.cancel.cancel(); bail!("{}", crate::spending::stopped_summary(kind)); } if meter.is_unattended() { diff --git a/native/core/src/lib.rs b/native/core/src/lib.rs index 95d10eba..af330c52 100644 --- a/native/core/src/lib.rs +++ b/native/core/src/lib.rs @@ -21,6 +21,7 @@ pub mod context; pub mod control; pub mod data; pub mod effort; +pub mod endpoints; pub mod engine; pub mod events; pub mod flatpak; diff --git a/native/core/src/models.rs b/native/core/src/models.rs index f23a0fca..38b0d4e4 100644 --- a/native/core/src/models.rs +++ b/native/core/src/models.rs @@ -1276,64 +1276,159 @@ pub fn presets() -> Vec { .collect() } -pub async fn detect() -> Vec { - let client = reqwest::Client::builder() - .timeout(Duration::from_millis(1200)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .expect("TLS client"); - futures_util::future::join_all(["ollama","local","llamacpp","vllm"].into_iter().map(|provider|{ - let client=client.clone();async move { - let preset=preset(provider);let base=preset["endpoint"].as_str().unwrap_or(""); - let url=if provider=="ollama"{format!("{}/api/tags",base.trim_end_matches("/v1"))}else{format!("{base}/models")}; - let start=std::time::Instant::now(); - let result: Result = async { - let response = client.get(url).send().await?.error_for_status()?; - let mut stream = response.bytes_stream(); - let mut bytes = Vec::new(); - while let Some(chunk) = stream.next().await { - let chunk = chunk?; - ensure!(bytes.len() + chunk.len() <= 2_000_000, "Model list exceeded 2 MB"); - bytes.extend_from_slice(&chunk); +/// The base URL a compatible server is called at: trimmed, without a +/// trailing `/`, and with `/v1` added when only a host was given (Ollama, +/// LM Studio, llama.cpp, vLLM, OpenAI and xAI all serve the API there). +/// Addresses with a user name or password, a query or a fragment are refused +/// so a key never ends up in `config.yaml` or the model registry. +pub fn normalize_endpoint(endpoint: &str) -> Result { + let trimmed = endpoint.trim(); + let mut url = reqwest::Url::parse(trimmed).with_context(|| { + format!("Enter a web address such as http://127.0.0.1:11434/v1; found {trimmed:?}") + })?; + ensure!( + matches!(url.scheme(), "http" | "https") && url.host_str().is_some(), + "The address must start with http:// or https://" + ); + ensure!( + url.username().is_empty() && url.password().is_none(), + "Remove the user name or password from the address; enter the API key in its own field" + ); + ensure!( + url.query().is_none() && url.fragment().is_none(), + "Remove the ?query or #fragment from the address" + ); + if matches!(url.path(), "" | "/") { + url.set_path("/v1"); + } + Ok(url.to_string().trim_end_matches('/').to_owned()) +} + +/// Where a server lists its models: Ollama's own `/api/tags` (with sizes and +/// details) beside its `/v1` API, every other server the OpenAI-style +/// `/models`. +fn models_url(provider: &str, base: &str) -> String { + let base = base.trim_end_matches('/'); + if provider == "ollama" { + format!("{}/api/tags", base.trim_end_matches("/v1")) + } else { + format!("{base}/models") + } +} + +/// The models one compatible server offers, from its model list (at most +/// 2 MB). `key` is sent as a bearer token when the server needs one. +pub async fn list_models( + client: &reqwest::Client, + provider: &str, + base: &str, + key: Option<&str>, +) -> Result> { + let mut request = client.get(models_url(provider, base)); + if let Some(key) = key.filter(|key| !key.is_empty()) { + request = request.bearer_auth(key); + } + let response = request.send().await.map_err(|error| { + anyhow::anyhow!( + "Could not reach {base}: {}", + if error.is_timeout() { + "it did not answer in time".to_owned() + } else if error.is_connect() { + "nothing is listening there, or the network refused the connection".to_owned() + } else { + error.without_url().to_string() + } + ) + })?; + let status = response.status(); + if !status.is_success() { + bail!( + "{base} answered {}{}", + status.as_u16(), + http_hint(status.as_u16(), is_loopback_endpoint(base)) + ); + } + let mut stream = response.bytes_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + ensure!( + bytes.len() + chunk.len() <= 2_000_000, + "Model list exceeded 2 MB" + ); + bytes.extend_from_slice(&chunk); + } + let value: Value = serde_json::from_slice(&bytes).context("The model list was not JSON")?; + let empty = Vec::new(); + let rows = value[if provider == "ollama" { + "models" + } else { + "data" + }] + .as_array() + .unwrap_or(&empty); + Ok(rows + .iter() + .filter_map(|m| { + let name = if provider == "ollama" { + m["name"].as_str() + } else { + m["id"].as_str() + }?; + let mut caps = serde_json::Map::new(); + caps.insert("completion".into(), Value::Bool(true)); + if let Some(list) = m.get("capabilities").and_then(Value::as_array) { + for item in list { + if let Some(s) = item.as_str() { + caps.insert(s.to_string(), Value::Bool(true)); + } } - Ok(serde_json::from_slice(&bytes)?) - }.await; + } + Some(json!({ + "id": name, + "name": name, + "size_bytes": m["size"].as_u64().unwrap_or(0), + "context_limit": m.pointer("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/details/context_length").and_then(Value::as_u64).unwrap_or(0), + "capabilities": Value::Object(caps), + "detail": m.pointer("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/details/parameter_size").and_then(Value::as_str).unwrap_or("") + })) + }) + .collect()) +} + +/// A client for model lists: short timeouts, no redirects, and no proxy for +/// servers on this computer. +pub fn list_client(base: &str, timeout: Duration) -> Result { + let mut builder = reqwest::Client::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::none()); + if is_loopback_endpoint(base) { + builder = builder.no_proxy(); + } + Ok(builder.build()?) +} + +pub async fn detect() -> Vec { + futures_util::future::join_all(["ollama", "local", "llamacpp", "vllm"].into_iter().map( + |provider| async move { + let preset = preset(provider); + let base = preset["endpoint"].as_str().unwrap_or(""); + let start = std::time::Instant::now(); + let result = match list_client(base, Duration::from_millis(1200)) { + Ok(client) => list_models(&client, provider, base, None).await, + Err(error) => Err(error), + }; match result { - Ok(value)=>{ - let empty=Vec::new();let rows=value[if provider=="ollama"{"models"}else{"data"}].as_array().unwrap_or(&empty); - let models: Vec<_> = rows - .iter() - .filter_map(|m| { - let name = if provider == "ollama" { - m["name"].as_str() - } else { - m["id"].as_str() - }?; - let mut caps = serde_json::Map::new(); - caps.insert("completion".into(), Value::Bool(true)); - if let Some(list) = m.get("capabilities").and_then(Value::as_array) { - for item in list { - if let Some(s) = item.as_str() { - caps.insert(s.to_string(), Value::Bool(true)); - } - } - } - Some(json!({ - "id": name, - "name": name, - "size_bytes": m["size"].as_u64().unwrap_or(0), - "context_limit": m.pointer("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/details/context_length").and_then(Value::as_u64).unwrap_or(0), - "capabilities": Value::Object(caps), - "detail": m.pointer("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/details/parameter_size").and_then(Value::as_str).unwrap_or("") - })) - }) - .collect(); + Ok(models) => { json!({"provider":provider,"label":preset["label"],"endpoint":base,"running":true,"latency_ms":start.elapsed().as_millis(),"detail":format!("{} models available",models.len()),"models":models}) } - Err(_)=>json!({"provider":provider,"label":preset["label"],"endpoint":base,"running":false,"latency_ms":start.elapsed().as_millis(),"models":[],"detail":"Not reachable"}), + Err(_) => { + json!({"provider":provider,"label":preset["label"],"endpoint":base,"running":false,"latency_ms":start.elapsed().as_millis(),"models":[],"detail":"Not reachable"}) + } } - } - })).await + }, + )) + .await } #[cfg(test)] @@ -1368,6 +1463,34 @@ mod tests { .contains(&key)); } + #[test] + fn endpoints_are_normalized_and_secrets_stay_out_of_them() { + assert_eq!( + normalize_endpoint(" http://127.0.0.1:11434 ").unwrap(), + "http://127.0.0.1:11434/v1" + ); + assert_eq!( + normalize_endpoint("https://api.openai.com/v1/").unwrap(), + "https://api.openai.com/v1" + ); + assert_eq!( + normalize_endpoint("https://api.groq.com/openai/v1").unwrap(), + "https://api.groq.com/openai/v1" + ); + assert!(normalize_endpoint("api.openai.com").is_err()); + assert!(normalize_endpoint("ftp://example.com").is_err()); + assert!(normalize_endpoint("https://me:secret@example.com/v1").is_err()); + assert!(normalize_endpoint("https://example.com/v1?key=secret").is_err()); + assert_eq!( + models_url("ollama", "http://127.0.0.1:11434/v1"), + "http://127.0.0.1:11434/api/tags" + ); + assert_eq!( + models_url("vllm", "http://127.0.0.1:8000/v1/"), + "http://127.0.0.1:8000/v1/models" + ); + } + #[test] fn a_local_server_error_is_named_as_one() { // A local runtime's 500 (a template or tool-call exception) is not diff --git a/native/core/src/service/model_catalog.rs b/native/core/src/service/model_catalog.rs index d31642e2..9a9e3f32 100644 --- a/native/core/src/service/model_catalog.rs +++ b/native/core/src/service/model_catalog.rs @@ -109,6 +109,7 @@ impl Service { } ("GET", "/api/models/outcomes") => self.model_outcomes(call).await, ("POST", "/api/models/test") => self.test_model(&call.body).await, + ("POST", "/api/providers/models") => self.endpoint_models(&call.body).await, _ => self.blocking(call, Self::model_routes_sync).await, } } @@ -160,6 +161,8 @@ impl Service { let cfg = self.config()?; Ok(json!({"ok":true,"local_engine":local_catalog(&cfg)})) } + ("POST", "/api/models/endpoints") => self.add_endpoint_models(&call.body), + ("POST", "/api/models/remove") => self.remove_endpoint_model(body.id.as_str()), ("POST", "/api/models/register" | "/api/models/select") => { let cfg = self.config()?; let model = if !call.text("provider").is_empty() { @@ -323,6 +326,21 @@ impl Service { key_set, offline, )); + // The user's own compatible endpoints. Each key name is looked up + // once, since a saved key may live in the system keyring. + let registry = model_registry::catalog(&self.engine.store(), &cfg.model)?; + let keys = std::cell::RefCell::new(HashMap::::new()); + targets.extend(crate::endpoints::picker_rows( + ®istry, + &cfg.model.default, + offline, + |name| { + *keys + .borrow_mut() + .entry(name.to_owned()) + .or_insert_with(|| matches!(config::secret(paths, name), Ok(Some(_)))) + }, + )); // Project last: provider observations may change while the local or // OpenRouter catalog is being read. Both vendor views must agree, and // an obsolete per-probe return must not overwrite a newer snapshot. @@ -343,6 +361,142 @@ impl Service { "generated_at": crate::now(), })) } + /// The provider, normalized address and key name of a request for one + /// of the user's compatible endpoints. + fn endpoint_target(&self, body: &Value) -> Result<(String, String, String)> { + let text = |key: &str| body[key].as_str().unwrap_or("").trim(); + let provider = text("provider"); + ensure!( + crate::endpoints::PROVIDERS.contains(&provider), + "Choose a provider: Ollama, LM Studio, llama.cpp, vLLM, xAI or OpenAI-compatible" + ); + let preset = models::preset(provider); + let typed = text("endpoint"); + let endpoint = models::normalize_endpoint(if typed.is_empty() { + preset["endpoint"].as_str().unwrap_or("") + } else { + typed + })?; + let key_env = match text("api_key_env") { + "" => crate::endpoints::key_name(provider, &endpoint), + name => name.to_owned(), + }; + ensure!( + config::valid_secret_name(&key_env), + "The key name must be letters, digits and _, such as OPENAI_API_KEY" + ); + Ok((provider.to_owned(), endpoint, key_env)) + } + /// POST /api/providers/models {provider, endpoint?, api_key?, + /// api_key_env?}: the models one compatible server offers. Nothing is + /// saved. The key typed in the form is used, else (on this computer's + /// window only) the one saved under the key name, so a remote device + /// never sends a saved key to an address it chose. + async fn endpoint_models(&self, body: &Value) -> Result { + let (provider, endpoint, key_env) = self.endpoint_target(body)?; + let local = models::is_loopback_endpoint(&endpoint); + ensure!( + local || !self.config()?.offline(), + "Offline mode: only servers on this computer can be reached" + ); + let saved = config::secret(self.engine.paths(), &key_env)?; + let key = match body["api_key"].as_str().map(str::trim) { + Some(typed) if !typed.is_empty() => Some(typed.to_owned()), + _ if self.remote_device => None, + _ => saved.clone(), + }; + let client = models::list_client(&endpoint, Duration::from_secs(10))?; + let models = models::list_models(&client, &provider, &endpoint, key.as_deref()).await?; + Ok(json!({ + "ok": true, + "provider": provider, + "label": models::preset(&provider)["label"], + "endpoint": endpoint, + "api_key_env": key_env, + "key_saved": saved.is_some(), + "local": local, + "models": models, + })) + } + /// POST /api/models/endpoints {provider, endpoint?, models: [name], + /// api_key?, api_key_env?, context_limit?, vision?}: adds models of one + /// compatible server to the registry, so the picker lists them, and + /// saves a typed key under `api_key_env`. Answers the added models. + fn add_endpoint_models(&self, body: &Value) -> Result { + let (provider, endpoint, key_env) = self.endpoint_target(body)?; + let mut names: Vec = Vec::new(); + for name in body["models"].as_array().into_iter().flatten() { + let name = name.as_str().unwrap_or("").trim(); + if !name.is_empty() && !names.iter().any(|seen| seen == name) { + names.push(name.to_owned()); + } + } + ensure!(!names.is_empty(), "Choose at least one model"); + ensure!(names.len() <= 100, "Add at most 100 models at a time"); + let paths = self.engine.paths(); + let key = body["api_key"].as_str().unwrap_or("").trim(); + if !key.is_empty() { + config::set_secret(paths, &key_env, key)?; + } + // With a key the server needs one: the row asks for it if it is + // removed later. Without one (Ollama, a server on the LAN) it never does. + let key_required = config::secret(paths, &key_env)?.is_some(); + let context_limit = + model_registry::recommended_context(&provider, body["context_limit"].as_u64()); + let mut extra = + json!({"key_required": key_required, "added_in": crate::endpoints::ADDED_IN}); + if let Some(vision) = body["vision"].as_bool() { + extra["capabilities"] = json!({"completion": true, "vision": vision}); + } + let mut added = Vec::new(); + for name in names { + let model = ModelConfig { + default: model_registry::model_id(&provider, &endpoint, &name), + name, + provider: provider.clone(), + endpoint: endpoint.clone(), + api_key_env: key_env.clone(), + keep_alive: "30m".into(), + context_limit, + }; + model_registry::validate(&model)?; + self.check_model_identity(&model)?; + self.register_with(&model, &extra)?; + added.push(model); + } + Ok(json!({"ok": true, "models": added})) + } + /// POST /api/models/remove {id}: removes a model added for one of the + /// user's endpoints, with any older registry row for the same server and + /// model. A saved key stays (another model may use it). + fn remove_endpoint_model(&self, id: &str) -> Result { + let cfg = self.config()?; + ensure!( + id != cfg.model.default, + "This model is the default in config.yaml (model:); change that first" + ); + let store = self.engine.store(); + let rows = store.models()?; + let row = rows + .iter() + .find(|row| row["id"] == id) + .context("That model is not in the registry")?; + ensure!( + crate::endpoints::is_endpoint_row(row), + "Only models added for your own endpoints can be removed here" + ); + let target = model_registry::from_row(row)?; + let mut removed = 0; + for row in &rows { + let same = crate::endpoints::is_endpoint_row(row) + && model_registry::from_row(row) + .is_ok_and(|model| model_registry::same_target(&model, &target)); + if same && row["id"] != json!(cfg.model.default) { + removed += usize::from(store.delete_model(row["id"].as_str().unwrap_or(""))?); + } + } + Ok(json!({"ok": true, "removed": removed})) + } pub(super) fn model_from_body(&self, body: &Value, fallback: &ModelConfig) -> ModelConfig { let provider = body["provider"] .as_str() @@ -418,6 +572,11 @@ impl Service { } } pub(super) fn register(&self, model: &ModelConfig) -> Result<()> { + self.register_with(model, &json!({})) + } + /// `register`, with extra metadata fields (`extra`'s keys replace the + /// stored ones). + fn register_with(&self, model: &ModelConfig, extra: &Value) -> Result<()> { model_registry::validate(model)?; let store = self.engine.store(); let mut metadata = store @@ -433,6 +592,9 @@ impl Service { .unwrap_or(json!({})); metadata["api_key_env"] = json!(model.api_key_env); metadata["keep_alive"] = json!(model.keep_alive); + for (key, value) in extra.as_object().into_iter().flatten() { + metadata[key] = value.clone(); + } store.upsert_model(&json!({"id":model.default,"name":model.name,"provider":model.provider,"endpoint":model.endpoint,"context_limit":model.context_limit,"metadata":metadata})) } pub(super) fn check_model_identity(&self, model: &ModelConfig) -> Result<()> { diff --git a/native/core/src/service/settings.rs b/native/core/src/service/settings.rs index 0a132467..554fc925 100644 --- a/native/core/src/service/settings.rs +++ b/native/core/src/service/settings.rs @@ -51,7 +51,13 @@ impl Service { json!({"ok":true,"app":"ShadowCode","version":crate::VERSION,"workspace":self.workspace()?,"model":cfg.model,"onboarding":cfg.onboarding,"trusted":cfg.is_trusted(&self.workspace()?),"permissions":cfg.permissions,"provider":{"ok":cfg.model.provider!="mock","name":cfg.model.provider,"detail":provider_detail},"runtime":"rust","cli_agents":cfg.cli_agents}), ) } - ("GET", "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/api/config") => config_view(&self.config()?), + ("GET", "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/api/config") => { + let merged = self.config()?; + let profile = Config::load(self.engine.paths(), None)?; + let mut view = config_view(&merged)?; + agent_layers(&mut view, &profile, &merged); + Ok(view) + } ("PUT", "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/api/config") => self.save_config(call), ("GET", "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/api/routing") => { let cfg = self.config()?; @@ -121,6 +127,9 @@ impl Service { { values["permissions"]["approve_shell"] = json!(true); } + if let Some(subagents) = values.get("subagents") { + crate::subagents::validate_change(subagents)?; + } let cfg = Config::update(self.engine.paths(), |cfg| { // Settings use the provider's model name as `default`. Give // that configuration a stable identity before saving it. @@ -171,7 +180,9 @@ impl Service { .vendors() .configure(&cfg.cli_agents, cfg.offline()); self.register(&cfg.model)?; - config_view(&cfg) + let mut view = config_view(&cfg)?; + agent_layers(&mut view, &cfg, &self.config()?); + Ok(view) } /// POST /api/onboarding: save the first-run choices, trust the chosen /// folder and open a conversation in it. @@ -197,7 +208,7 @@ impl Service { }; cfg.permissions.approve_shell = true; cfg.ui["theme"] = json!(if body.theme.is_empty() { - "light" + "system" } else { body.theme.as_str() }); @@ -229,6 +240,24 @@ impl Service { /// GET/PUT /api/config response: the saved configuration plus what the /// permission modes mean for each vendor runtime. +/// Settings › Agent shows and saves the profile's `agent` values, while a +/// trusted project may change some of them for itself. `agent_profile` holds +/// the saved values and `agent_project` the keys the open project changes, +/// with the values it uses. +fn agent_layers(view: &mut Value, profile: &Config, merged: &Config) { + let saved = json!(profile.agent); + let used = json!(merged.agent); + let project: serde_json::Map = used + .as_object() + .into_iter() + .flatten() + .filter(|(key, value)| saved.get(key.as_str()) != Some(value)) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + view["agent_profile"] = saved; + view["agent_project"] = Value::Object(project); +} + pub(super) fn config_view(cfg: &Config) -> Result { let mut value = json!(cfg); value["permissions"]["vendor_notes"] = permissions::vendor_notes(cfg); diff --git a/native/core/src/store.rs b/native/core/src/store.rs index c8409a20..f918b195 100644 --- a/native/core/src/store.rs +++ b/native/core/src/store.rs @@ -662,11 +662,48 @@ impl Store { FROM sessions s WHERE (? IS NULL OR s.workspace=? OR EXISTS(SELECT 1 FROM session_meta w WHERE w.session_id=s.id AND w.key='worktree_source' AND w.value=?)) AND (s.title LIKE ? ESCAPE '!' OR s.workspace LIKE ? ESCAPE '!' - OR EXISTS(SELECT 1 FROM tasks t WHERE t.session_id=s.id AND t.prompt LIKE ? ESCAPE '!')) + OR EXISTS(SELECT 1 FROM tasks t WHERE t.session_id=s.id + AND (t.prompt LIKE ? ESCAPE '!' OR t.summary LIKE ? ESCAPE '!'))) AND (? OR NOT EXISTS(SELECT 1 FROM session_meta c WHERE c.session_id=s.id AND c.key='compare_id')) AND (? OR NOT EXISTS(SELECT 1 FROM session_meta a WHERE a.session_id=s.id AND a.key='subagent_parent')) AND NOT EXISTS(SELECT 1 FROM session_meta o WHERE o.session_id=s.id AND o.key='second_opinion') - ORDER BY s.updated_at DESC LIMIT ?", params![workspace.map(|p|p.to_string_lossy()),workspace.map(|p|p.to_string_lossy()),workspace.map(|p|p.to_string_lossy()),needle,needle,needle,include_compare,include_subagents,limit.clamp(1,10000)]) + ORDER BY s.updated_at DESC LIMIT ?", params![workspace.map(|p|p.to_string_lossy()),workspace.map(|p|p.to_string_lossy()),workspace.map(|p|p.to_string_lossy()),needle,needle,needle,needle,include_compare,include_subagents,limit.clamp(1,10000)]) + .and_then(|mut rows| { + let search = search.trim(); + if search.is_empty() { + return Ok(rows); + } + // Where a conversation matched, unless its title already shows + // it: the newest prompt or answer containing the words. + for row in &mut rows { + let title = row["title"].as_str().unwrap_or(""); + if find_ignoring_ascii_case(title, search).is_some() { + continue; + } + let sid = row["id"].as_str().unwrap_or("").to_owned(); + let hit = self.query( + "SELECT prompt,summary FROM tasks WHERE session_id=? + AND (prompt LIKE ? ESCAPE '!' OR summary LIKE ? ESCAPE '!') + ORDER BY created_at DESC LIMIT 1", + params![sid, needle, needle], + )?; + if let Some(task) = hit.first() { + let found = ["prompt", "summary"].into_iter().find_map(|field| { + let text = task[field].as_str()?; + Some((field, search_snippet(text, search)?)) + }); + if let Some((field, (snippet, start, end))) = found { + row["match"] = json!({ + "field": if field == "prompt" { "prompt" } else { "answer" }, + "snippet": snippet, + "start": start, + "end": end, + }); + } + } + } + Ok(rows) + }) } /// Move a conversation to another folder (a worktree task's conversation /// returns to its project when the worktree is removed). The vendor CLI @@ -1448,6 +1485,10 @@ impl Store { params![model["id"].as_str().context("Model ID required")?,model["name"].as_str().context("Model name required")?,model["provider"].as_str().context("Provider required")?,model["endpoint"].as_str().unwrap_or(""),model["context_limit"].as_u64().unwrap_or(128000),model.get("metadata").unwrap_or(&json!({})).to_string()])?; Ok(()) } + /// Removes one registry model; true when a row was deleted. + pub fn delete_model(&self, id: &str) -> Result { + Ok(self.execute("DELETE FROM models WHERE id=?", [id])? > 0) + } pub fn upsert_detected_model(&self, model: &Value) -> Result<()> { self.execute("INSERT INTO models(id,name,provider,endpoint,context_limit,metadata) VALUES(?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET context_limit=CASE WHEN json_extract(models.metadata,'$.api_key_env') IS NULL THEN excluded.context_limit ELSE models.context_limit END,metadata=json_patch(models.metadata,excluded.metadata) WHERE models.name=excluded.name AND models.provider=excluded.provider AND models.endpoint=excluded.endpoint", params![model["id"].as_str().context("Model ID required")?,model["name"].as_str().context("Model name required")?,model["provider"].as_str().context("Provider required")?,model["endpoint"].as_str().context("Endpoint required")?,model["context_limit"].as_u64().context("Context limit required")?,model["metadata"].to_string()])?; @@ -1655,6 +1696,72 @@ fn finish_task_on( Ok(sid) } +/// Byte offset of `needle` in `text`, comparing ASCII letters without case +/// (as SQLite's `LIKE` does). +fn find_ignoring_ascii_case(text: &str, needle: &str) -> Option { + let (hay, pin) = (text.as_bytes(), needle.as_bytes()); + if pin.is_empty() || pin.len() > hay.len() { + return None; + } + (0..=hay.len() - pin.len()).find(|&i| hay[i..i + pin.len()].eq_ignore_ascii_case(pin)) +} + +/// A short excerpt of `text` around the first match of `needle`, on one +/// line, with "…" where it was cut, and the match's position in it +/// (characters, start and end). +fn search_snippet(text: &str, needle: &str) -> Option<(String, usize, usize)> { + const BEFORE: usize = 40; + const AFTER: usize = 80; + let at = find_ignoring_ascii_case(text, needle)?; + let end = at + needle.len(); + let mut from = text[..at] + .char_indices() + .rev() + .nth(BEFORE - 1) + .map_or(0, |(i, _)| i); + // Start at a word when the cut lands inside one. + if from > 0 { + if let Some(space) = text[from..at].find(char::is_whitespace) { + from += space; + } + } + let to = text[end..] + .char_indices() + .nth(AFTER) + .map_or(text.len(), |(i, _)| end + i); + // Runs of spaces and line breaks become one space. + let flat = |part: &str| { + let mut out = String::with_capacity(part.len()); + for c in part.chars() { + if !c.is_whitespace() { + out.push(c); + } else if !out.ends_with(' ') { + out.push(' '); + } + } + out + }; + let lead = flat(&text[from..at]); + let lead = if from == 0 { + lead.trim_start().to_owned() + } else { + format!("…{}", lead.trim_start()) + }; + let hit = flat(&text[at..end]); + let tail = flat(&text[end..to]); + let tail = if to == text.len() { + tail.trim_end().to_owned() + } else { + format!("{}…", tail.trim_end()) + }; + let begin = lead.chars().count(); + Some(( + format!("{lead}{hit}{tail}"), + begin, + begin + hit.chars().count(), + )) +} + #[cfg(test)] mod verification_tests { use super::*; @@ -2161,3 +2268,35 @@ mod verification_tests { assert_eq!(result["final_assessment"], "not_completed"); } } + +#[cfg(test)] +mod search_tests { + #[test] + fn search_snippets_show_the_match_in_context() { + let (snippet, start, end) = super::search_snippet("Fixed the Login bug", "login").unwrap(); + assert_eq!(snippet, "Fixed the Login bug"); + assert_eq!(&snippet[start..end], "Login"); + let long = format!( + "{} the parser now reads\n\n nested tables {}", + "word ".repeat(30), + "tail ".repeat(40) + ); + let (snippet, start, end) = super::search_snippet(&long, "NESTED").unwrap(); + assert!( + snippet.starts_with('…') && snippet.ends_with('…'), + "{snippet}" + ); + assert!( + snippet.contains("parser now reads nested tables"), + "{snippet}" + ); + let chars: Vec = snippet.chars().collect(); + assert_eq!(chars[start..end].iter().collect::(), "nested"); + // Character offsets stay right after multi-byte text. + let (snippet, start, end) = + super::search_snippet("Größe: ändern Sie die Datei", "datei").unwrap(); + let chars: Vec = snippet.chars().collect(); + assert_eq!(chars[start..end].iter().collect::(), "Datei"); + assert!(super::search_snippet("nothing here", "absent").is_none()); + } +} diff --git a/native/core/src/subagents.rs b/native/core/src/subagents.rs index 23833c86..56d048bf 100644 --- a/native/core/src/subagents.rs +++ b/native/core/src/subagents.rs @@ -97,6 +97,38 @@ impl SubagentsConfig { } } +/// Check a `subagents:` change before it is saved. Loading stays lenient +/// (out-of-range values are clamped, see [`SubagentsConfig::from_config`]) so +/// an existing file never stops the engine from starting; a new value that +/// would be clamped or ignored is refused instead. +pub fn validate_change(value: &Value) -> Result<()> { + let Some(object) = value.as_object() else { + bail!( + "subagents must be a group of settings, for example `subagents: {{max_parallel: 4}}`" + ); + }; + for (key, item) in object { + let (min, max) = match key.as_str() { + "enabled" => { + ensure!(item.is_boolean(), "subagents.enabled must be true or false"); + continue; + } + "max_parallel" => (1, 8), + "max_depth" => (0, 3), + "max_turns" => (1, 200), + "max_per_task" => (1, 64), + _ => bail!( + "subagents.{key} is not a setting; use enabled, max_parallel, max_depth, max_turns or max_per_task" + ), + }; + ensure!( + item.as_u64().is_some_and(|n| (min..=max).contains(&n)), + "subagents.{key} must be a whole number from {min} to {max}; found {item}" + ); + } + Ok(()) +} + /// Tools a child may use. Deny wins; an empty allow list allows everything /// its permissions allow. `mcp__server` in a list covers that server's tools. #[derive(Clone, Debug, Default)] @@ -1311,3 +1343,31 @@ mod cloud_consent_tests { assert!(refused.contains("Plan → Implement → Review"), "{refused}"); } } + +#[cfg(test)] +mod validate_change_tests { + use super::validate_change; + use serde_json::json; + + #[test] + fn saved_subagent_settings_are_checked_by_name_type_and_range() { + validate_change(&json!({})).unwrap(); + validate_change(&json!({ + "enabled": false, "max_parallel": 8, "max_depth": 0, + "max_turns": 200, "max_per_task": 1 + })) + .unwrap(); + for (bad, says) in [ + (json!(4), "must be a group"), + (json!({"enabled": "yes"}), "true or false"), + (json!({"max_parallel": 0}), "from 1 to 8; found 0"), + (json!({"max_depth": 4}), "from 0 to 3"), + (json!({"max_turns": 2.5}), "whole number"), + (json!({"max_per_task": "16"}), "whole number"), + (json!({"max_paralel": 4}), "is not a setting"), + ] { + let error = validate_change(&bad).unwrap_err().to_string(); + assert!(error.contains(says), "{bad}: {error}"); + } + } +} diff --git a/native/core/src/worktrees/locks.rs b/native/core/src/worktrees/locks.rs index a21cce1a..d9d34dac 100644 --- a/native/core/src/worktrees/locks.rs +++ b/native/core/src/worktrees/locks.rs @@ -71,9 +71,20 @@ impl Advisory { path, }; lock.validate()?; - lock.file - .try_lock_exclusive() - .with_context(|| busy.to_owned())?; + // A process forking at this moment holds a copy of the previous + // owner's descriptor until it execs, which takes milliseconds; only a + // lock still held after a short wait belongs to another owner. + let mut waited = 0; + loop { + match lock.file.try_lock_exclusive() { + Ok(()) => break, + Err(error) if waited < 20 && error.kind() == std::io::ErrorKind::WouldBlock => { + waited += 1; + std::thread::sleep(std::time::Duration::from_millis(25)); + } + Err(error) => return Err(anyhow::Error::new(error).context(busy.to_owned())), + } + } lock.validate()?; Ok(lock) } diff --git a/native/core/tests/config_keys.rs b/native/core/tests/config_keys.rs index 531cc6f5..9c993c69 100644 --- a/native/core/tests/config_keys.rs +++ b/native/core/tests/config_keys.rs @@ -370,3 +370,76 @@ fn a_bad_value_sent_by_settings_names_its_key() { ); runtime.block_on(service.engine.shutdown()).unwrap(); } + +#[test] +fn a_saved_subagent_setting_out_of_range_is_refused_but_an_old_file_still_loads() { + let root = tempfile::tempdir().unwrap(); + let paths = AppPaths::isolated(root.path()).unwrap(); + // Saving: refused, and nothing is written. + let error = Config::patch( + &paths, + serde_json::json!({"subagents": {"max_parallel": 20}}), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("from 1 to 8"), "{error}"); + Config::patch( + &paths, + serde_json::json!({"subagents": {"max_parallel": 6}}), + ) + .unwrap(); + assert_eq!( + SubagentsConfig::from_config(&Config::load(&paths, None).unwrap()).max_parallel, + 6 + ); + // A file written before the check still loads, clamped as before. + let file = paths.config_file(); + let text = std::fs::read_to_string(&file) + .unwrap() + .replace("max_parallel: 6", "max_parallel: 20"); + std::fs::write(&file, text).unwrap(); + let config = Config::load(&paths, None).unwrap(); + assert_eq!(SubagentsConfig::from_config(&config).max_parallel, 8); +} + +#[test] +fn settings_see_the_profile_agent_values_and_what_the_project_changes() { + use shadowcode_core::service::{Request, Service}; + let root = tempfile::tempdir().unwrap(); + let paths = AppPaths::isolated(&root.path().join("profile")).unwrap(); + let project = root.path().join("project"); + fs::create_dir_all(project.join(".shadow/config")).unwrap(); + fs::write( + project.join(".shadow/config/config.yaml"), + "agent:\n max_steps: 200\n", + ) + .unwrap(); + let project = project.canonicalize().unwrap(); + Config::patch(&paths, json!({"trusted_workspaces": [project]})).unwrap(); + let service = Service::open(paths, Some(project)).unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let call = |method: &str, body: Value| { + runtime + .block_on(service.dispatch(Request { + method: method.into(), + path: "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/api/config".into(), + body, + })) + .unwrap() + }; + let view = call("GET", Value::Null); + // The project's value is what tasks use; the profile's is what Settings saves. + assert_eq!(view["agent"]["max_steps"], 200); + assert_eq!(view["agent_profile"]["max_steps"], 64); + assert_eq!(view["agent_project"], json!({"max_steps": 200})); + let saved = call("PUT", json!({"values": {"agent": {"max_steps": 80}}})); + assert_eq!(saved["agent_profile"]["max_steps"], 80); + assert_eq!(saved["agent_project"], json!({"max_steps": 200})); + let view = call("GET", Value::Null); + assert_eq!(view["agent_profile"]["max_steps"], 80); + assert_eq!(view["agent"]["max_steps"], 200); + runtime.block_on(service.engine.shutdown()).unwrap(); +} diff --git a/native/core/tests/custom_endpoints.rs b/native/core/tests/custom_endpoints.rs new file mode 100644 index 00000000..a1a1988a --- /dev/null +++ b/native/core/tests/custom_endpoints.rs @@ -0,0 +1,408 @@ +//! The user's own OpenAI-compatible endpoints against a fake server (a small +//! Python server on loopback). It serves `/v1/models` and streaming +//! `/v1/chat/completions` with a canned tool call, checks the bearer key, +//! and records every request. Covers listing an endpoint's models, adding +//! them (the key saved privately), the picker row, a task on the native loop, +//! and removal. +use serde_json::{json, Value}; +use shadowcode_core::{ + config::Config, + paths::AppPaths, + service::{Request, Service}, +}; +use std::{ + fs, + io::{BufRead, BufReader}, + path::Path, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; + +const FAKE: &str = r#"#!/usr/bin/env python3 +import json, os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +HERE = os.path.dirname(os.path.abspath(__file__)) +KEY = "sk-endpoint-good" + +def log(value): + with open(os.path.join(HERE, "requests.jsonl"), "a") as f: + f.write(json.dumps(value) + "\n") + +def sse(handler, chunks): + handler.send_response(200) + handler.send_header("Content-Type", "text/event-stream") + handler.end_headers() + for chunk in chunks: + handler.wfile.write(("data: " + json.dumps(chunk) + "\n\n").encode()) + handler.wfile.write(b"data: [DONE]\n\n") + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.0" + def log_message(self, *a): + pass + def reply(self, code, body): + data = json.dumps(body).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + def auth(self): + return self.headers.get("Authorization", "") == "Bearer " + KEY + def do_GET(self): + log({"method": "GET", "path": self.path, "auth": self.auth()}) + if not self.auth(): + return self.reply(401, {"error": {"message": "Incorrect API key provided"}}) + if self.path == "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/v1/models": + return self.reply(200, {"object": "list", "data": [ + {"id": "acme-coder", "object": "model"}, + {"id": "acme-chat", "object": "model"}]}) + self.reply(404, {}) + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0")))) + messages = body.get("messages", []) + log({"method": "POST", "path": self.path, "auth": self.auth(), "model": body.get("model")}) + if not self.auth(): + return self.reply(401, {"error": {"message": "Incorrect API key provided"}}) + if self.path != "/v1/chat/completions": + return self.reply(404, {}) + done = any(m.get("role") == "tool" for m in messages) + if body.get("tools") and not done: + return sse(self, [ + {"choices": [{"delta": {"role": "assistant", "content": None, "tool_calls": [ + {"index": 0, "id": "call_1", "type": "function", "function": { + "name": "write_file", + "arguments": json.dumps({"path": "hello.txt", "content": "hi from my endpoint\n"})}}]}, + "finish_reason": None}]}, + {"choices": [{"delta": {}, "finish_reason": "tool_calls"}], + "usage": {"prompt_tokens": 40, "completion_tokens": 9}}]) + return sse(self, [ + {"choices": [{"delta": {"role": "assistant", "content": "Wrote hello.txt."}, "finish_reason": None}]}, + {"choices": [{"delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 55, "completion_tokens": 5}}]) + +server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) +print(server.server_address[1], flush=True) +server.serve_forever() +"#; + +struct Fake { + child: Child, + /// The address as a person would type it: no `/v1`. + host: String, + dir: tempfile::TempDir, +} +impl Drop for Fake { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} +impl Fake { + fn start() -> Self { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("fake_endpoint.py"); + fs::write(&script, FAKE).unwrap(); + let mut child = Command::new("python3") + .arg(&script) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut line = String::new(); + BufReader::new(child.stdout.take().unwrap()) + .read_line(&mut line) + .unwrap(); + let host = format!("http://127.0.0.1:{}", line.trim()); + Self { child, host, dir } + } + fn requests(&self) -> Vec { + fs::read_to_string(self.dir.path().join("requests.jsonl")) + .unwrap_or_default() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect() + } +} + +async fn call(service: &Service, method: &str, path: &str, body: Value) -> anyhow::Result { + service + .dispatch(Request { + method: method.into(), + path: path.into(), + body, + }) + .await +} + +async fn wait_job(service: &Service, id: &str) -> Value { + let started = Instant::now(); + loop { + let job = call(service, "GET", &format!("/api/jobs/{id}"), Value::Null) + .await + .unwrap(); + if matches!( + job["status"].as_str(), + Some("completed" | "failed" | "cancelled") + ) { + return job; + } + assert!(started.elapsed() < Duration::from_secs(30), "{job}"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +fn setup(root: &Path) -> (AppPaths, Service) { + let project = root.join("project"); + fs::create_dir(&project).unwrap(); + let paths = AppPaths::isolated(&root.join("profile")).unwrap(); + Config::patch( + &paths, + json!({ + "trusted_workspaces": [project.clone()], + "permissions": {"mode": "allow_edits"}, + // Never probe the vendor CLIs installed on the test machine. + "cli_agents": {"enabled": false} + }), + ) + .unwrap(); + let service = Service::open(paths.clone(), Some(project)).unwrap(); + (paths, service) +} + +fn endpoint_rows(picker: &Value) -> Vec { + picker["targets"] + .as_array() + .unwrap() + .iter() + .filter(|t| t["custom"] == true) + .cloned() + .collect() +} + +#[tokio::test] +async fn an_endpoint_is_listed_added_used_and_removed() { + // A name no developer machine sets, so the saved key is the one used. + const KEY_NAME: &str = "SHADOWCODE_TEST_ENDPOINT_KEY"; + std::env::remove_var(KEY_NAME); + let fake = Fake::start(); + let root = tempfile::tempdir().unwrap(); + let (paths, service) = setup(root.path()); + let project = root.path().join("project"); + + // The wrong key: the server's refusal, and nothing is saved. + let refused = call( + &service, + "POST", + "/api/providers/models", + json!({"provider":"openai_compatible","endpoint":fake.host,"api_key":"sk-wrong","api_key_env":KEY_NAME}), + ) + .await; + let error = format!("{:#}", refused.unwrap_err()); + assert!( + error.contains("401") && error.contains("check the API key"), + "{error}" + ); + assert!( + !error.contains("sk-wrong"), + "the key is never echoed: {error}" + ); + + // The typed key lists the models; the address gains /v1. + let listed = call( + &service, + "POST", + "/api/providers/models", + json!({"provider":"openai_compatible","endpoint":fake.host,"api_key":"sk-endpoint-good","api_key_env":KEY_NAME}), + ) + .await + .unwrap(); + let endpoint = format!("{}/v1", fake.host); + assert_eq!(listed["endpoint"], endpoint); + assert_eq!(listed["api_key_env"], KEY_NAME); + assert_eq!(listed["key_saved"], false); + assert_eq!(listed["local"], true); + let names: Vec<&str> = listed["models"] + .as_array() + .unwrap() + .iter() + .map(|m| m["id"].as_str().unwrap()) + .collect(); + assert_eq!(names, ["acme-coder", "acme-chat"]); + assert!( + fs::read_to_string(paths.secrets_file()) + .map(|s| !s.contains("sk-endpoint-good")) + .unwrap_or(true), + "listing saves nothing" + ); + assert!(endpoint_rows( + &call(&service, "GET", "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/api/picker", Value::Null) + .await + .unwrap() + ) + .is_empty()); + + // Adding saves the key privately and registers the chosen model. + let added = call( + &service, + "POST", + "/api/models/endpoints", + json!({"provider":"openai_compatible","endpoint":fake.host,"models":["acme-coder"," acme-coder "], + "api_key":"sk-endpoint-good","api_key_env":KEY_NAME}), + ) + .await + .unwrap(); + let models = added["models"].as_array().unwrap(); + assert_eq!(models.len(), 1, "duplicates are added once: {added}"); + assert_eq!(models[0]["endpoint"], endpoint); + assert_eq!(models[0]["api_key_env"], KEY_NAME); + let id = models[0]["default"].as_str().unwrap().to_owned(); + assert!(id.starts_with("model:"), "{id}"); + assert!(!added.to_string().contains("sk-endpoint-good")); + assert!(fs::read_to_string(paths.secrets_file()) + .unwrap() + .contains(KEY_NAME)); + + // Listing again uses the saved key on this computer's window. + let again = call( + &service, + "POST", + "/api/providers/models", + json!({"provider":"openai_compatible","endpoint":fake.host,"api_key_env":KEY_NAME}), + ) + .await + .unwrap(); + assert_eq!(again["key_saved"], true); + assert_eq!(again["models"].as_array().unwrap().len(), 2); + + // The picker lists it with the models on this computer. + let rows = endpoint_rows( + &call(&service, "GET", "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/api/picker", Value::Null) + .await + .unwrap(), + ); + assert_eq!(rows.len(), 1, "{rows:?}"); + let row = &rows[0]; + assert_eq!(row["id"], id.as_str()); + assert_eq!(row["group"], "local"); + assert_eq!(row["inference"], "local"); + assert_eq!(row["availability"], "ready"); + assert_eq!(row["name"], "acme-coder · This computer"); + assert!(row["subtitle"] + .as_str() + .unwrap() + .starts_with("OpenAI-compatible · 127.0.0.1:")); + // A cached (snapshot) read lists it too. + let cached = call(&service, "GET", "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/api/picker?cached=1", Value::Null) + .await + .unwrap(); + assert_eq!(endpoint_rows(&cached).len(), 1); + + // A task runs on ShadowCode's own loop with the saved key. + let job = call( + &service, + "POST", + "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/api/jobs", + json!({"task":"Create hello.txt","model":id}), + ) + .await + .unwrap(); + let done = wait_job(&service, job["id"].as_str().unwrap()).await; + assert_eq!(done["status"], "completed", "{done}"); + assert_eq!( + fs::read_to_string(project.join("hello.txt")).unwrap(), + "hi from my endpoint\n" + ); + let chats: Vec = fake + .requests() + .into_iter() + .filter(|r| r["path"] == "/v1/chat/completions") + .collect(); + assert_eq!(chats.len(), 2, "{chats:?}"); + assert!(chats + .iter() + .all(|r| r["auth"] == true && r["model"] == "acme-coder")); + + // Only the user's endpoint rows can be removed here. + let not_endpoint = call(&service, "POST", "/api/models/remove", json!({"id":"mock"})).await; + assert!(not_endpoint.is_err()); + call(&service, "POST", "/api/models/remove", json!({"id":id})) + .await + .unwrap(); + assert!(endpoint_rows( + &call(&service, "GET", "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/api/picker", Value::Null) + .await + .unwrap() + ) + .is_empty()); + assert!( + call(&service, "POST", "/api/models/remove", json!({"id":id})) + .await + .is_err() + ); + // The saved key stays for other models of this server. + assert!(fs::read_to_string(paths.secrets_file()) + .unwrap() + .contains(KEY_NAME)); +} + +#[tokio::test] +async fn endpoint_requests_are_checked_before_anything_is_sent() { + let root = tempfile::tempdir().unwrap(); + let (paths, service) = setup(root.path()); + let refuse = |body: Value| { + let service = &service; + async move { + format!( + "{:#}", + call(service, "POST", "/api/providers/models", body) + .await + .unwrap_err() + ) + } + }; + assert!(refuse(json!({"provider":"openrouter"})) + .await + .contains("Choose a provider")); + assert!( + refuse(json!({"provider":"vllm","endpoint":"ftp://example.com"})) + .await + .contains("http://") + ); + assert!( + refuse(json!({"provider":"vllm","endpoint":"https://user:secret@example.com/v1"})) + .await + .contains("user name or password") + ); + assert!(refuse( + json!({"provider":"vllm","endpoint":"https://example.com/v1","api_key_env":"not a name"}) + ) + .await + .contains("key name")); + assert!(call( + &service, + "POST", + "/api/models/endpoints", + json!({"provider":"vllm","endpoint":"http://127.0.0.1:9/v1","models":[]}) + ) + .await + .is_err()); + // The config.yaml fallback model is registered by the engine itself; it + // is not one of the user's endpoint rows. + Config::patch( + &paths, + json!({"model": {"provider":"local","endpoint":"http://127.0.0.1:9/v1","name":"fixture","context_limit":16384}}), + ) + .unwrap(); + let models = call(&service, "GET", "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/api/models?detect=false", Value::Null) + .await + .unwrap(); + assert!(endpoint_rows(&json!({"targets": models["picker"]})).is_empty()); + // Offline: only servers on this computer can be reached. + Config::patch(&paths, json!({"network": {"mode": "offline"}})).unwrap(); + assert!(refuse( + json!({"provider":"openai_compatible","endpoint":"https://api.example.com/v1"}) + ) + .await + .contains("Offline mode")); +} diff --git a/native/core/tests/data_care.rs b/native/core/tests/data_care.rs index d04c52a4..7ee48220 100644 --- a/native/core/tests/data_care.rs +++ b/native/core/tests/data_care.rs @@ -480,7 +480,8 @@ async fn reset_moves_everything_aside_and_keeps_backups_worktrees_and_downloads( assert!(!p.paths.secrets_file().exists()); assert!(!p.paths.data.join("webview").exists()); assert!(!p.paths.state.join("ui.log").exists()); - assert_eq!(Config::load(&p.paths, None).unwrap().ui["theme"], "light"); + // The default theme again (it follows the desktop). + assert_eq!(Config::load(&p.paths, None).unwrap().ui["theme"], "system"); // Kept in place. assert_eq!(backups(&p.paths).len(), 1); for kept in ["local-models", "managed-worktrees"] { diff --git a/native/core/tests/process_stress.rs b/native/core/tests/process_stress.rs index 1f3ff05b..0761217f 100644 --- a/native/core/tests/process_stress.rs +++ b/native/core/tests/process_stress.rs @@ -117,7 +117,11 @@ async fn a_detached_pipe_cannot_hang_completion() { let root = tempfile::tempdir().unwrap(); let result = run( ProcessSpec::shell( - "setsid sh -c 'echo $$ > escaped.pid; sleep 8' & sleep .1; printf done", + // Exit only once the child has left the shell's process group + // (it writes its pid after setsid); a fixed pause could end the + // shell first on a slow runner, and the group kill took it too. + "setsid sh -c 'echo $$ > escaped.pid; sleep 8' & \ + while [ ! -s escaped.pid ]; do sleep .02; done; printf done", root.path().into(), Duration::from_secs(3), ), diff --git a/native/core/tests/service_commands.rs b/native/core/tests/service_commands.rs index 95418dbf..a618ae24 100644 --- a/native/core/tests/service_commands.rs +++ b/native/core/tests/service_commands.rs @@ -1353,6 +1353,52 @@ async fn session_listing_and_literal_search_work_after_tasks_are_saved() { .unwrap(); assert_eq!(found["sessions"].as_array().unwrap().len(), 1); assert_eq!(found["sessions"][0]["id"], plain["id"]); + // The prompt that matched is shown, with the match marked. + let hit = &found["sessions"][0]["match"]; + assert_eq!(hit["field"], "prompt"); + assert_eq!(hit["snippet"], "Find the archived needle"); + assert_eq!(hit["start"], 9); + assert_eq!(hit["end"], 24); + + // The agent's answers are searched too. + let store = service.engine.store(); + let task = store + .create_task(special["id"].as_str().unwrap(), "Tidy the parser") + .unwrap(); + store + .finish_task( + &task, + "completed", + "Moved the TOML reader into its own module and added tests.", + &json!({}), + ) + .unwrap(); + let found = call( + &service, + "GET", + "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/api/sessions?q=toml%20reader", + Value::Null, + ) + .await + .unwrap(); + let sessions = found["sessions"].as_array().unwrap(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0]["id"], special["id"]); + assert_eq!(sessions[0]["match"]["field"], "answer"); + assert_eq!( + sessions[0]["match"]["snippet"], + "Moved the TOML reader into its own module and added tests." + ); + // A title match needs no excerpt; no search, no excerpts. + let found = call(&service, "GET", "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/api/sessions?q=Literal", Value::Null) + .await + .unwrap(); + assert!(found["sessions"][0].get("match").is_none()); + assert!(all["sessions"] + .as_array() + .unwrap() + .iter() + .all(|s| s.get("match").is_none())); } #[tokio::test] diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index b2e3f179..e63b6e0c 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -3,5 +3,9 @@ "identifier": "main", "description": "Embedded ShadowCode interface only; all filesystem and process actions use the guarded Rust command service.", "windows": ["main"], - "permissions": ["core:event:allow-listen", "core:event:allow-unlisten"] + "permissions": [ + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:webview:allow-set-webview-zoom" + ] } diff --git a/ui/e2e/agentSettings.spec.ts b/ui/e2e/agentSettings.spec.ts new file mode 100644 index 00000000..4d42683c --- /dev/null +++ b/ui/e2e/agentSettings.spec.ts @@ -0,0 +1,174 @@ +import { test, expect, type Page } from "@playwright/test"; +import { installFakeBackend } from "./fakeBackend"; + +// Settings › Agent in the desktop window (fake engine): edit, see the +// inline checks, survive a refused save, and save only what changed. +test.beforeEach(async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + (page as Page & { errors?: string[] }).errors = errors; + await page.addInitScript(installFakeBackend, { stepMs: 60 }); + await page.goto("/"); + await expect( + page.getByRole("heading", { name: "What should we work on?" }), + ).toBeVisible(); +}); +test.afterEach(async ({ page }) => { + expect((page as Page & { errors?: string[] }).errors).toEqual([]); +}); + +type Logged = { method: string; path: string; body: any }; +const configSaves = (page: Page) => + page.evaluate(() => + ( + window as unknown as { __SHADOW_FAKE__: { log: Logged[] } } + ).__SHADOW_FAKE__.log.filter( + (r) => r.method === "PUT" && r.path === "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/api/config", + ), + ); + +async function openAgent(page: Page) { + await page.keyboard.press("Control+,"); + const dialog = page.getByRole("dialog", { name: "Settings" }); + await dialog.getByRole("button", { name: "Agent", exact: true }).click(); + await expect(dialog.getByRole("heading", { name: "Agent" })).toBeVisible(); + return dialog; +} + +test("edits agent settings, checks them inline and saves only what changed", async ({ + page, +}) => { + const dialog = await openAgent(page); + const steps = dialog.getByLabel("Steps per task", { exact: true }); + const save = dialog.getByRole("button", { name: "Save", exact: true }); + // The engine's values, with what each does, its range and default. + await expect(steps).toHaveValue("64"); + await expect(steps).toHaveAccessibleDescription(/1–1,000 · default 64\.$/); + await expect( + dialog.getByLabel("Running at once", { exact: true }), + ).toHaveValue("4"); + await expect( + dialog.getByLabel("Checkpoints kept per repository", { exact: true }), + ).toHaveValue("200"); + await expect(save).toBeDisabled(); + + // Out of range: said under the field, and Save waits. + await steps.fill("1001"); + await expect(steps).toHaveAttribute("aria-invalid", "true"); + await expect(steps).toHaveAccessibleDescription( + /Enter a whole number from 1 to 1,000\.$/, + ); + await expect(dialog.getByRole("status")).toHaveText( + "Fix the setting marked above to save.", + ); + await expect(save).toBeDisabled(); + await steps.fill("120"); + await expect(steps).not.toHaveAttribute("aria-invalid", "true"); + + await dialog + .getByLabel("Shorten history at this share of the context", { exact: true }) + .fill("0.8"); + await dialog + .getByLabel("Pause and ask when the agent seems stuck", { exact: true }) + .uncheck(); + await dialog.getByLabel("Running at once", { exact: true }).fill("6"); + await dialog + .getByLabel("Before subscription turns", { exact: true }) + .uncheck(); + await dialog.getByRole("button", { name: "Add a check" }).click(); + // The new row has focus: type straight into it. + await expect(dialog.getByLabel("Check 1", { exact: true })).toBeFocused(); + await page.keyboard.type("npm test"); + + // Keyboard: Reset puts the default back and returns focus to the field. + const timeout = dialog.getByLabel("Tool time limit", { exact: true }); + await timeout.fill("90"); + await dialog + .getByRole("button", { name: "Reset Tool time limit to its default" }) + .focus(); + await page.keyboard.press("Enter"); + await expect(timeout).toHaveValue("60"); + await expect(timeout).toBeFocused(); + + await page.screenshot({ path: "test-results/settings-agent-edit.png" }); + + // The engine refuses the first save: its reason shows on the page. + await page.evaluate(() => { + const w = window as unknown as { + __SHADOW_TEST_TRANSPORT__: { + request: (p: string, m: string, b: unknown) => Promise; + }; + }; + const bridge = w.__SHADOW_TEST_TRANSPORT__; + const request = bridge.request.bind(bridge); + let refused = false; + bridge.request = async (path, method, body) => { + if (path === "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/api/config" && method === "PUT" && !refused) { + refused = true; + throw new Error( + "/home/dev/.config/shadow-agent/config.yaml needs a fix: model.context_limit must be between 1024 and 4000000 tokens; found 12", + ); + } + return request(path, method, body); + }; + }); + await save.click(); + const refusal = dialog.getByRole("alert"); + await expect(refusal).toHaveText( + /needs a fix: model\.context_limit must be between 1024 and 4000000 tokens; found 12$/, + ); + await expect(save).toHaveAccessibleDescription(/model\.context_limit/); + expect(await configSaves(page)).toEqual([]); + + // Saved on the next try: only the changed keys of the page's groups. + await save.click(); + await expect( + page.getByRole("status").filter({ hasText: "Settings saved" }), + ).toBeVisible(); + await expect(refusal).toHaveCount(0); + const saves = await configSaves(page); + expect(saves).toHaveLength(1); + expect(saves[0].body.values).toEqual({ + agent: { max_steps: 120, compact_ratio: 0.8, stuck_check: false }, + subagents: { max_parallel: 6 }, + checkpoints: { vendor: false }, + verification: { commands: ["npm test"] }, + }); + await expect(save).toBeDisabled(); + + // Reopened, the page shows what the engine now has. + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + const again = await openAgent(page); + await expect(again.getByLabel("Steps per task", { exact: true })).toHaveValue( + "120", + ); + await expect( + again.getByLabel("Running at once", { exact: true }), + ).toHaveValue("6"); + await expect(again.getByLabel("Nesting depth", { exact: true })).toHaveValue( + "1", + ); + await expect( + again.getByLabel("Pause and ask when the agent seems stuck", { + exact: true, + }), + ).not.toBeChecked(); + await expect(again.getByLabel("Check 1", { exact: true })).toHaveValue( + "npm test", + ); + await expect( + again.getByRole("button", { name: "Reset Steps per task to its default" }), + ).toBeVisible(); +}); + +test("the command palette opens Settings › Agent", async ({ page }) => { + await page.keyboard.press("Control+k"); + await page.keyboard.type("limits, retries, subagents"); + await page.keyboard.press("Enter"); + const dialog = page.getByRole("dialog", { name: "Settings" }); + await expect(dialog.getByRole("heading", { name: "Agent" })).toBeVisible(); + await expect( + dialog.getByRole("button", { name: "Agent", exact: true }), + ).toHaveAttribute("aria-current", "page"); +}); diff --git a/ui/e2e/appearance.spec.ts b/ui/e2e/appearance.spec.ts new file mode 100644 index 00000000..db6237b4 --- /dev/null +++ b/ui/e2e/appearance.spec.ts @@ -0,0 +1,335 @@ +import { test, expect, type Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { installFakeBackend } from "./fakeBackend"; +import { installFakeTools } from "./fakeTools"; +import { expectEditorText } from "./editorHelpers"; + +// Text size and theme: the zoom keys, Settings › Appearance's live preview, +// the command palette, and the CSS zoom a browser falls back to (the desktop +// zooms its webview instead) keeping the terminal and editor accurate. +test.beforeEach(async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + (page as Page & { errors?: string[] }).errors = errors; + await page.addInitScript(installFakeBackend, { stepMs: 60, keepUi: true }); + await page.addInitScript(installFakeTools, {}); + // The size the window opened at, before the config was read. + await page.addInitScript(() => + document.addEventListener("DOMContentLoaded", () => { + (window as any).__zoomAtStart = + document.documentElement.style.zoom || "1"; + }), + ); + await page.goto("/"); + await expect( + page.getByRole("heading", { name: "What should we work on?" }), + ).toBeVisible(); +}); +test.afterEach(async ({ page }) => { + expect((page as Page & { errors?: string[] }).errors).toEqual([]); +}); + +const zoom = (page: Page) => + page.evaluate(() => document.documentElement.style.zoom || "1"); +const theme = (page: Page) => + page.evaluate(() => document.documentElement.dataset.theme); +const savedUi = (page: Page) => + page.evaluate(() => + ( + (window as any).__SHADOW_FAKE__.log as { + method: string; + path: string; + body: { values?: { ui?: Record } }; + }[] + ) + .filter((r) => r.method === "PUT" && r.path === "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/api/config") + .map((r) => r.body.values?.ui), + ); +const both = async (page: Page) => [await theme(page), await zoom(page)]; +const status = (page: Page) => + page.getByRole("status").filter({ hasText: /^Text size/ }); +const heading = (page: Page) => + page.getByRole("heading", { name: "What should we work on?" }); + +test("Ctrl+=, Ctrl+- and Ctrl+0 zoom the window, say the size and keep it", async ({ + page, +}) => { + const button = page.getByRole("button", { name: "Review changes" }); + const before = (await button.boundingBox())!; + expect(await zoom(page)).toBe("1"); + await page.keyboard.press("Control+Equal"); + await expect.poll(() => zoom(page)).toBe("1.1"); + await expect(status(page)).toHaveText("Text size 110%"); + // Ctrl++ (Shift, or the keypad) zooms in too; Ctrl+- zooms out. + await page.keyboard.press("Control+Shift+Equal"); + await expect.poll(() => zoom(page)).toBe("1.25"); + await page.keyboard.press("Control+NumpadAdd"); + await expect.poll(() => zoom(page)).toBe("1.5"); + await page.keyboard.press("Control+Minus"); + await expect.poll(() => zoom(page)).toBe("1.25"); + await expect(status(page)).toHaveText("Text size 125%"); + await page.keyboard.press("Control+0"); + await expect.poll(() => zoom(page)).toBe("1"); + // From the composer as well, up to the largest size. + await page.getByRole("textbox", { name: "Message ShadowCode" }).focus(); + for (let i = 0; i < 6; i++) await page.keyboard.press("Control+Equal"); + await expect.poll(() => zoom(page)).toBe("1.75"); + await expect(status(page)).toHaveText("Text size 175% · largest"); + // The whole window is larger, not just the text. + const after = (await button.boundingBox())!; + for (const ratio of [ + after.height / before.height, + after.width / before.width, + ]) { + // Borders and text snap to whole pixels, so it is close to 1.75. + expect(ratio).toBeGreaterThan(1.65); + expect(ratio).toBeLessThan(1.85); + } + // Saved once the keys stop, and remembered for the next start. + await expect + .poll(() => savedUi(page).then((saved) => saved.at(-1))) + .toEqual({ zoom: 1.75 }); + expect(await page.evaluate(() => localStorage.getItem("shadow:zoom"))).toBe( + "1.75", + ); + await expect(page.locator(".zoom-status")).toBeEmpty(); + // A dialog keeps its keys. + await page.keyboard.press("Control+,"); + await expect(page.getByRole("dialog", { name: "Settings" })).toBeVisible(); + await page.keyboard.press("Control+Minus"); + await page.keyboard.press("Escape"); + expect(await zoom(page)).toBe("1.75"); + // A reload opens at the saved size before the config has been read. + await page.reload(); + await expect(heading(page)).toBeVisible(); + expect(await page.evaluate(() => (window as any).__zoomAtStart)).toBe("1.75"); + expect(await zoom(page)).toBe("1.75"); +}); + +test("Settings › Appearance shows theme and text size as they are picked, and only Save keeps them", async ({ + page, +}) => { + const settings = page.getByRole("dialog", { name: "Settings" }); + const open = async () => { + await page.keyboard.press("Control+,"); + await settings + .getByRole("button", { name: "Appearance", exact: true }) + .click(); + }; + const option = (group: string, name: string) => + settings + .getByRole("radiogroup", { name: group }) + .getByRole("radio", { name, exact: true }); + await open(); + await expect(option("Theme", "Light")).toHaveAttribute( + "aria-checked", + "true", + ); + await expect(option("Text size", "100%")).toHaveAttribute( + "aria-checked", + "true", + ); + const reset = settings.getByRole("button", { name: "Reset to 100%" }); + await expect(reset).toBeDisabled(); + await option("Theme", "Dark").click(); + await expect.poll(() => theme(page)).toBe("dark"); + await option("Text size", "150%").click(); + await expect.poll(() => zoom(page)).toBe("1.5"); + // The dialog still fits the window at that size. + const box = (await settings.boundingBox())!; + expect(box.y).toBeGreaterThanOrEqual(0); + expect(box.y + box.height).toBeLessThanOrEqual(1000); + // Another page puts the saved ones back… + await settings.getByRole("button", { name: "Accounts", exact: true }).click(); + await expect.poll(() => both(page)).toEqual(["light", "1"]); + await settings + .getByRole("button", { name: "Appearance", exact: true }) + .click(); + await expect(option("Theme", "Light")).toHaveAttribute( + "aria-checked", + "true", + ); + // …and so does closing Settings, with Close or with Escape. + await option("Theme", "Dark").click(); + await option("Text size", "125%").click(); + await settings.getByRole("button", { name: "Close", exact: true }).click(); + await expect(settings).toHaveCount(0); + await expect.poll(() => both(page)).toEqual(["light", "1"]); + await open(); + await option("Text size", "90%").click(); + await expect.poll(() => zoom(page)).toBe("0.9"); + await page.keyboard.press("Escape"); + await expect(settings).toHaveCount(0); + await expect.poll(() => zoom(page)).toBe("1"); + expect(await savedUi(page)).toEqual([]); + // Save keeps them, with the notification choices. + await open(); + await option("Theme", "Dark").click(); + await option("Text size", "125%").click(); + await expect(reset).toBeEnabled(); + await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) + .include(".settings") + .analyze() + .then((results) => expect(results.violations).toEqual([])); + await settings.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Settings saved")).toBeVisible(); + expect(await savedUi(page)).toEqual([ + expect.objectContaining({ theme: "dark", zoom: 1.25, notify: true }), + ]); + await settings.getByRole("button", { name: "Close", exact: true }).click(); + await expect.poll(() => both(page)).toEqual(["dark", "1.25"]); + expect( + await page.evaluate(() => [ + localStorage.getItem("shadow:theme"), + localStorage.getItem("shadow:zoom"), + ]), + ).toEqual(["dark", "1.25"]); + // Reset previews 100% until Save; leaving brings back the saved 125%. + await open(); + await reset.click(); + await expect.poll(() => zoom(page)).toBe("1"); + await expect(option("Text size", "100%")).toHaveAttribute( + "aria-checked", + "true", + ); + await page.keyboard.press("Escape"); + await expect.poll(() => zoom(page)).toBe("1.25"); + await page.reload(); + await expect(heading(page)).toBeVisible(); + await expect.poll(() => both(page)).toEqual(["dark", "1.25"]); +}); + +test("the command palette reaches every theme and the text size", async ({ + page, +}) => { + const run = async (command: string) => { + await page.keyboard.press("Control+k"); + const palette = page.getByRole("dialog", { name: "Command palette" }); + await palette + .getByRole("combobox", { name: "Search commands" }) + .fill(command); + await page.keyboard.press("Enter"); + await expect(palette).toHaveCount(0); + }; + await page.keyboard.press("Control+k"); + const palette = page.getByRole("dialog", { name: "Command palette" }); + await palette + .getByRole("combobox", { name: "Search commands" }) + .fill("Theme"); + const entry = (name: string) => + palette.getByRole("option", { name: `Theme: ${name}`, exact: true }); + await expect(entry("same as the system")).toBeVisible(); + await expect(entry("dark")).toBeVisible(); + await expect(entry("light")).toContainText("In use"); + await expect(entry("dark")).not.toContainText("In use"); + await page.keyboard.press("Escape"); + await run("Theme: dark"); + await expect.poll(() => theme(page)).toBe("dark"); + // System follows the computer's setting, both ways. + await page.emulateMedia({ colorScheme: "light" }); + await run("Theme: same as the system"); + await expect.poll(() => theme(page)).toBe("light"); + await page.emulateMedia({ colorScheme: "dark" }); + await expect.poll(() => theme(page)).toBe("dark"); + await page.emulateMedia({ colorScheme: "light" }); + await expect.poll(() => theme(page)).toBe("light"); + await run("Zoom in"); + await expect.poll(() => zoom(page)).toBe("1.1"); + await expect(status(page)).toHaveText("Text size 110%"); + await run("Zoom out"); + await run("Zoom out"); + await expect.poll(() => zoom(page)).toBe("0.9"); + await run("Reset zoom"); + await expect.poll(() => zoom(page)).toBe("1"); + await expect + .poll(() => savedUi(page).then((saved) => saved.at(-1))) + .toEqual({ zoom: 1 }); + expect((await savedUi(page)).slice(0, 2)).toEqual([ + { theme: "dark" }, + { theme: "system" }, + ]); +}); + +test("zoomed with CSS, the terminal and the editor put the pointer on the right text", async ({ + page, +}) => { + await page.evaluate(() => { + const fake = (window as any).__SHADOW_FAKE__; + fake.state.files["README.md"] = "alpha beta gamma\n"; + }); + for (let i = 0; i < 3; i++) await page.keyboard.press("Control+Equal"); + await expect.poll(() => zoom(page)).toBe("1.5"); + const drawer = page.getByRole("complementary", { name: "Drawer" }); + await page.keyboard.press("Control+`"); + const terminal = drawer.getByRole("group", { name: "Terminal 1" }); + await expect(terminal).toContainText("demo$"); + await terminal.click(); + const echoed = "one two three four five"; + await page.keyboard.type(`echo ${echoed}`); + await page.keyboard.press("Enter"); + const rows = terminal.locator(".xterm-rows > div"); + await expect( + rows.filter({ hasText: new RegExp(`^${echoed}\\s*$`) }), + ).toHaveCount(1); + // The terminal fits the window (its height is a share of it). + const screen = (await terminal.boundingBox())!; + expect(screen.y + screen.height).toBeLessThanOrEqual(1000); + // Double-click "four" on the second row: the selection covers that word, + // not one further right or lower (where unscaled maths would land). + const word = await rows + .filter({ hasText: new RegExp(`^${echoed}\\s*$`) }) + .evaluate((row) => { + const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode as Text; + const at = node.data.indexOf("four"); + if (at < 0) continue; + const range = document.createRange(); + range.setStart(node, at); + range.setEnd(node, at + "four".length); + const r = range.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height }; + } + throw new Error("echoed word not found"); + }); + await page.mouse.dblclick(word.x + word.width / 2, word.y + word.height / 2); + // xterm draws a first-row box and an empty one for the rows between. + const selection = () => + terminal.evaluate((element) => + [...element.querySelectorAll(".xterm-selection div")] + .map((div) => div.getBoundingClientRect()) + .filter((r) => r.height > 0) + .map((r) => ({ x: r.x, y: r.y, width: r.width })), + ); + await expect.poll(selection).toHaveLength(1); + const [selected] = await selection(); + expect(Math.abs(selected.x - word.x)).toBeLessThan(3); + expect(Math.abs(selected.y - word.y)).toBeLessThan(3); + expect(Math.abs(selected.width - word.width)).toBeLessThan(3); + // The editor: a click before "beta" puts the cursor there. + await drawer + .locator(".drawer-tabs") + .getByRole("tab", { name: "Files" }) + .click(); + await drawer.getByRole("button", { name: "README.md", exact: true }).click(); + const editor = drawer.getByRole("textbox", { name: "Edit README.md" }); + await expectEditorText(editor, "alpha beta gamma\n"); + const b = await editor.evaluate((element) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode as Text; + const at = node.data.indexOf("beta"); + if (at < 0) continue; + const range = document.createRange(); + range.setStart(node, at); + range.setEnd(node, at + 1); + const r = range.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height }; + } + throw new Error("beta not found"); + }); + await page.mouse.click(b.x + b.width / 4, b.y + b.height / 2); + await page.keyboard.type("X"); + await expectEditorText(editor, "alpha Xbeta gamma\n"); +}); diff --git a/ui/e2e/approvals.spec.ts b/ui/e2e/approvals.spec.ts index febfea4a..44d06003 100644 --- a/ui/e2e/approvals.spec.ts +++ b/ui/e2e/approvals.spec.ts @@ -73,6 +73,50 @@ test("a risky command is explained, tagged and never offered Always allow", asyn await expect(card).toHaveCount(0); }); +test("a new approval is announced once and its card is named for the action", async ({ + page, +}) => { + const milestones = page.locator(".milestones"); + await expect(milestones).toHaveAttribute("aria-live", "polite"); + // The conversation is a named log that does not read out streaming text, + // and the status bar only announces its state. + await expect(page.getByRole("log", { name: "Conversation" })).toHaveAttribute( + "aria-live", + "off", + ); + await expect(page.locator("footer.statusline")).not.toHaveAttribute( + "aria-live", + ); + await expect(page.locator("footer.statusline [aria-live]")).toHaveText( + "Ready", + ); + await page.evaluate(() => + (window as unknown as Fake).__SHADOW_FAKE__.requestApproval({ + command: "npm test", + preview: { kind: "command", command: "npm test", cwd: "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/work/demo" }, + }), + ); + const card = page.getByRole("group", { + name: "Approval needed: npm test", + }); + await expect(card).toBeVisible(); + await expect(milestones).toHaveText("Approval needed: npm test"); + // Later reads of the same pending approval say nothing new. + await page.evaluate(() => + (window as unknown as Fake).__SHADOW_FAKE__.requestApproval({ + command: "cargo build", + preview: { kind: "command", command: "cargo build", cwd: "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/work/demo" }, + }), + ); + await expect(milestones.locator("p")).toHaveText([ + "Approval needed: npm test", + "Approval needed: cargo build", + ]); + await card.getByRole("button", { name: "Deny", exact: true }).click(); + await expect(card).toHaveCount(0); + await expect(milestones.locator("p")).toHaveCount(2); +}); + test("Always allow here remembers the command, and Settings removes it", async ({ page, }) => { diff --git a/ui/e2e/automations.spec.ts b/ui/e2e/automations.spec.ts index c15672de..ea624214 100644 --- a/ui/e2e/automations.spec.ts +++ b/ui/e2e/automations.spec.ts @@ -1,4 +1,5 @@ import { test, expect, type Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; import { installFakeBackend } from "./fakeBackend"; import { installFakeTools } from "./fakeTools"; @@ -117,6 +118,60 @@ test("creates an automation, runs it now and shows its history", async ({ await expect(card).toContainText("Next: in 30 min"); }); +async function createAutomation(page: Page, name: string) { + const tools = await openTool(page, "Automations"); + await tools.getByRole("button", { name: "New automation" }).click(); + const form = tools.getByRole("form", { name: "New automation" }); + await form.getByLabel("Name").fill(name); + await form.getByLabel("What should it do?").fill("Review the commits."); + await form.getByRole("button", { name: "Create automation" }).click(); + return tools; +} + +test("Delete asks in the app's own dialog", async ({ page }) => { + await start(page); + const tools = await createAutomation(page, "Morning review"); + const card = tools.getByRole("article", { name: "Morning review" }); + await card.getByRole("button", { name: "Delete" }).click(); + const confirm = page.getByRole("dialog", { + name: "Delete this automation?", + }); + await expect(confirm).toContainText( + "“Morning review” and its run history are removed.", + ); + const axe = await new AxeBuilder({ page }) + .include('[role="dialog"]') + .withTags(["wcag2a", "wcag2aa"]) + .analyze(); + expect(axe.violations).toEqual([]); + await confirm.getByRole("button", { name: "Cancel" }).click(); + await expect(confirm).toHaveCount(0); + await expect(card).toBeVisible(); + await card.getByRole("button", { name: "Delete" }).click(); + await confirm.getByRole("button", { name: "Delete automation" }).click(); + await expect(card).toHaveCount(0); + await expect(page.getByText("Automation deleted")).toBeVisible(); + await expect(tools.getByText("No automations yet")).toBeVisible(); + expect( + (await fakeLog(page)).filter( + (r) => r.method === "DELETE" && r.path.startsWith("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/api/automations/"), + ), + ).toHaveLength(1); +}); + +test("a notification waits while the pointer is on it", async ({ page }) => { + await start(page); + await createAutomation(page, "Morning review"); + // Notifications stay for 5 s, but not while pointed at; afterwards they + // finish the time they had left. + const toast = page.locator(".toast", { hasText: "Automation created" }); + await toast.hover(); + await page.waitForTimeout(6000); + await expect(toast).toBeVisible(); + await page.mouse.move(0, 0); + await expect(toast).toHaveCount(0, { timeout: 7000 }); +}); + test("starts a task from an issue and offers a pull request that closes it", async ({ page, }) => { diff --git a/ui/e2e/composer.spec.ts b/ui/e2e/composer.spec.ts index 1bb35e4a..6f8022c6 100644 --- a/ui/e2e/composer.spec.ts +++ b/ui/e2e/composer.spec.ts @@ -97,6 +97,85 @@ test("@ attaches files, effort and mode reach the engine, ↑ recalls the prompt await expect(prompt(page)).toHaveValue(""); }); +test("/ menu: Enter picks the highlighted command, the next Enter runs it", async ({ + page, +}) => { + await prompt(page).pressSequentially("/mod"); + const menu = page.getByRole("listbox", { name: "Slash commands" }); + const options = menu.getByRole("option"); + // Name matches first, then commands whose description mentions it. + await expect(options.locator("strong")).toHaveText([ + "/model", + "/models", + "/status", + ]); + // The field names the menu and its highlighted row for screen readers. + const id = async (n: number) => (await options.nth(n).getAttribute("id"))!; + await expect(prompt(page)).toHaveAttribute( + "aria-controls", + (await menu.getAttribute("id"))!, + ); + await expect(prompt(page)).toHaveAttribute( + "aria-activedescendant", + await id(0), + ); + await expect(options.nth(0)).toHaveAttribute("aria-selected", "true"); + await prompt(page).press("ArrowDown"); + await expect(prompt(page)).toHaveAttribute( + "aria-activedescendant", + await id(1), + ); + await prompt(page).press("ArrowUp"); + await prompt(page).press("Enter"); + await expect(prompt(page)).toHaveValue("/model "); + await expect(menu).toHaveCount(0); + await expect(prompt(page)).not.toHaveAttribute("aria-activedescendant"); + const search = page.getByRole("combobox", { name: "Search models" }); + await expect(search).toHaveCount(0); + // With the menu closed, Enter runs the command: /model opens the picker. + await prompt(page).press("Enter"); + await expect(search).toBeVisible(); + await search.press("Escape"); + await expect(search).toHaveCount(0); + expect(await jobPosts(page)).toEqual([]); + // Part of a name finds the command too. + await prompt(page).pressSequentially("/view"); + await expect(options.locator("strong")).toHaveText(["/review"]); +}); + +test("screen readers hear when an answer finishes, a task fails or is stopped", async ({ + page, +}) => { + const said = page.locator(".milestones p"); + await chooseLocal(page); + await runTask(page, "Fix the add function"); + await expect(said.last()).toHaveText("Answer finished"); + await page.evaluate(() => { + (window as unknown as Fake).__SHADOW_FAKE__.state.failNext = + "Model provider returned HTTP 500"; + }); + await prompt(page).fill("Try once more"); + await send(page).click(); + await expect(said.last()).toHaveText("Task failed", { timeout: 15000 }); + // Held mid-way, then stopped. + await page.evaluate(() => { + const { state } = (window as unknown as Fake).__SHADOW_FAKE__; + state.held = state.jobs.map((j: { session_id: string }) => j.session_id); + }); + await prompt(page).fill("Keep going"); + await send(page).click(); + await page.getByRole("button", { name: "Stop task" }).click(); + await page.evaluate(() => { + (window as unknown as Fake).__SHADOW_FAKE__.state.held = []; + }); + await expect(said.last()).toHaveText("Task stopped", { timeout: 15000 }); + expect(await said.allTextContents()).toEqual([ + "Answer finished", + "Task failed", + "Task stopped", + ]); +}); + test("Only change these limits edits to the mentioned files", async ({ page, }) => { diff --git a/ui/e2e/editor.spec.ts b/ui/e2e/editor.spec.ts index 1053e92b..c5c67e0b 100644 --- a/ui/e2e/editor.spec.ts +++ b/ui/e2e/editor.spec.ts @@ -47,7 +47,7 @@ async function openFile(page: Page, path: string) { await page.getByRole("button", { name: "Review changes" }).click(); await drawer .locator(".drawer-tabs") - .getByRole("button", { name: "Files" }) + .getByRole("tab", { name: "Files" }) .click(); await drawer.getByRole("button", { name: path, exact: true }).click(); const editor = drawer.getByRole("textbox", { diff --git a/ui/e2e/endpoints.spec.ts b/ui/e2e/endpoints.spec.ts new file mode 100644 index 00000000..d65b91a7 --- /dev/null +++ b/ui/e2e/endpoints.spec.ts @@ -0,0 +1,151 @@ +import { test, expect, type Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { installFakeBackend } from "./fakeBackend"; + +// Settings › Accounts › Your own endpoints: connect a server, add its models +// to the picker, test, use and remove them, against the deterministic fake +// engine (an Ollama server on this computer and a keyed cloud API). +type Call = { method: string; path: string; body: any }; + +async function start(page: Page) { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + (page as Page & { errors?: string[] }).errors = errors; + await page.addInitScript(installFakeBackend, { stepMs: 60 }); + await page.goto("/"); + await expect( + page.getByRole("heading", { name: "What should we work on?" }), + ).toBeVisible(); +} +test.afterEach(async ({ page }) => { + expect((page as Page & { errors?: string[] }).errors).toEqual([]); +}); + +const fakeLog = (page: Page) => + page.evaluate( + () => + (window as unknown as { __SHADOW_FAKE__: { log: Call[] } }) + .__SHADOW_FAKE__.log, + ); + +async function openCard(page: Page) { + await page.keyboard.press("Control+,"); + const settings = page.getByRole("dialog", { name: "Settings" }); + await settings.getByRole("button", { name: "Accounts", exact: true }).click(); + const card = settings.getByRole("article", { name: "Your own endpoints" }); + await card.scrollIntoViewIfNeeded(); + return { settings, card }; +} + +async function accessible(page: Page) { + for (const theme of ["light", "dark"]) { + await page.evaluate( + (t) => (document.documentElement.dataset.theme = t), + theme, + ); + const results = await new AxeBuilder({ page }) + .include('[role="dialog"]') + .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) + .analyze(); + expect(results.violations).toEqual([]); + } +} + +test("a server on this computer and a keyed cloud API join the picker", async ({ + page, +}) => { + await start(page); + const { settings, card } = await openCard(page); + await expect(card).toContainText("No endpoints added yet."); + + // Ollama on this computer: the address is filled in and no key is needed. + await card.getByRole("button", { name: "Add an endpoint…" }).click(); + const server = card.getByRole("combobox", { name: "Server" }); + await expect(server).toBeFocused(); + await expect(card.getByRole("textbox", { name: "Address" })).toHaveValue( + "http://127.0.0.1:11434/v1", + ); + await card.getByRole("button", { name: "Find models" }).click(); + const models = card.getByRole("group", { name: "Ollama lists 2 models" }); + await models.getByRole("checkbox", { name: "qwen3-coder:30b" }).check(); + await accessible(page); + await models.getByRole("button", { name: "Add 1 to the picker" }).click(); + await expect( + page.getByText("Added 1 model to the model picker."), + ).toBeVisible(); + const added = card.getByRole("list", { name: "Added models" }); + await expect(added).toContainText("qwen3-coder:30b"); + await expect(added).toContainText("Ollama · 127.0.0.1:11434"); + + // Test answers in place. + await added.getByRole("button", { name: "Test qwen3-coder:30b" }).click(); + await expect(added.getByRole("status")).toHaveText("Answered in 0.4 s"); + + // A cloud API: the wrong (missing) key is refused with the server's + // answer, then the typed key lists its models. A bare host gains /v1. + await card.getByRole("button", { name: "Add an endpoint…" }).click(); + await server.selectOption({ label: "OpenAI or another compatible API" }); + const address = card.getByRole("textbox", { name: "Address" }); + await address.fill("https://api.example.com"); + await card.getByRole("button", { name: "Find models" }).click(); + await expect(card.getByRole("alert")).toHaveText( + "https://api.example.com/v1 answered 401; check the API key", + ); + await expect( + card.getByRole("textbox", { name: "Or add a model by its exact name" }), + ).toBeVisible(); + await card.getByLabel("API key", { exact: true }).fill("sk-example-good"); + await card.getByRole("button", { name: "Find models" }).click(); + await expect(address).toHaveValue("https://api.example.com/v1"); + await card + .getByRole("group", { name: "OpenAI-compatible lists 2 models" }) + .getByRole("checkbox", { name: "example-coder" }) + .check(); + await card.getByRole("button", { name: "Add 1 to the picker" }).click(); + await expect(added).toContainText("example-coder"); + await expect(added).toContainText("OpenAI-compatible · api.example.com"); + const addCall = (await fakeLog(page)).find( + (r) => + r.path === "/api/models/endpoints" && + r.body.models?.includes("example-coder"), + ); + // The key name is the engine's choice for this address; the key itself + // goes out only in this request. + expect(addCall?.body).toMatchObject({ + provider: "openai_compatible", + endpoint: "https://api.example.com/v1", + api_key: "sk-example-good", + api_key_env: "EXAMPLE_API_KEY", + models: ["example-coder"], + }); + await expect(card.getByLabel("API key", { exact: true })).toHaveCount(0); + + // Both rows are in the picker, in their groups. + await settings.getByRole("button", { name: "Close" }).first().click(); + await page.getByRole("button", { name: /Model for this task/ }).click(); + const search = page.getByRole("combobox", { name: "Search models" }); + await search.fill("example-coder"); + await expect( + page.getByRole("option", { name: /example-coder/ }), + ).toContainText("Cloud"); + await search.fill("qwen3-coder"); + await expect( + page.getByRole("option", { name: /qwen3-coder:30b · This computer/ }), + ).toContainText("Local"); + await search.press("Enter"); + await expect( + page.getByRole("button", { name: /Model for this task/ }), + ).toContainText("qwen3-coder:30b"); + + // Removing asks first and keeps the saved key. + const again = await openCard(page); + await again.card + .getByRole("button", { name: "Remove example-coder" }) + .click(); + const confirm = page.getByRole("dialog", { name: "Remove example-coder" }); + await expect(confirm).toContainText("A saved API key stays"); + await confirm.getByRole("button", { name: "Remove" }).click(); + await expect( + again.card.getByRole("list", { name: "Added models" }), + ).not.toContainText("example-coder"); +}); diff --git a/ui/e2e/fakeBackend.ts b/ui/e2e/fakeBackend.ts index f29f3877..b50c6c8b 100644 --- a/ui/e2e/fakeBackend.ts +++ b/ui/e2e/fakeBackend.ts @@ -49,6 +49,15 @@ export type FakeOptions = { spendLimitOnApi?: boolean; /** Tasks on a paid API row meet a busy provider twice before answering. */ retryOnApi?: boolean; + /** Earlier conversations in the project, by title, newest first (s2, + * s3, …), all older than the open one. */ + conversations?: string[]; + /** The `ui` settings group survives a reload of the window, as the + * engine's config file does (kept in this tab's session storage). */ + keepUi?: boolean; + /** Tasks first search the project and find this many matches (a long + * tool output). */ + searchHits?: number; }; export function installFakeBackend(options: FakeOptions = {}) { @@ -365,10 +374,41 @@ export function installFakeBackend(options: FakeOptions = {}) { }, }, network: { mode: options.network || "online" }, - ui: { theme: "light", notify: true }, + ui: { + theme: "light", + notify: true, + ...(options.keepUi + ? JSON.parse(sessionStorage.getItem("shadow-fake-config-ui") || "{}") + : {}), + }, cli_agents: { enabled: true }, guardian: { enabled: false, interval_sec: 3600 }, limits: { on_limit: "local", fallback_model: "" }, + // The engine's defaults (config.rs). `subagents` is returned only + // once it has been saved; until then the engine uses its defaults. + agent: { + max_steps: 64, + tool_timeout_sec: 60, + parallel_reads: true, + compact_ratio: 0.7, + model_retries: 3, + retry_backoff_sec: 1.0, + max_fix_retries: 3, + max_output_bytes: 256_000, + max_task_tokens: 1_000_000, + autonomy_profile: "normal", + summary_compaction: true, + summary_timeout_sec: 60, + stuck_check: true, + }, + checkpoints: { + shell: true, + vendor: true, + keep: 200, + max_copy_files: 5000, + max_copy_bytes: 67_108_864, + }, + verification: { commands: [] as string[] }, }, vendors: { codex: { @@ -630,6 +670,10 @@ export function installFakeBackend(options: FakeOptions = {}) { : null) as null | Json, fetched_at: (options.openrouterKey ? now() - 600 : null) as null | number, }, + /** Your own endpoints (POST /api/models/endpoints): registered models + * and the keys saved for them (never served back). */ + endpoints: [] as Json[], + endpointKeys: {} as Record, /** Paid-model spending today (GET /api/spending). */ spentToday: 0.42, jobs: [] as Json[], @@ -757,6 +801,16 @@ export function installFakeBackend(options: FakeOptions = {}) { })), }, }; + (options.conversations || []).forEach((title, i) => + state.sessions.push({ + id: `s${i + 2}`, + workspace, + status: "idle", + title, + updated_at: now() - 60 * (i + 1), + target: null, + }), + ); if (options.firstRun) { state.onboarded = false; state.local.models = []; @@ -1033,6 +1087,113 @@ export function installFakeBackend(options: FakeOptions = {}) { }, }; } + /** Two servers the endpoint form can reach: Ollama on this computer + * (no key) and a cloud API that wants `sk-example-good`. */ + const endpointServers: Record = { + "http://127.0.0.1:11434/v1": { + key: "", + models: ["qwen3-coder:30b", "gemma4:12b"], + }, + "https://api.example.com/v1": { + key: "sk-example-good", + models: ["example-coder", "example-mini"], + }, + }; + const endpointLabels: Record = { + ollama: "Ollama", + local: "LM Studio / local", + llamacpp: "llama.cpp", + vllm: "vLLM", + grok: "Grok / xAI", + openai_compatible: "OpenAI-compatible", + }; + function endpointAddress(raw: string) { + let url: URL; + try { + url = new URL(String(raw || "").trim()); + } catch { + throw new Error( + JSON.stringify({ + error: `Enter a web address such as http://127.0.0.1:11434/v1; found ${JSON.stringify(raw)}`, + }), + ); + } + if (url.pathname === "/" || url.pathname === "") url.pathname = "/v1"; + return url.toString().replace(/\/$/, ""); + } + function endpointKeyName(provider: string, endpoint: string) { + if (provider === "grok") return "XAI_API_KEY"; + if (provider === "ollama") return "OLLAMA_API_KEY"; + if (provider !== "openai_compatible") return "OPENAI_API_KEY"; + const host = new URL(endpoint).hostname; + const labels = host.split("."); + if (labels.length < 2 || /^[\d.]+$/.test(host)) return "OPENAI_API_KEY"; + return `${labels[labels.length - 2].toUpperCase()}_API_KEY`; + } + function endpointRow(m: Json) { + const url = new URL(m.endpoint); + const local = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname); + const offline = state.config.network?.mode === "offline"; + const missingKey = m.key_required && !state.endpointKeys[m.api_key_env]; + return { + id: m.id, + provider: m.provider, + account: "", + model: m.name, + route: "native", + group: local ? "local" : "api", + name: local ? `${m.name} · This computer` : m.name, + subtitle: `${endpointLabels[m.provider] || "OpenAI-compatible"} · ${url.host}`, + inference: local ? "local" : "cloud", + availability: + offline && !local ? "unavailable" : missingKey ? "sign_in" : "ready", + availability_label: + offline && !local + ? "Unavailable" + : missingKey + ? "Add API key" + : "Ready", + reason: + offline && !local + ? "Offline mode: only models on this computer run" + : missingKey + ? `Add the API key for ${url.host} in Settings › Accounts` + : "", + featured: false, + vision: Boolean(m.vision), + tools: true, + is_default: false, + usage: local + ? { + ...unknownUsage, + state: "local", + label: "Runs on this computer · No subscription quota", + } + : { + ...unknownUsage, + state: "api_key", + label: "Your endpoint · billed by the provider", + }, + endpoint: m.endpoint, + custom: true, + }; + } + function endpointTarget(body: Json) { + const provider = String(body?.provider || ""); + if (!endpointLabels[provider]) + throw new Error( + JSON.stringify({ + error: + "Choose a provider: Ollama, LM Studio, llama.cpp, vLLM, xAI or OpenAI-compatible", + }), + ); + const endpoint = endpointAddress(body?.endpoint); + const keyName = + String(body?.api_key_env || "").trim() || + endpointKeyName(provider, endpoint); + return { provider, endpoint, keyName }; + } + function pickerTargets() { const rows: Json[] = []; for (const [key, v] of Object.entries(state.vendors) as [string, Json][]) { @@ -1041,6 +1202,7 @@ export function installFakeBackend(options: FakeOptions = {}) { else rows.push(vendorRow(key, v)); } for (const m of openrouterModels) rows.push(openrouterRow(m)); + for (const m of state.endpoints) rows.push(endpointRow(m)); for (const m of state.local.models as Json[]) rows.push({ id: m.id, @@ -1219,7 +1381,8 @@ export function installFakeBackend(options: FakeOptions = {}) { state.cursor += 1; state.events.push({ id: state.cursor, - ts: now(), + // Seconds with a fraction, as the engine records events. + ts: Date.now() / 1000, type, payload, session_id: sessionId, @@ -1313,6 +1476,40 @@ export function installFakeBackend(options: FakeOptions = {}) { ], ] : []; + /** A search with many matches first (searchHits), shaped as the + * engine's search_text result. */ + const searchOutput = { + matches: Array.from({ length: options.searchHits || 0 }, (_, i) => ({ + path: `src/module${Math.floor(i / 10)}.ts`, + line: (i % 10) + 1, + text: `export const add${i} = (a, b) => a + b;`, + })), + truncated: false, + scanned: options.searchHits || 0, + }; + const searchSteps: [string, Json][] = options.searchHits + ? [ + [ + "tool.started", + { + tool: "search_text", + call_id: "c0", + arguments: { query: "export const add" }, + }, + ], + [ + "tool.completed", + { + tool: "search_text", + call_id: "c0", + success: true, + output: searchOutput, + output_preview: JSON.stringify(searchOutput).slice(0, 2000), + arguments: { query: "export const add" }, + }, + ], + ] + : []; const steps: [string, Json][] = [ ...(local && options.localStartup ? ([ @@ -1387,6 +1584,7 @@ export function installFakeBackend(options: FakeOptions = {}) { }, }, ], + ...searchSteps, [ "tool.started", { tool: "read_file", call_id: "c1", arguments: { path: "src/app.ts" } }, @@ -2805,6 +3003,11 @@ export function installFakeBackend(options: FakeOptions = {}) { const values = body.values || {}; for (const [k, v] of Object.entries(values)) state.config[k] = { ...(state.config[k] || {}), ...(v as Json) }; + if (options.keepUi) + sessionStorage.setItem( + "shadow-fake-config-ui", + JSON.stringify(state.config.ui), + ); } return state.config; } @@ -3071,6 +3274,102 @@ export function installFakeBackend(options: FakeOptions = {}) { state.openrouter.fetched_at = now(); return openrouterStatus(); } + if (path === "/api/providers/models" && method === "POST") { + const { provider, endpoint, keyName } = endpointTarget(body); + const server = endpointServers[endpoint]; + const local = endpoint.startsWith("http://127.0.0.1"); + if (state.config.network?.mode === "offline" && !local) + throw new Error( + JSON.stringify({ + error: "Offline mode: only servers on this computer can be reached", + }), + ); + if (!server) + throw new Error( + JSON.stringify({ + error: `Could not reach ${endpoint}: nothing is listening there, or the network refused the connection`, + }), + ); + const typed = String(body?.api_key || "").trim(); + const key = typed || state.endpointKeys[keyName] || ""; + if (server.key && key !== server.key) + throw new Error( + JSON.stringify({ + error: `${endpoint} answered 401; check the API key`, + }), + ); + return { + ok: true, + provider, + label: endpointLabels[provider], + endpoint, + api_key_env: keyName, + key_saved: Boolean(state.endpointKeys[keyName]), + local, + models: server.models.map((id) => ({ + id, + name: id, + size_bytes: 0, + context_limit: 0, + capabilities: { completion: true }, + detail: "", + })), + }; + } + if (path === "/api/models/endpoints" && method === "POST") { + const { provider, endpoint, keyName } = endpointTarget(body); + const names = [ + ...new Set( + ((body?.models || []) as string[]) + .map((n) => String(n).trim()) + .filter(Boolean), + ), + ]; + if (!names.length) + throw new Error(JSON.stringify({ error: "Choose at least one model" })); + const typed = String(body?.api_key || "").trim(); + if (typed) state.endpointKeys[keyName] = typed; + const added = names.map((name) => { + const id = `model:${provider}:${endpoint}:${name}`; + const model = { + id, + provider, + endpoint, + name, + api_key_env: keyName, + key_required: Boolean(state.endpointKeys[keyName]), + vision: body?.vision === true, + }; + state.endpoints = [ + ...state.endpoints.filter((m: Json) => m.id !== id), + model, + ]; + return { + default: id, + name, + provider, + endpoint, + api_key_env: keyName, + }; + }); + return { ok: true, models: added }; + } + if (path === "/api/models/remove" && method === "POST") { + const before = state.endpoints.length; + state.endpoints = state.endpoints.filter((m: Json) => m.id !== body?.id); + if (state.endpoints.length === before) + throw new Error( + JSON.stringify({ error: "That model is not in the registry" }), + ); + return { ok: true, removed: 1 }; + } + if (path === "/api/models/test" && method === "POST") + return { + ok: true, + reply: "I can respond.", + latency_ms: 420, + capabilities: { completion: true }, + }; if (path === "/api/openrouter/refresh" && method === "POST") { state.openrouter.fetched_at = now(); return openrouterStatus(); @@ -3167,13 +3466,50 @@ export function installFakeBackend(options: FakeOptions = {}) { return { ok: true, local_engine: state.local }; if (path === "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/api/sessions" && method === "GET") { const all = ["true", "1"].includes(q.get("include_compare") || ""); + const search = (q.get("q") || "").trim().toLowerCase(); + // Like the engine: titles, prompts and answers; an excerpt of the + // newest prompt or answer that matched unless the title shows it. + const found = (s: Json) => { + if (!search) return { ok: true }; + if ( + String(s.title || "") + .toLowerCase() + .includes(search) + ) + return { ok: true }; + for (const job of [...state.jobs].reverse()) { + if (job.session_id !== s.id) continue; + for (const [field, text] of [ + ["prompt", job.task], + ["answer", job.result?.summary ?? job.summary], + ] as [string, string | undefined][]) { + const at = String(text || "") + .toLowerCase() + .indexOf(search); + if (at < 0) continue; + return { + ok: true, + match: { + field, + snippet: String(text), + start: at, + end: at + search.length, + }, + }; + } + } + return { ok: false }; + }; return { sessions: state.sessions .filter((s: Json) => all || !s.compare_id) - .map((s: Json) => ({ + .map((s: Json): { s: Json; hit: Json } => ({ s, hit: found(s) })) + .filter(({ hit }: { hit: Json }) => hit.ok) + .map(({ s, hit }: { s: Json; hit: Json }) => ({ ...s, compare_id: s.compare_id || null, compare_lane: s.compare_lane || null, + ...(hit.match ? { match: hit.match } : {}), })), }; } @@ -3992,7 +4328,31 @@ export function installFakeBackend(options: FakeOptions = {}) { } return { ...approval, pending: false }; } - if (path === "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/api/commands") return { commands: [] }; + if (path === "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/api/commands") + // A few of the engine's built-ins, in its order, for the / menu. + return { + commands: [ + ["help", "List native commands and project workflows", ""], + [ + "status", + "Show the selected model, permissions, and active work", + "", + ], + ["model", "Show or select a registered model", "[id]"], + ["models", "List registered and detected models", ""], + ["plan", "Plan a task with read-only tools", ""], + ["review", "Review project changes with read-only tools", "[focus]"], + ["diff", "Show pending changes", "[path]"], + ["new", "Start a new conversation", ""], + ].map(([name, description, arg_spec]) => ({ + name, + description, + arg_spec, + alias: "", + source: "builtin", + kind: "builtin", + })), + }; if (path === "/api/workspace/git") { const lane = laneFor(state.selected); return { diff --git a/ui/e2e/fakePerformance.ts b/ui/e2e/fakePerformance.ts index 3eb8d14e..4286927e 100644 --- a/ui/e2e/fakePerformance.ts +++ b/ui/e2e/fakePerformance.ts @@ -8,6 +8,8 @@ export function installFakePerformance(options: { initialReplyChars?: number; intervalMs?: number; initialReplyText?: string; + /** Events in the conversation's latest window (history pages stay 128). */ + windowEvents?: number; }) { const scope = window as any; const inner = scope.__SHADOW_TEST_TRANSPORT__; @@ -42,9 +44,9 @@ export function installFakePerformance(options: { let timer: ReturnType | undefined; let job: any = null; let fragments = 0; - const page = (through: number) => { + const page = (through: number, size = pageSize) => { const events = saved.slice( - Math.max(0, Math.min(count, through) - pageSize), + Math.max(0, Math.min(count, through) - size), Math.min(count, through), ); return { @@ -98,7 +100,7 @@ export function installFakePerformance(options: { path === `/api/sessions/${session.id}/activate` ) { const result = await inner.request(fullPath, method, body); - const latest = page(count); + const latest = page(count, options.windowEvents ?? pageSize); return record(fullPath, { ...result, events: latest.events, diff --git a/ui/e2e/find.spec.ts b/ui/e2e/find.spec.ts new file mode 100644 index 00000000..e773840d --- /dev/null +++ b/ui/e2e/find.spec.ts @@ -0,0 +1,206 @@ +import { test, expect, type Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { installFakeBackend } from "./fakeBackend"; +import { installFakePerformance } from "./fakePerformance"; + +// Find in conversation (Ctrl+F) over a 1,000-message conversation: the +// latest 400 events load with the conversation (the transcript draws its +// latest 150 rows of them) and the rest are five saved history pages. +test.beforeEach(async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + (page as Page & { errors?: string[] }).errors = errors; + await page.addInitScript(installFakeBackend); + await page.addInitScript(installFakePerformance, { + messages: 1000, + windowEvents: 400, + }); + await page.goto("/"); + await page.locator('.task-link[data-session-id="s1"]').click(); + await expect(rows(page).last()).toContainText("Saved message 01000."); +}); +test.afterEach(async ({ page }) => { + expect((page as Page & { errors?: string[] }).errors).toEqual([]); +}); + +const rows = (page: Page) => + page.locator(".msg-user .bubble, .msg-agent .markdown"); +const message = (page: Page, n: number) => + rows(page).filter({ + hasText: `Saved message ${String(n).padStart(5, "0")}.`, + }); +const findBox = (page: Page) => + page.getByRole("textbox", { name: "Find in conversation" }); +const count = (page: Page) => page.locator(".find-bar [role=status]"); +/** The text the current-match highlight covers ("" without one). */ +const currentHighlight = (page: Page) => + page.evaluate(() => { + const highlight = CSS.highlights.get("shadow-find-current"); + return highlight ? [...highlight].map(String).join("|") : ""; + }); + +test("Ctrl+F finds text in rows not drawn yet and in saved pages", async ({ + page, +}) => { + const composer = page.getByRole("textbox", { name: "Message ShadowCode" }); + await composer.focus(); + await page.keyboard.press("Control+f"); + await expect(findBox(page)).toBeFocused(); + + await findBox(page).fill("message 00990"); + await expect(count(page)).toHaveText("1 of 1"); + await expect(message(page, 990)).toBeInViewport(); + await expect.poll(() => currentHighlight(page)).toBe("message 00990"); + + // In the loaded conversation but outside the 150 rows drawn. + await expect(message(page, 700)).toHaveCount(0); + await findBox(page).fill("MESSAGE 00700"); + await expect(count(page)).toHaveText("1 of 1"); + await expect(message(page, 700)).toBeInViewport(); + await expect.poll(() => currentHighlight(page)).toBe("message 00700"); + + // Answers match as they read (no Markdown markers) and every drawn match + // is highlighted. A new query starts at the latest match where you are + // reading; stepping wraps into the oldest saved page and back. + await findBox(page).fill("with emphasis, code"); + await expect(count(page)).toHaveText("500 of 500"); + await expect + .poll(() => + page.evaluate(() => CSS.highlights.get("shadow-find")?.size || 0), + ) + .toBeGreaterThan(30); + await findBox(page).press("Enter"); + await expect(count(page)).toHaveText("1 of 500"); + await expect(page.getByText("Browsing saved history.")).toBeVisible(); + await expect(message(page, 2)).toBeInViewport(); + await findBox(page).press("Shift+Enter"); + await expect(count(page)).toHaveText("500 of 500"); + await expect(page.getByText("Browsing saved history.")).toHaveCount(0); + await expect(message(page, 1000)).toBeInViewport(); + + // Only in a saved page: it opens, like Older messages would. + await findBox(page).fill("message 00042"); + await expect(count(page)).toHaveText("1 of 1"); + await expect(page.getByText("Browsing saved history.")).toBeVisible(); + await expect(message(page, 42)).toBeInViewport(); + await expect.poll(() => currentHighlight(page)).toBe("message 00042"); + + // Nothing on this page: the latest match, back in the latest messages. + await findBox(page).fill("saved message 0099"); + await expect(count(page)).toHaveText("10 of 10"); + await expect(message(page, 999)).toBeInViewport(); + await findBox(page).press("Enter"); + await expect(count(page)).toHaveText("1 of 10"); + await expect(message(page, 990)).toBeInViewport(); + await page.keyboard.press("Shift+F3"); + await expect(count(page)).toHaveText("10 of 10"); + await page.keyboard.press("Shift+F3"); + await expect(count(page)).toHaveText("9 of 10"); + await page.keyboard.press("F3"); + await expect(count(page)).toHaveText("10 of 10"); + await page.getByRole("button", { name: "Previous match" }).click(); + await expect(count(page)).toHaveText("9 of 10"); + await expect(message(page, 998)).toBeInViewport(); + + await findBox(page).fill("nothing like this"); + await expect(count(page)).toHaveText("No matches"); + await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled(); + + // Escape closes find and puts focus back in the composer. + await findBox(page).press("Escape"); + await expect(page.locator(".find-bar")).toHaveCount(0); + await expect(composer).toBeFocused(); + expect(await currentHighlight(page)).toBe(""); +}); + +test("the palette and Ctrl+F open find; another conversation closes it", async ({ + page, +}) => { + await page.keyboard.press("Control+k"); + await page.getByLabel("Search commands").fill("Find in"); + await page.keyboard.press("Enter"); + await expect(findBox(page)).toBeFocused(); + await findBox(page).fill("message 00999"); + await expect(count(page)).toHaveText("1 of 1"); + + // Ctrl+F again (from elsewhere) selects the query to type over. + await page.getByRole("textbox", { name: "Message ShadowCode" }).focus(); + await page.keyboard.press("Control+f"); + await expect(findBox(page)).toBeFocused(); + expect( + await findBox(page).evaluate((input: HTMLInputElement) => [ + input.selectionStart, + input.selectionEnd, + ]), + ).toEqual([0, "message 00999".length]); + + await page.keyboard.press("Control+n"); + await expect(page.locator(".find-bar")).toHaveCount(0); + await page.keyboard.press("Control+f"); + await expect(findBox(page)).toHaveValue(""); +}); + +test("find reads well in both themes and passes axe", async ({ page }) => { + await page.keyboard.press("Control+f"); + await findBox(page).fill("Saved message 0099"); + await expect(count(page)).toHaveText("10 of 10"); + for (const theme of ["light", "dark"]) { + await page.evaluate( + (t) => (document.documentElement.dataset.theme = t), + theme, + ); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) + .include(".find-bar") + .analyze(); + expect(results.violations).toEqual([]); + await page.locator(".stage").screenshot({ + path: `test-results/find-${theme}.png`, + }); + } +}); + +test("scrolling to a match is instant when reduced motion is asked for", async ({ + page, +}) => { + await page.evaluate(() => { + const scope = window as unknown as { scrolls: string[] }; + scope.scrolls = []; + const original = Element.prototype.scrollTo; + Element.prototype.scrollTo = function (this: Element, ...args: unknown[]) { + const options = args[0] as ScrollToOptions | undefined; + if (options?.behavior) scope.scrolls.push(options.behavior); + return (original as (...a: unknown[]) => void).apply(this, args); + }; + }); + const scrolls = () => + page.evaluate(() => (window as unknown as { scrolls: string[] }).scrolls); + await page.keyboard.press("Control+f"); + await findBox(page).fill("message 00900"); + await expect(message(page, 900)).toBeInViewport(); + expect(await scrolls()).toContain("smooth"); + + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.evaluate( + () => ((window as unknown as { scrolls: string[] }).scrolls = []), + ); + await findBox(page).fill("message 00700"); + await expect(message(page, 700)).toBeInViewport(); + expect(await scrolls()).toContain("auto"); + expect(await scrolls()).not.toContain("smooth"); +}); + +test("without the highlight API the match's row is marked", async ({ + page, +}) => { + await page.evaluate(() => { + delete (window as unknown as { Highlight?: unknown }).Highlight; + }); + await page.keyboard.press("Control+f"); + await findBox(page).fill("message 00800"); + await expect(count(page)).toHaveText("1 of 1"); + const marked = page.locator('[data-find-current="row"]'); + await expect(marked).toHaveCount(1); + await expect(marked).toContainText("Saved message 00800."); + await expect(message(page, 800)).toBeInViewport(); +}); diff --git a/ui/e2e/palette.spec.ts b/ui/e2e/palette.spec.ts new file mode 100644 index 00000000..d4cc1f5a --- /dev/null +++ b/ui/e2e/palette.spec.ts @@ -0,0 +1,226 @@ +import { test, expect, type Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { installFakeBackend } from "./fakeBackend"; + +// The command palette (Ctrl+K): fuzzy search over commands, conversations +// and projects, the commands run last first, and the combobox keyboard +// model, against the production build and the fake engine. +test.beforeEach(async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + (page as Page & { errors?: string[] }).errors = errors; + await page.addInitScript(installFakeBackend, { + stepMs: 60, + conversations: [ + "Fix the login redirect", + "Add dark mode to settings", + "Speed up the test suite", + ], + }); + await page.goto("/"); + await expect( + page.getByRole("heading", { name: "What should we work on?" }), + ).toBeVisible(); +}); +test.afterEach(async ({ page }) => { + expect((page as Page & { errors?: string[] }).errors).toEqual([]); +}); + +const palette = (page: Page) => + page.getByRole("dialog", { name: "Command palette" }); +const search = (page: Page) => + palette(page).getByRole("combobox", { name: "Search commands" }); +const options = (page: Page) => palette(page).getByRole("option"); +const active = (page: Page) => + palette(page).locator('[role="option"][aria-selected="true"]'); +const group = (page: Page, name: string) => + palette(page).getByRole("group", { name }); +const panel = (page: Page, name: string) => + page + .getByRole("complementary", { name: "Drawer" }) + .getByRole("tablist", { name: "Panels" }) + .getByRole("tab", { name, exact: true }); + +async function openPalette(page: Page) { + await page.keyboard.press("Control+k"); + await expect(search(page)).toBeFocused(); +} + +/** The field points at the active option, which is the only selected one. */ +async function expectActive(page: Page, name: string) { + await expect(active(page)).toHaveCount(1); + await expect(active(page)).toHaveAccessibleName(name); + const id = await active(page).getAttribute("id"); + await expect(search(page)).toHaveAttribute("aria-activedescendant", id!); + await expect(active(page)).toBeInViewport(); +} + +test("finds commands by letters in order, emphasises the match and runs the best one", async ({ + page, +}) => { + await openPalette(page); + const listbox = palette(page).getByRole("listbox"); + await expect(search(page)).toHaveAttribute("aria-expanded", "true"); + await expect(search(page)).toHaveAttribute( + "aria-controls", + (await listbox.getAttribute("id"))!, + ); + + await page.keyboard.type("cmp"); + await expect(options(page).first()).toHaveAccessibleName( + "Compare models on this task", + ); + await search(page).fill("rev ch"); + await expect(group(page, "Commands")).toBeVisible(); + await expectActive(page, "Review changes"); + await expect(active(page).locator(".palette-match")).toHaveText([ + "Rev", + "ch", + ]); + + // Both themes, with emphasised letters and a conversation listed. + await search(page).fill("set"); + await expect(group(page, "Conversations")).toBeVisible(); + for (const theme of ["light", "dark"]) { + await page.evaluate( + (t) => (document.documentElement.dataset.theme = t), + theme, + ); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) + .include('[role="dialog"]') + .analyze(); + expect(results.violations).toEqual([]); + } + + await search(page).fill("zzqx"); + await expect(palette(page).getByRole("listbox")).toHaveCount(0); + await expect(search(page)).toHaveAttribute("aria-expanded", "false"); + await expect(palette(page).getByRole("status")).toHaveText( + "Nothing matches “zzqx”.", + ); + + await search(page).fill("rev ch"); + await page.keyboard.press("Enter"); + await expect(palette(page)).toHaveCount(0); + await expect(panel(page, "Changes")).toHaveAttribute("aria-selected", "true"); +}); + +test("opens a conversation by name, as choosing it in the sidebar does", async ({ + page, +}) => { + await openPalette(page); + // Nothing typed: commands only. + await expect(group(page, "Conversations")).toHaveCount(0); + await page.keyboard.type("login red"); + const found = group(page, "Conversations").getByRole("option"); + await expect(found).toHaveCount(1); + await expect(found).toHaveAccessibleName("Fix the login redirect"); + // The project it belongs to describes it. + await expect(found).toHaveAccessibleDescription("demo"); + await expectActive(page, "Fix the login redirect"); + await page.keyboard.press("Enter"); + + await expect(palette(page)).toHaveCount(0); + await expect( + page.getByRole("heading", { level: 1, name: "Fix the login redirect" }), + ).toBeVisible(); + await expect( + page.locator('.task-link[data-session-id="s2"]'), + ).toHaveAttribute("aria-current", "page"); + + // Projects are listed too. + await openPalette(page); + await page.keyboard.type("demo"); + await expect( + group(page, "Projects").getByRole("option"), + ).toHaveAccessibleName("demo"); +}); + +test("the commands run last come first when nothing is typed, after a reload too", async ({ + page, +}) => { + await openPalette(page); + await expect(group(page, "Recent")).toHaveCount(0); + await page.keyboard.type("keyboard short"); + await page.keyboard.press("Enter"); + const help = page.getByRole("dialog", { name: "Help & Shortcuts" }); + await expect(help).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(help).toHaveCount(0); + + await openPalette(page); + await page.keyboard.type("brow fi"); + await page.keyboard.press("Enter"); + await expect(panel(page, "Files")).toHaveAttribute("aria-selected", "true"); + + await page.reload(); + await expect( + page.getByRole("heading", { name: "What should we work on?" }), + ).toBeVisible(); + await openPalette(page); + const recent = group(page, "Recent").getByRole("option"); + await expect(recent).toHaveCount(2); + await expect(recent.nth(0)).toHaveAccessibleName("Browse files"); + await expect(recent.nth(1)).toHaveAccessibleName("Keyboard shortcuts"); + // The newest is ready to run again with Enter. + await expectActive(page, "Browse files"); + // Listed once: the full list goes on without them. + await expect( + palette(page).getByRole("option", { name: "Browse files", exact: true }), + ).toHaveCount(1); +}); + +test("arrows, Page Up/Down and Home/End move the active option and keep it in view", async ({ + page, +}) => { + const composer = page.getByRole("textbox", { name: "Message ShadowCode" }); + await composer.focus(); + await openPalette(page); + const names = await options(page).evaluateAll((all) => + all.map((option) => option.getAttribute("aria-label")!), + ); + expect(names[0]).toBe("New task"); + await expectActive(page, names[0]); + await page.keyboard.press("ArrowUp"); + await expectActive(page, names[0]); + await page.keyboard.press("ArrowDown"); + await expectActive(page, names[1]); + await page.keyboard.press("End"); + await expectActive(page, names.at(-1)!); + await page.keyboard.press("ArrowDown"); + await expectActive(page, names.at(-1)!); + await page.keyboard.press("Home"); + await expectActive(page, names[0]); + + // A page is the options that fit, less one. + await page.keyboard.press("PageDown"); + const paged = names.indexOf((await active(page).getAttribute("aria-label"))!); + expect(paged).toBeGreaterThan(3); + await expectActive(page, names[paged]); + await page.keyboard.press("PageDown"); + await expectActive(page, names[Math.min(2 * paged, names.length - 1)]); + await page.keyboard.press("PageUp"); + await expectActive(page, names[paged]); + await page.keyboard.press("PageUp"); + await expectActive(page, names[0]); + + // The pointer moves the active option too, leaving focus in the field. + const terminal = palette(page).getByRole("option", { + name: "Open a terminal", + }); + await terminal.hover(); + await expectActive(page, "Open a terminal"); + await expect(search(page)).toBeFocused(); + + // Escape closes and gives focus back. + await page.keyboard.press("Escape"); + await expect(palette(page)).toHaveCount(0); + await expect(composer).toBeFocused(); + + // A click runs an option. + await openPalette(page); + await palette(page).getByRole("option", { name: "Browse files" }).click(); + await expect(palette(page)).toHaveCount(0); + await expect(panel(page, "Files")).toHaveAttribute("aria-selected", "true"); +}); diff --git a/ui/e2e/polish.spec.ts b/ui/e2e/polish.spec.ts index c00e6c8c..c80ef9dd 100644 --- a/ui/e2e/polish.spec.ts +++ b/ui/e2e/polish.spec.ts @@ -28,6 +28,7 @@ test.afterEach(async ({ page }) => { const SECTIONS = [ "Accounts", "Local models", + "Agent", "Code intelligence", "Rules & skills", "Voice", @@ -198,7 +199,30 @@ test("the drawer keeps Close on its tab row and the Tasks panel is keyboard frie .size, ); expect(rows).toBe(1); - await drawer.getByRole("button", { name: "Tasks", exact: true }).click(); + // The tabs are one tab stop; the arrows, Home and End move between them + // and Enter opens the focused one. + const tabs = drawer.getByRole("tablist", { name: "Panels" }); + const tab = (name: string) => tabs.getByRole("tab", { name, exact: true }); + await expect(tab("Changes")).toHaveAttribute("aria-selected", "true"); + await expect(drawer.getByRole("tabpanel", { name: "Changes" })).toBeVisible(); + await tab("Changes").focus(); + await page.keyboard.press("ArrowRight"); + await expect(tab("Git")).toBeFocused(); + await expect(tab("Git")).toHaveAttribute("aria-selected", "false"); + await page.keyboard.press("End"); + await expect(tab("Tools")).toBeFocused(); + await page.keyboard.press("Home"); + await expect(tab("Changes")).toBeFocused(); + await page.keyboard.press("ArrowLeft"); + await page.keyboard.press("ArrowLeft"); + await expect(tab("Tasks")).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(tab("Tasks")).toHaveAttribute("aria-selected", "true"); + await expect(drawer.getByRole("tabpanel", { name: "Tasks" })).toBeVisible(); + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("Tab"); + await expect(tab("Tasks")).toBeFocused(); + await drawer.getByRole("tab", { name: "Tasks", exact: true }).click(); const search = drawer.getByRole("textbox", { name: "Search tasks" }); await expect(search).toBeVisible(); await expect(drawer).not.toContainText("session"); diff --git a/ui/e2e/preview.spec.ts b/ui/e2e/preview.spec.ts index 0bcf5b7c..159c6c93 100644 --- a/ui/e2e/preview.spec.ts +++ b/ui/e2e/preview.spec.ts @@ -89,7 +89,7 @@ async function openPreview(page: Page) { await page.getByRole("button", { name: "Review changes" }).click(); await drawer(page) .locator(".drawer-tabs") - .getByRole("button", { name: "Preview" }) + .getByRole("tab", { name: "Preview" }) .click(); await preview(page) .getByRole("button", { name: /localhost:5173/ }) diff --git a/ui/e2e/search.spec.ts b/ui/e2e/search.spec.ts new file mode 100644 index 00000000..96ffbd45 --- /dev/null +++ b/ui/e2e/search.spec.ts @@ -0,0 +1,53 @@ +import { test, expect, type Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { installFakeBackend } from "./fakeBackend"; + +// Task search finds words in prompts and in the agent's answers, and shows +// where it matched, against the deterministic fake engine. +test.beforeEach(async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + (page as Page & { errors?: string[] }).errors = errors; + await page.addInitScript(installFakeBackend, { + stepMs: 60, + completedTask: true, + }); + await page.goto("/"); +}); +test.afterEach(async ({ page }) => { + expect((page as Page & { errors?: string[] }).errors).toEqual([]); +}); + +test("search shows the answer or prompt that matched", async ({ page }) => { + const sidebar = page.getByRole("complementary", { + name: "Projects and tasks", + }); + const search = sidebar.getByRole("textbox", { name: "Search tasks" }); + await search.fill("inspection finished"); + const row = sidebar.locator(".task-row").filter({ hasText: "Answer ·" }); + await expect(row).toHaveCount(1); + await expect(row.locator(".search-snippet")).toHaveText( + "Answer · Project inspection finished.", + ); + await expect(row.locator("mark")).toHaveText("inspection finished"); + for (const theme of ["light", "dark"]) { + await page.evaluate( + (t) => (document.documentElement.dataset.theme = t), + theme, + ); + const results = await new AxeBuilder({ page }) + .include(".sidebar") + .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) + .analyze(); + expect(results.violations).toEqual([]); + } + + await search.fill("inspect the"); + await expect(sidebar.locator(".search-snippet")).toHaveText( + "You · Inspect the project", + ); + // Clearing the search lists conversations without excerpts again. + await search.fill(""); + await expect(sidebar.locator(".search-snippet")).toHaveCount(0); + await expect(sidebar.locator(".task-row")).not.toHaveCount(0); +}); diff --git a/ui/e2e/secondOpinions.spec.ts b/ui/e2e/secondOpinions.spec.ts index f850a96d..7ba28258 100644 --- a/ui/e2e/secondOpinions.spec.ts +++ b/ui/e2e/secondOpinions.spec.ts @@ -60,7 +60,7 @@ test("reviews the staged changes before a commit, with consent for a cloud revie .click(); await drawer(page) .locator(".drawer-tabs") - .getByRole("button", { name: "Git" }) + .getByRole("tab", { name: "Git" }) .click(); const panel = drawer(page); await panel.getByRole("button", { name: "Stage all" }).click(); diff --git a/ui/e2e/tools.spec.ts b/ui/e2e/tools.spec.ts index 2bfb233e..6888b793 100644 --- a/ui/e2e/tools.spec.ts +++ b/ui/e2e/tools.spec.ts @@ -33,7 +33,7 @@ const fakeLog = (page: Page) => const drawer = (page: Page) => page.getByRole("complementary", { name: "Drawer" }); const tab = (page: Page, name: string) => - drawer(page).locator(".drawer-tabs").getByRole("button", { name }); + drawer(page).locator(".drawer-tabs").getByRole("tab", { name }); async function openDrawer(page: Page, name: string) { await page.getByRole("button", { name: "Review changes" }).click(); diff --git a/ui/e2e/transcriptPolish.spec.ts b/ui/e2e/transcriptPolish.spec.ts new file mode 100644 index 00000000..b4ef0f4c --- /dev/null +++ b/ui/e2e/transcriptPolish.spec.ts @@ -0,0 +1,199 @@ +import { test, expect, type Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { installFakeBackend } from "./fakeBackend"; + +// Transcript polish against the fake engine: message times from recorded +// events, a long tool output's Show all and Copy, and the window title. +// Times are read in a fixed zone and locale from a fixed clock. +test.use({ locale: "en-GB", timezoneId: "Europe/London" }); + +const NOW = "2026-10-12T15:00:00+01:00"; +const SEARCH_HITS = 600; +const seconds = (iso: string) => Date.parse(iso) / 1000; + +/** Three saved exchanges: nine days ago, yesterday and today. */ +const HISTORY = [ + ["saved-1", "2026-10-03T09:15:00+01:00", "Plan the release"], + ["saved-1", "2026-10-03T09:16:00+01:00", "Here is the plan."], + ["saved-2", "2026-10-11T14:32:00+01:00", "Update the changelog"], + ["saved-2", "2026-10-11T14:40:00+01:00", "Updated the changelog."], + ["saved-3", "2026-10-12T14:32:00+01:00", "Check the tests"], + ["saved-3", "2026-10-12T14:33:00+01:00", "All tests pass."], +].map(([task, at, text], index) => ({ task, ts: seconds(at), text, index })); + +/** Saves the history as the engine records it (installed after the fake). */ +function seedHistory(rows: typeof HISTORY) { + const fake = (window as any).__SHADOW_FAKE__; + const { state } = fake; + state.sessions[0].title = "Release checklist"; + for (const row of rows) { + const record = (type: string, payload: Record) => + state.events.push({ + id: ++state.cursor, + ts: row.ts, + type, + payload, + session_id: "s1", + task_id: row.task, + }); + if (row.index % 2 === 0) record("user.message", { text: row.text }); + else { + record("model.delta", { + text: row.text, + message_id: `m${row.index}`, + complete: true, + }); + record("agent.completed", { success: true, summary: row.text }); + } + } +} + +test.beforeEach(async ({ page, context }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + (page as Page & { errors?: string[] }).errors = errors; + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await page.clock.install({ time: new Date(NOW) }); + await page.addInitScript(installFakeBackend, { + stepMs: 60, + searchHits: SEARCH_HITS, + }); + await page.addInitScript(seedHistory, HISTORY); + await page.goto("/"); + // The window reopens the saved conversation. + await expect(page.locator(".chat-stream")).toContainText("All tests pass."); +}); +test.afterEach(async ({ page }) => { + expect((page as Page & { errors?: string[] }).errors).toEqual([]); +}); + +/** Sends a task on the local model (the fake searches, edits and tests). */ +async function send(page: Page, text: string) { + await page.getByRole("button", { name: /Model for this task/ }).click(); + const search = page.getByRole("combobox", { name: "Search models" }); + await search.fill("qwen3:14b"); + await search.press("Enter"); + await expect(page.getByRole("listbox")).toHaveCount(0); + await page.getByRole("textbox", { name: "Message ShadowCode" }).fill(text); + await page.getByRole("button", { name: "Send task" }).click(); +} + +/** The new task's summary follows the three saved ones. */ +const finished = (page: Page) => + expect(page.getByRole("region", { name: "Task summary" })).toHaveCount(4, { + timeout: 15000, + }); + +async function axeClean(page: Page) { + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) + .include(".chat-stream") + .analyze(); + expect(results.violations).toEqual([]); +} + +test("messages show when they were sent, quietly; the window names the conversation", async ({ + page, +}) => { + await expect(page).toHaveTitle("Release checklist — ShadowCode"); + const times = page.locator(".chat-stream time.message-time"); + await expect(times).toHaveText([ + "3 Oct, 09:15", + "3 Oct, 09:16", + "Yesterday 14:32", + "Yesterday 14:40", + "14:32", + "14:33", + ]); + // A reply a minute later keeps its time until the message is hovered. + const quiet = times.nth(5); + await expect(quiet).toHaveClass(/is-quiet/); + await expect(quiet).toHaveCSS("opacity", "0"); + await expect(times.nth(4)).toHaveCSS("opacity", "1"); + await page.locator(".msg-agent", { hasText: "All tests pass." }).hover(); + await expect(quiet).toHaveCSS("opacity", "1"); + const yesterday = times.nth(2); + await expect(yesterday).toHaveAttribute( + "datetime", + new Date(HISTORY[2].ts * 1000).toISOString(), + ); + await expect(yesterday).toHaveAttribute( + "title", + /^Sunday,? 11 October 2026 at 14:32$/, + ); + + // A new message: the title shows the task running, and the prompt shows + // the time it was sent. + await page.evaluate(() => { + (window as any).__SHADOW_FAKE__.state.held = ["s1"]; + }); + await send(page, "Find every add"); + await expect(page).toHaveTitle("● Release checklist — ShadowCode"); + await expect( + page.locator(".msg-user", { hasText: "Find every add" }).locator("time"), + ).toHaveText("15:00"); + await page.evaluate(() => { + (window as any).__SHADOW_FAKE__.state.held = []; + }); + await finished(page); + await expect(page).toHaveTitle("Release checklist — ShadowCode"); + for (const theme of ["light", "dark"]) { + await page.evaluate( + (t) => (document.documentElement.dataset.theme = t), + theme, + ); + await axeClean(page); + } +}); + +test("a long tool output can be shown in full and copied", async ({ page }) => { + await send(page, "Find every add"); + await finished(page); + await page + .locator("details.activity-step", { hasText: "Reading project" }) + .last() + .locator("summary") + .click(); + const lines = 5 * SEARCH_HITS + 6; + const showAll = page.getByRole("button", { + name: `Show all (${lines.toLocaleString("en-GB")} lines) of the output of search_text`, + }); + const output = page + .locator(".activity-calls li", { hasText: "search_text" }) + .locator("pre"); + await expect(output).toContainText('"matches": ['); + await expect(output).not.toContainText('"scanned": 600'); + await showAll.click(); + await expect( + page.getByText( + `Showing the last 2,000 of ${lines.toLocaleString("en-GB")} lines. Copy includes all of it.`, + ), + ).toBeVisible(); + await expect(output).toContainText('"scanned": 600'); + await expect(output).not.toContainText("add0 "); + + const copy = page.getByRole("button", { + name: "Copy the output of search_text", + }); + await copy.click(); + await expect( + page.getByRole("button", { name: "Copied the output of search_text" }), + ).toHaveText("Copied"); + const copied = await page.evaluate(() => navigator.clipboard.readText()); + expect(copied.split("\n")).toHaveLength(lines); + expect(copied).toContain("export const add0 = (a, b) => a + b;"); + expect(copied).toContain('"scanned": 600'); + + for (const theme of ["light", "dark"]) { + await page.evaluate( + (t) => (document.documentElement.dataset.theme = t), + theme, + ); + await axeClean(page); + } + await page + .getByRole("button", { name: "Show less of the output of search_text" }) + .click(); + await expect(page.getByText(/^Showing the last/)).toHaveCount(0); + await expect(showAll).toBeVisible(); +}); diff --git a/ui/e2e/workspace.spec.ts b/ui/e2e/workspace.spec.ts index 4ad5c6bd..5f9a9293 100644 --- a/ui/e2e/workspace.spec.ts +++ b/ui/e2e/workspace.spec.ts @@ -230,7 +230,7 @@ test("drawer tabs keep the terminal, the open file and the commit message", asyn await page.getByRole("button", { name: "Review changes" }).click(); const drawer = page.getByRole("complementary", { name: "Drawer" }); const tab = (name: string) => - drawer.locator(".drawer-tabs").getByRole("button", { name }); + drawer.locator(".drawer-tabs").getByRole("tab", { name }); await drawer.getByPlaceholder("Commit message").fill("Fix the add function"); await tab("Terminal").click(); const shell = drawer.getByRole("group", { name: "Terminal 1" }); @@ -271,7 +271,7 @@ test("keeps an unsaved file draft and reviews an agent edit before saving", asyn await page.getByRole("button", { name: "Review changes" }).click(); const drawer = page.getByRole("complementary", { name: "Drawer" }); const tab = (name: string) => - drawer.locator(".drawer-tabs").getByRole("button", { name }); + drawer.locator(".drawer-tabs").getByRole("tab", { name }); await tab("Files").click(); await drawer.getByRole("button", { name: "README.md", exact: true }).click(); const editor = drawer.getByRole("textbox", { name: "Edit README.md" }); @@ -325,7 +325,7 @@ test("indents and outdents selected file lines in the rendered editor", async ({ const drawer = page.getByRole("complementary", { name: "Drawer" }); await drawer .locator(".drawer-tabs") - .getByRole("button", { name: "Files" }) + .getByRole("tab", { name: "Files" }) .click(); await drawer.getByRole("button", { name: "README.md", exact: true }).click(); const editor = drawer.getByRole("textbox", { name: "Edit README.md" }); @@ -350,7 +350,7 @@ test("recovers an unsaved editor draft after window reload and clears it after s const drawer = page.getByRole("complementary", { name: "Drawer" }); const files = drawer .locator(".drawer-tabs") - .getByRole("button", { name: "Files" }); + .getByRole("tab", { name: "Files" }); await files.click(); await drawer.getByRole("button", { name: "README.md", exact: true }).click(); const editor = drawer.getByRole("textbox", { name: "Edit README.md" }); @@ -408,7 +408,7 @@ test("waits for an in-flight recovery write before discarding its draft", async const drawer = page.getByRole("complementary", { name: "Drawer" }); await drawer .locator(".drawer-tabs") - .getByRole("button", { name: "Files" }) + .getByRole("tab", { name: "Files" }) .click(); await drawer.getByRole("button", { name: "README.md", exact: true }).click(); await page.evaluate(() => { @@ -444,7 +444,7 @@ test("waits for an in-flight recovery write before discarding its draft", async await page.getByRole("button", { name: "Review changes" }).click(); await drawer .locator(".drawer-tabs") - .getByRole("button", { name: "Files" }) + .getByRole("tab", { name: "Files" }) .click(); await expect( drawer.getByRole("button", { name: "Open README.md, unsaved" }), @@ -458,7 +458,7 @@ test("coalesces superseded recovery drafts behind an in-flight write", async ({ const drawer = page.getByRole("complementary", { name: "Drawer" }); await drawer .locator(".drawer-tabs") - .getByRole("button", { name: "Files" }) + .getByRole("tab", { name: "Files" }) .click(); await drawer.getByRole("button", { name: "README.md", exact: true }).click(); await page.evaluate(() => { @@ -509,7 +509,7 @@ test("starts a fresh recovery revision after saving a file and editing again", a const drawer = page.getByRole("complementary", { name: "Drawer" }); await drawer .locator(".drawer-tabs") - .getByRole("button", { name: "Files" }) + .getByRole("tab", { name: "Files" }) .click(); await drawer.getByRole("button", { name: "README.md", exact: true }).click(); const editor = drawer.getByRole("textbox", { name: "Edit README.md" }); @@ -546,7 +546,7 @@ test("reviews a competing window's recovery draft before choosing either version const drawer = page.getByRole("complementary", { name: "Drawer" }); await drawer .locator(".drawer-tabs") - .getByRole("button", { name: "Files" }) + .getByRole("tab", { name: "Files" }) .click(); await drawer.getByRole("button", { name: "README.md", exact: true }).click(); const editor = drawer.getByRole("textbox", { name: "Edit README.md" }); @@ -607,7 +607,7 @@ test("shows and retries a failed recovery cleanup after file save", async ({ const drawer = page.getByRole("complementary", { name: "Drawer" }); await drawer .locator(".drawer-tabs") - .getByRole("button", { name: "Files" }) + .getByRole("tab", { name: "Files" }) .click(); await drawer.getByRole("button", { name: "README.md", exact: true }).click(); await page.evaluate(() => { @@ -951,6 +951,58 @@ test("settings sections are accessible and trap focus", async ({ page }) => { await expect(dialog).toHaveCount(0); }); +test("Latest activity jumps instead of gliding when motion is reduced", async ({ + page, +}) => { + await runLocalTask(page, "Fix the add function"); + // A short window, so the finished task scrolls. + await page.setViewportSize({ width: 1000, height: 480 }); + const stream = page.locator(".chat-stream"); + // Record how the page asks the conversation to scroll. + await stream.evaluate((el) => { + const asked: (string | undefined)[] = []; + (window as unknown as { __scrolls: typeof asked }).__scrolls = asked; + const scrollTo = el.scrollTo.bind(el) as (o: ScrollToOptions) => void; + el.scrollTo = ((options: ScrollToOptions) => { + asked.push(options.behavior); + scrollTo(options); + }) as typeof el.scrollTo; + }); + const asked = () => + page.evaluate( + () => (window as unknown as { __scrolls: string[] }).__scrolls, + ); + const latest = page.getByRole("button", { name: "Latest activity" }); + for (const [motion, behavior] of [ + ["reduce", "auto"], + ["no-preference", "smooth"], + ] as const) { + await page.emulateMedia({ reducedMotion: motion }); + await stream.evaluate((el) => el.scrollTo({ top: 0 })); + await latest.click(); + await expect(latest).toHaveCount(0); + expect((await asked()).at(-1)).toBe(behavior); + // A smooth scroll is still gliding when the button goes; let it land, + // or it carries on past the next scroll to the top. + await expect + .poll(() => + stream.evaluate( + (el) => el.scrollHeight - el.scrollTop - el.clientHeight, + ), + ) + .toBeLessThan(2); + } + // Reduced motion lands at the bottom at once. + await page.emulateMedia({ reducedMotion: "reduce" }); + await stream.evaluate((el) => el.scrollTo({ top: 0 })); + await latest.click(); + expect( + await stream.evaluate( + (el) => el.scrollHeight - el.scrollTop - el.clientHeight, + ), + ).toBeLessThan(2); +}); + test("the window works at its 520 px minimum width", async ({ page }) => { await page.setViewportSize({ width: 520, height: 800 }); await expect(page.locator(".sidebar")).toHaveCount(0); @@ -1320,7 +1372,7 @@ test("requires open editor drafts to be saved before Compare snapshots the proje const drawer = page.getByRole("complementary", { name: "Drawer" }); await drawer .locator(".drawer-tabs") - .getByRole("button", { name: "Files" }) + .getByRole("tab", { name: "Files" }) .click(); await drawer.getByRole("button", { name: "README.md", exact: true }).click(); const editor = drawer.getByRole("textbox", { name: "Edit README.md" }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ae6f898c..6e743690 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -12,9 +12,10 @@ import { BootScreen, Toasts } from "./components/shell/Chrome"; import { composerAccess } from "./components/shell/ComposerDock"; import { AppDialogs, type Overlay } from "./components/shell/AppDialogs"; import { paletteItems } from "./components/shell/palette"; +import { ZoomStatus } from "./components/shell/ZoomStatus"; import { useConversation } from "./hooks/useConversation"; import { useToasts } from "./hooks/useToasts"; -import { useTheme } from "./hooks/useTheme"; +import { useAppearance } from "./hooks/useAppearance"; import { useFeed } from "./hooks/useFeed"; import { useShortcuts, type ShortcutAction } from "./hooks/useShortcuts"; import { useWorkspace } from "./hooks/useWorkspace"; @@ -39,7 +40,11 @@ import { useConversationEdits } from "./hooks/useConversationEdits"; import { RewindDialog } from "./components/RewindDialog"; import { useNavigation } from "./hooks/useNavigation"; import { useJobControls } from "./hooks/useJobControls"; -import { useDesktopEvents, useSidebar } from "./hooks/useWindow"; +import { + useDesktopEvents, + useSidebar, + useWindowTitle, +} from "./hooks/useWindow"; import { useRowActions } from "./hooks/useRowActions"; import { useRunCheck } from "./hooks/useRunCheck"; import { useConversationBadges } from "./hooks/useConversationBadges"; @@ -93,6 +98,8 @@ export default function App() { const [panel, setPanel] = useState(null); const [diffPath, setDiffPath] = useState(""); const [overlay, setOverlay] = useState(""); + /** Find in conversation: 0 while closed, bumped by each Ctrl+F. */ + const [find, setFind] = useState(0); const [submitting, setSubmitting] = useState(false); const allowanceReturn = useRef(false); const promptRef = useRef(null); @@ -100,7 +107,7 @@ export default function App() { const taskRef = useRef(""); taskRef.current = task; - const { toasts, toast, dismiss } = useToasts(); + const { toasts, toast, dismiss, hold } = useToasts(); const shutdown = useDesktopEvents(toast); const [sidebar, setSidebar] = useSidebar(); const setJobs = useStableCallback((jobs: Job[]) => feed.setJobs(jobs)); @@ -114,11 +121,12 @@ export default function App() { void allowance.reload(); }; const memory = useDrawerMemory(workspace); - useTheme( - ws.configLoaded - ? String((cfg.ui as { theme?: string } | undefined)?.theme || "system") - : undefined, - ); + const appearance = useAppearance({ + cfg, + loaded: ws.configLoaded, + reloadConfig: ws.reloadConfig, + toast, + }); function openSettings( section: SettingsSection = "accounts", @@ -302,9 +310,6 @@ export default function App() { void reloadAllowance(); } }, [transcript.usageVersion, reloadPicker, reloadAllowance]); - useEffect(() => { - document.title = `${busy ? "● " : ""}ShadowCode`; - }, [busy]); useEffect(() => { const key = draftKey(sessionId, workspace); const timer = setTimeout(() => { @@ -387,6 +392,11 @@ export default function App() { writeStore(draftKey(sessionId, workspace), null); }, }); + // The window names the open conversation, as the top bar does. + useWindowTitle( + sessions.find((s) => s.id === sessionId)?.title || compare.laneOf?.title, + busy, + ); // A worktree conversation belongs to its project. const projectPath = worktree.task?.workspace || compare.projectPath; const listedProjects = ws.projects.filter( @@ -457,6 +467,7 @@ export default function App() { inWorktree: Boolean(worktree.task), }); + const closeFind = useStableCallback(() => setFind(0)); const shortcuts: Record void> = { "close-overlay": () => setOverlay(""), "close-trust": () => nav.setTrust(null), @@ -474,6 +485,10 @@ export default function App() { stop: () => void controls.stop(), export: () => exportSession(), help: () => setOverlay("help"), + find: () => setFind((n) => n + 1), + "zoom-in": () => appearance.zoom(1), + "zoom-out": () => appearance.zoom(-1), + "zoom-reset": () => appearance.zoom(0), "previous-conversation": conversations.previous, "next-conversation": conversations.next, "recent-conversation": conversations.recent, @@ -615,6 +630,11 @@ export default function App() { ); const view = compare.view; + /** Choosing a conversation in the sidebar or the command palette. */ + const openConversation = (id: string) => { + compare.setView("chat"); + void nav.openSession(id); + }; const currentSession = sessions.find((s) => s.id === sessionId); const palette = paletteItems({ newTask: () => void nav.newSession(), @@ -628,19 +648,11 @@ export default function App() { openSettings(section, advanced ? { advanced } : {}), exportTask: exportSession, stop: () => void controls.stop(), - toggleTheme: () => - void api - .saveConfig({ - ui: { - theme: - document.documentElement.dataset.theme === "dark" - ? "light" - : "dark", - }, - }) - .then(ws.reloadConfig) - .catch((e) => toast(String(e), "err")), + theme: appearance.setTheme, + themeInUse: appearance.theme, + zoom: appearance.zoom, help: () => setOverlay("help"), + find: () => setFind((n) => n + 1), }); return ( @@ -658,10 +670,7 @@ export default function App() { onAction={(action, session, title) => void conversations.act(action, session, title) } - onSelect={(id) => { - compare.setView("chat"); - void nav.openSession(id); - }} + onSelect={openConversation} onNew={() => void nav.newSession()} onProject={(path) => void nav.pickProject(path)} onSettings={() => openSettings()} @@ -720,6 +729,8 @@ export default function App() { setTryOn(null); if (pending) void actions.tryOnModel(id, pending); }} + find={find} + onCloseFind={closeFind} selectedTarget={selectedTarget} queuedJobs={queuedJobs} queuedTaskIds={queuedTaskIds} @@ -827,7 +838,8 @@ export default function App() { targets={pickerTargets} /> )} - + + {rewinding.asking && ( void roles.reload(), } : undefined, - onSave: async (values) => { + appearance: appearance.controls, + onSave: async (values, options) => { try { await api.saveConfig(values); await ws.reloadConfig(); void picker.reload(); toast("Settings saved", "ok"); } catch (e) { + if (options?.rethrow) throw e; toast(String(e), "err"); } }, @@ -927,6 +941,8 @@ export default function App() { projects={listedProjects} workspace={workspace} onPickProject={(path) => void nav.pickProject(path)} + sessions={sessions} + onOpenSession={openConversation} /> ); diff --git a/ui/src/api.ts b/ui/src/api.ts index ae340ec3..bc5c78f9 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -346,6 +346,50 @@ export type OpenRouterStatus = { activity_url: string; }; +/** The OpenAI-compatible servers a user can add (docs/API_CONTRACT.md, + * "Your own endpoints"). */ +export type EndpointProvider = + "ollama" | "local" | "llamacpp" | "vllm" | "grok" | "openai_compatible"; + +export type EndpointRequest = { + provider: EndpointProvider; + /** A bare host gains `/v1`; empty means the preset's address. */ + endpoint?: string; + /** Typed in the form; sent as a bearer token, saved only when adding. */ + api_key?: string; + /** The name the key is saved under; empty means the engine's default. */ + api_key_env?: string; +}; + +/** POST /api/providers/models: what one server lists. Nothing is saved. */ +export type EndpointModels = { + ok: boolean; + provider: EndpointProvider; + label: string; + /** Normalized address, as it will be saved. */ + endpoint: string; + api_key_env: string; + /** A key is already saved under `api_key_env`. */ + key_saved: boolean; + /** The server runs on this computer. */ + local: boolean; + models: { + id: string; + name: string; + size_bytes: number; + context_limit: number; + detail: string; + }[]; +}; + +/** POST /api/models/test: one short completion (45 s limit). */ +export type ModelTest = { + ok: boolean; + reply?: string; + error?: string | null; + latency_ms: number; +}; + /** GET /api/allowance: how much each way of running a model has left, only * from what each source reports (no estimates). */ export type AllowanceState = @@ -693,6 +737,17 @@ export type Session = { worktree_task?: string | null; /** The project a worktree conversation belongs to (listed under it). */ worktree_source?: string | null; + /** Search results only: the prompt or answer that matched, unless the + * title already shows the words. */ + match?: SessionMatch; +}; +/** An excerpt around a task-search match; `start`/`end` are character + * (code point) offsets of the matched words in `snippet`. */ +export type SessionMatch = { + field: "prompt" | "answer"; + snippet: string; + start: number; + end: number; }; export type SessionDetail = Session & { /** The open worktree task this conversation runs in (as last saved). */ @@ -1810,6 +1865,25 @@ export const api = { send("/api/openrouter/key", "POST", { api_key: "" }), refreshOpenrouter: () => send("/api/openrouter/refresh", "POST", {}), + /** Your own endpoints: the models one server lists (nothing is saved). */ + endpointModels: (request: EndpointRequest) => + send("/api/providers/models", "POST", request), + /** Adds models of one server to the picker and saves a typed key. */ + addEndpointModels: ( + request: EndpointRequest & { models: string[]; vision?: boolean }, + ) => + send<{ ok: boolean; models: { default: string; name: string }[] }>( + "/api/models/endpoints", + "POST", + request, + ), + /** Removes a model added for an endpoint; its saved key stays. */ + removeEndpointModel: (id: string) => + send<{ ok: boolean; removed: number }>("/api/models/remove", "POST", { + id, + }), + testModel: (id: string) => + send("/api/models/test", "POST", { id }), localModels: () => get("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/api/local-models"), addLocalModel: (path: string) => send<{ ok: boolean; local_engine?: LocalCatalog }>( diff --git a/ui/src/appearance.css b/ui/src/appearance.css new file mode 100644 index 00000000..3afc4e41 --- /dev/null +++ b/ui/src/appearance.css @@ -0,0 +1,37 @@ +/* Text size ---------------------------------------------------------------- */ +/* The size for a moment after a zoom key: under the header, out of the way of + the notifications in the middle, and never in the way of a click. */ +.zoom-status { + position: fixed; + top: 62px; + right: 20px; + z-index: 40; + padding: 6px 12px; + border-radius: var(--pill); + background: var(--surface); + border: 1px solid var(--border); + box-shadow: var(--shadow-2); + font-size: 12px; + font-variant-numeric: tabular-nums; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease-out; +} +.zoom-status[data-shown] { + opacity: 1; +} +.zoom-status:empty { + padding: 0; + border: 0; +} + +/* Settings › Appearance: seven sizes wrap in a narrow window. */ +.text-size-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; +} +.text-size-steps { + flex-wrap: wrap; +} diff --git a/ui/src/components.css b/ui/src/components.css index 30d79730..250387c9 100644 --- a/ui/src/components.css +++ b/ui/src/components.css @@ -802,6 +802,66 @@ div.activity-step { white-space: pre-wrap; overflow-wrap: anywhere; } +/* Tool output: Copy in the box's corner (on hover or focus, like message + actions), Show all / Show less under a long one. */ +.tool-output { + position: relative; + min-width: 0; +} +.activity-calls .tool-output { + grid-column: 1 / -1; +} +.tool-output.is-expanded pre { + max-height: min(60vh, 640px); +} +.tool-output-copy, +.tool-output-bar button { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border-radius: 5px; + font-size: 11px; + color: var(--muted); +} +.tool-output-copy { + position: absolute; + top: 5px; + right: 12px; + background: var(--surface); + border: 1px solid var(--border); + opacity: 0; + transition: opacity 0.12s; +} +.tool-output:hover .tool-output-copy, +.tool-output:focus-within .tool-output-copy, +.tool-output-copy.is-copied { + opacity: 1; +} +@media (hover: none) { + .tool-output-copy { + opacity: 1; + } +} +.tool-output-copy:hover, +.tool-output-bar button:hover { + color: var(--text); +} +.tool-output-bar button:hover { + background: var(--surface-2); +} +.tool-output-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 2px 6px; + margin-top: 2px; +} +.tool-output-bar .hint { + margin: 0 auto 0 0; + font-size: 11px; +} .msg-summary { display: grid; gap: 10px; @@ -2208,3 +2268,76 @@ div.activity-step { grid-template-columns: 1fr; } } +/* Accounts › Your own endpoints. */ +.endpoint-rows { + list-style: none; + margin: 2px 0; + padding: 0; + display: grid; + gap: 6px; +} +.endpoint-rows li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 10px; + padding: 6px 0; + border-top: 1px solid var(--border); +} +.endpoint-rows li > p { + flex-basis: 100%; +} +.endpoint-row-name { + display: grid; + min-width: 0; + flex: 1 1 220px; +} +.endpoint-row-name strong { + overflow-wrap: anywhere; +} +.endpoint-row-name small { + color: var(--muted); + overflow-wrap: anywhere; +} +.endpoint-row-actions { + gap: 4px; +} +.endpoint-test-ok { + color: var(--text-2); +} +.endpoint-form, +.endpoint-models { + display: grid; + gap: 8px; +} +.endpoint-form h5 { + margin: 4px 0 0; + font-size: 13px; +} +.endpoint-models { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 10px; + margin: 0; +} +.endpoint-models legend { + color: var(--text-2); + font-size: 12.5px; + padding: 0 4px; +} +.endpoint-models .check { + margin: 0; + overflow-wrap: anywhere; +} +.endpoint-advanced summary { + cursor: pointer; + color: var(--text-2); + font-size: 12.5px; +} +.endpoint-advanced[open] { + display: grid; + gap: 8px; +} +.endpoint-manual .row input { + flex: 1; +} diff --git a/ui/src/components/ActivityTimeline.tsx b/ui/src/components/ActivityTimeline.tsx index 51638291..0e66a424 100644 --- a/ui/src/components/ActivityTimeline.tsx +++ b/ui/src/components/ActivityTimeline.tsx @@ -5,6 +5,7 @@ import { type TaskActivity, type TimelineStep, } from "../lib/activity"; +import { ToolOutput } from "./ToolOutput"; /** One compact timeline for a task. Every step is backed by recorded events * and expands to the real tool calls and their output. Memoized: a task's @@ -122,10 +123,10 @@ function Step({ : "done"} {call.output && ( -
-                {call.output.slice(0, 4000)}
-                {call.output.length > 4000 ? "\n… (truncated)" : ""}
-              
+ )} ))} diff --git a/ui/src/components/ApprovalCard.test.tsx b/ui/src/components/ApprovalCard.test.tsx index cc025e95..46d28195 100644 --- a/ui/src/components/ApprovalCard.test.tsx +++ b/ui/src/components/ApprovalCard.test.tsx @@ -1,7 +1,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Approval } from "../api"; -import { ApprovalCard, PREVIEW_LINES } from "./ApprovalCard"; +import { ApprovalCard, PREVIEW_LINES, approvalSummary } from "./ApprovalCard"; afterEach(cleanup); @@ -187,4 +187,63 @@ describe("ApprovalCard", () => { screen.queryByRole("button", { name: "Always allow here" }), ).toBeNull(); }); + + it("is named for the action it would allow", () => { + const { rerender } = render( + , + ); + const card = screen.getByRole("group", { + name: "Approval needed: Write notes.txt", + }); + expect(card.getAttribute("aria-describedby")).toBeTruthy(); + expect( + document.getElementById(card.getAttribute("aria-describedby")!) + ?.textContent, + ).toBe("Allow this change?"); + rerender( + , + ); + expect( + screen.getByRole("group", { + name: "Approval needed: npm test --watch=false", + }), + ).toBeTruthy(); + }); + + it("summarizes moves, folders and long commands in a few words", () => { + const base = { id: "a1", tool: "move_file" }; + expect( + approvalSummary({ + ...base, + preview: { kind: "move", from: "a.ts", to: "b.ts" }, + }), + ).toBe("Move a.ts → b.ts"); + expect( + approvalSummary({ + ...base, + tool: "create_directory", + preview: { kind: "folder", path: "docs" }, + }), + ).toBe("Create folder docs"); + const long = approvalSummary({ + ...base, + tool: "exec", + command: "x".repeat(300), + }); + expect(long).toHaveLength(100); + expect(long.endsWith("…")).toBe(true); + expect(approvalSummary({ id: "a1" })).toBe("an action"); + }); }); diff --git a/ui/src/components/ApprovalCard.tsx b/ui/src/components/ApprovalCard.tsx index 3a5e40b0..618d80d7 100644 --- a/ui/src/components/ApprovalCard.tsx +++ b/ui/src/components/ApprovalCard.tsx @@ -30,6 +30,42 @@ const BACKGROUND_LABELS = new Map([ /** Diff lines the card shows before "Show all". */ export const PREVIEW_LINES = 14; +/** Whether the approval changes files, and what it does in its own words: + * file tools report their tool name as the "command", and what they do is + * in the reason ("Write hello.txt"). */ +function actionOf(approval: Approval) { + const tool = approval.tool || ""; + const preview = approval.preview || null; + const fileChange = + FILE_TOOLS.has(tool) || + preview?.kind === "files" || + preview?.kind === "move" || + preview?.kind === "folder"; + const command = + approval.command && approval.command !== tool ? approval.command : ""; + const main = fileChange ? approval.reason || command : command; + return { fileChange, main }; +} + +/** The action in a few words ("npm test", "Write notes.txt"): the card's + * accessible name and what the conversation announces when it appears. */ +export function approvalSummary(approval: Approval): string { + const preview = approval.preview || null; + const text = + preview?.kind === "command" + ? preview.command + : preview?.kind === "move" + ? `Move ${preview.from} → ${preview.to}` + : preview?.kind === "folder" + ? `Create folder ${preview.path}` + : actionOf(approval).main || + approval.reason || + approval.tool || + "an action"; + const line = text.replace(/\s+/g, " ").trim(); + return line.length > 100 ? `${line.slice(0, 99)}…` : line; +} + const STATUS: Record = { added: "New file", deleted: "Deleted", @@ -152,24 +188,18 @@ export const ApprovalCard = memo(function ApprovalCard({ const [note, setNote] = useState(""); const tool = approval.tool || ""; const preview = approval.preview || null; - // File tools report their tool name as the "command"; what they do is in - // the reason ("Write hello.txt"). - const fileChange = - FILE_TOOLS.has(tool) || - preview?.kind === "files" || - preview?.kind === "move" || - preview?.kind === "folder"; - const command = - approval.command && approval.command !== tool ? approval.command : ""; - const main = fileChange ? approval.reason || command : command; + const { fileChange, main } = actionOf(approval); const detail = main === approval.reason ? "" : approval.reason; const decide = (answer: ApprovalDecision) => onDecide(approval.id, answer); + // Named for what it would do, so several cards (and a screen reader + // moving into one) tell themselves apart; the question describes it. return (
diff --git a/ui/src/components/AutomationsPanel.test.tsx b/ui/src/components/AutomationsPanel.test.tsx index 8cc74acc..372c5769 100644 --- a/ui/src/components/AutomationsPanel.test.tsx +++ b/ui/src/components/AutomationsPanel.test.tsx @@ -5,6 +5,7 @@ import { render, screen, waitFor, + within, } from "@testing-library/react"; import { AutomationEditor, AutomationsPanel } from "./AutomationsPanel"; import { @@ -246,3 +247,62 @@ it("lists automations with their next time, runs one now and shows history", asy fireEvent.click(screen.getByRole("button", { name: "Open conversation" })); expect(open).toHaveBeenCalledWith("s9"); }); + +it("asks in the app's own dialog before deleting an automation", async () => { + // The old window.confirm would answer "no" here. + const confirm = vi.fn(() => false); + vi.stubGlobal("confirm", confirm); + const now = Date.now() / 1000; + const row: Automation = { + ...defaultDraft(true), + name: "Nightly tests", + prompt: "Run the tests", + id: "a1", + workspace: "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/work/demo", + paused: false, + next_run_at: now + 600, + created_at: 1, + updated_at: 1, + description: "Every day at 02:00", + running_run: null, + last_run: null, + }; + const listed = (automations: Automation[]) => ({ + workspace: "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/work/demo", + automations, + scheduler: true, + now, + }); + mocked.list.mockResolvedValue(listed([row])); + mocked.remove.mockImplementation(async () => { + mocked.list.mockResolvedValue(listed([])); + return { ok: true }; + }); + const toast = vi.fn(); + render( undefined} />); + const card = await screen.findByRole("article", { name: "Nightly tests" }); + const remove = within(card).getByRole("button", { name: "Delete" }); + // Cancel keeps it. + fireEvent.click(remove); + const dialog = screen.getByRole("dialog", { + name: "Delete this automation?", + }); + expect(text(dialog)).toContain("“Nightly tests” and its run history"); + expect(text(dialog)).toContain("Conversations it created stay."); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(mocked.remove).not.toHaveBeenCalled(); + // Confirming deletes it. + fireEvent.click(remove); + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: "Delete automation", + }), + ); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(mocked.remove).toHaveBeenCalledWith("a1"); + expect(toast).toHaveBeenCalledWith("Automation deleted", "ok"); + expect(await screen.findByText("No automations yet")).toBeTruthy(); + expect(confirm).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); +}); diff --git a/ui/src/components/AutomationsPanel.tsx b/ui/src/components/AutomationsPanel.tsx index 28233106..a668b55e 100644 --- a/ui/src/components/AutomationsPanel.tsx +++ b/ui/src/components/AutomationsPanel.tsx @@ -26,6 +26,7 @@ import { import { forgeApi } from "../lib/forge"; import { isReady, type PickerTarget } from "../lib/picker"; import { Empty } from "./cards"; +import { ConfirmDialog } from "./ConfirmDialog"; import "../tools.css"; type Toast = (text: string, kind?: "ok" | "err" | "info") => void; @@ -49,6 +50,7 @@ export function AutomationsPanel({ const [history, setHistory] = useState(""); const [runs, setRuns] = useState([]); const [pending, setPending] = useState(""); + const [deleting, setDeleting] = useState(null); const [repo, setRepo] = useState(false); const [now, setNow] = useState(nowSeconds); @@ -248,15 +250,7 @@ export function AutomationsPanel({ type="button" className="mini danger-text" disabled={busy || Boolean(a.running_run)} - onClick={() => { - if ( - !window.confirm( - `Delete the automation "${a.name}" and its history? Conversations it created stay.`, - ) - ) - return; - void act(a.id, "delete", () => automationsApi.remove(a.id)); - }} + onClick={() => setDeleting(a)} > Delete @@ -267,6 +261,26 @@ export function AutomationsPanel({ ); })} + {deleting && ( + setDeleting(null)} + onConfirm={async () => { + await act(deleting.id, "delete", async () => { + await automationsApi.remove(deleting.id); + toast("Automation deleted", "ok"); + }); + setDeleting(null); + }} + > +

+ “{deleting.name}” and its run history are removed. Conversations it + created stay. +

+
+ )}
); } diff --git a/ui/src/components/Composer.extras.test.tsx b/ui/src/components/Composer.extras.test.tsx index b00d179e..cbf64876 100644 --- a/ui/src/components/Composer.extras.test.tsx +++ b/ui/src/components/Composer.extras.test.tsx @@ -12,13 +12,19 @@ import { useRef, useState } from "react"; const mocks = vi.hoisted(() => ({ agents: vi.fn(), mentions: vi.fn() })); vi.mock("../api", () => ({ api: mocks })); -import { Composer } from "./Composer"; +import { Composer, type SlashCommand } from "./Composer"; import { EffortControl, ModeToggle } from "./ComposerModes"; import { usePromptHistory } from "../hooks/useComposerExtras"; import { pushHistory } from "../lib/promptHistory"; import type { Mention } from "../lib/mentions"; -function Harness({ onSubmit = () => {} }: { onSubmit?: () => void }) { +function Harness({ + onSubmit = () => {}, + commands = [], +}: { + onSubmit?: () => void; + commands?: SlashCommand[]; +}) { const [task, setTask] = useState(""); const [mentions, setMentions] = useState([]); const promptRef = useRef(null); @@ -37,7 +43,7 @@ function Harness({ onSubmit = () => {} }: { onSubmit?: () => void }) { attachDisabled={false} picker={null} controls={null} - commands={[]} + commands={commands} placeholder="" hint="" busy={false} @@ -133,6 +139,90 @@ describe("composer @ menu", () => { }); }); +describe("composer / menu", () => { + const commands: SlashCommand[] = [ + { name: "help", description: "List native commands", arg_spec: "" }, + { + name: "status", + description: "Show the selected model and active work", + arg_spec: "", + }, + { name: "model", description: "Show or select a model", arg_spec: "[id]" }, + { name: "models", description: "List registered models", arg_spec: "" }, + { name: "review", description: "Review project changes", arg_spec: "" }, + ]; + const hits = () => + within(screen.getByRole("listbox", { name: "Slash commands" })) + .getAllByRole("option") + .map((option) => option.querySelector("strong")?.textContent); + + it("lists name matches first, then names and descriptions containing the text", () => { + render(); + typed("/mod"); + expect(hits()).toEqual(["/model", "/models", "/status"]); + typed("/view"); + expect(hits()).toEqual(["/review"]); + typed("/zzz"); + expect(screen.getByText("No matching commands")).toBeTruthy(); + }); + + it("Enter picks the highlighted command; the next Enter sends", () => { + const submit = vi.fn(); + render(); + typed("/mod"); + fireEvent.keyDown(field(), { key: "ArrowDown" }); + fireEvent.keyDown(field(), { key: "ArrowDown" }); + fireEvent.keyDown(field(), { key: "ArrowUp" }); + fireEvent.keyDown(field(), { key: "Enter" }); + expect(submit).not.toHaveBeenCalled(); + // A command without arguments gets no trailing space. + expect(screen.getByTestId("task").textContent).toBe("/models"); + expect( + screen.queryByRole("listbox", { name: "Slash commands" }), + ).toBeNull(); + fireEvent.keyDown(field(), { key: "Enter" }); + expect(submit).toHaveBeenCalledTimes(1); + // Tab still picks, and a command that takes arguments gets a space. + typed("/mo"); + fireEvent.keyDown(field(), { key: "Tab" }); + expect(screen.getByTestId("task").textContent).toBe("/model "); + // Nothing to pick: Enter sends what was typed. + typed("/zzz"); + fireEvent.keyDown(field(), { key: "Enter" }); + expect(submit).toHaveBeenCalledTimes(2); + }); + + it("points the field at the menu and its highlighted command", () => { + render(); + expect(field().getAttribute("aria-controls")).toBeNull(); + expect(field().getAttribute("aria-autocomplete")).toBe("list"); + typed("/mod"); + const menu = screen.getByRole("listbox", { name: "Slash commands" }); + expect(field().getAttribute("aria-controls")).toBe(menu.id); + const active = () => + document.getElementById(field().getAttribute("aria-activedescendant")!); + expect(active()?.getAttribute("aria-selected")).toBe("true"); + expect(active()?.textContent).toContain("/model"); + fireEvent.keyDown(field(), { key: "ArrowDown" }); + expect(active()?.textContent).toContain("/models"); + fireEvent.keyDown(field(), { key: "Escape" }); + expect(field().getAttribute("aria-controls")).toBeNull(); + expect(field().getAttribute("aria-activedescendant")).toBeNull(); + }); + + it("points the field at the @ menu while it is open", async () => { + render(); + typed("Explain @app"); + const menu = await screen.findByRole("listbox", { name: "Mentions" }); + await within(menu).findByRole("option", { name: /src\/app\.ts/ }); + expect(field().getAttribute("aria-controls")).toBe(menu.id); + expect( + document.getElementById(field().getAttribute("aria-activedescendant")!) + ?.textContent, + ).toContain("src/app.ts"); + }); +}); + describe("composer history", () => { it("recalls earlier prompts with ↑ and ↓ only from an empty field", () => { pushHistory("/p", "first prompt"); diff --git a/ui/src/components/Composer.tsx b/ui/src/components/Composer.tsx index 4b05ad3a..8bb64f3f 100644 --- a/ui/src/components/Composer.tsx +++ b/ui/src/components/Composer.tsx @@ -37,6 +37,7 @@ import { type Attachment, } from "../lib/attachments"; import type { ContextAttachment } from "../lib/pendingAttachments"; +import { matchSlashCommands } from "../lib/slashCommands"; import { ContextChips } from "./ContextChips"; import { ContextInventory } from "./ContextInventory"; @@ -134,9 +135,8 @@ export function Composer({ const [slashOpen, setSlashOpen] = useState(false); const [dragging, setDragging] = useState(false); const [contextInventoryOpen, setContextInventoryOpen] = useState(false); - const hits = slashOpen - ? commands.filter((c) => c.name.startsWith(task.slice(1))).slice(0, 8) - : []; + const hits = slashOpen ? matchSlashCommands(commands, task.slice(1)) : []; + const activeSlash = Math.min(slashIndex, Math.max(0, hits.length - 1)); // The @ menu: `@name` at the start of a message names a subagent; // `@path` anywhere attaches a project file or folder. const [caret, setCaret] = useState(task.length); @@ -191,6 +191,15 @@ export function Composer({ setMentionIndex(0); moveCaret(next.caret); }; + /** Click, Tab and Enter all put the command in the field and close the + * menu; the next Enter sends it. */ + const pickSlash = (command: SlashCommand) => { + const text = `/${command.name}${command.arg_spec ? " " : ""}`; + onTask(text); + setSlashOpen(false); + setSlashIndex(0); + moveCaret(text.length); + }; const trackCaret = (el: HTMLTextAreaElement) => setCaret(el.selectionStart ?? el.value.length); useEffect(() => { @@ -229,20 +238,22 @@ export function Composer({ /> )} {slashOpen && ( -
+
{hits.length ? ( hits.map((c, i) => (
e.preventDefault()} - onClick={() => { - onTask(`/${c.name}${c.arg_spec ? " " : ""}`); - setSlashOpen(false); - promptRef.current?.focus(); - }} + onClick={() => pickSlash(c)} > /{c.name} {c.description} @@ -339,9 +350,16 @@ export function Composer({ )}
', + ); + expect(rowText(editing).text).toBe(""); + expect(rowText(row('
Using Codex
')).text).toBe( + "Using Codex", + ); +}); + +it("finds every match as a Range over the drawn text, across elements", () => { + const ranges = rangesIn(answer(), "bug"); + expect(ranges.map(String)).toEqual(["Bug", "bug"]); + expect(rangesIn(answer(), "the bug").map(String)).toEqual(["the Bug"]); + // Collapsed spaces still map back to the text as written. + expect(rangesIn(answer(), "fixed the").map(String)).toEqual(["Fixed the"]); + expect(rangesIn(answer(), "")).toEqual([]); + expect(rangesIn(answer(), "copy")).toEqual([]); +}); + +class FakeHighlight extends Set { + priority = 0; + constructor(...ranges: Range[]) { + super(ranges); + } +} + +it("paints matches with the highlight API where it exists", () => { + expect(canHighlight()).toBe(false); + const [first, second] = rangesIn(answer(), "bug"); + expect(paintMatches([first], second)).toBe(false); + + const registry = new Map(); + vi.stubGlobal("CSS", { highlights: registry }); + vi.stubGlobal("Highlight", FakeHighlight); + expect(canHighlight()).toBe(true); + expect(paintMatches([first], second)).toBe(true); + expect([...registry.get(HIGHLIGHT_ALL)!]).toEqual([first]); + expect([...registry.get(HIGHLIGHT_CURRENT)!]).toEqual([second]); + expect(registry.get(HIGHLIGHT_CURRENT)!.priority).toBe(1); + paintMatches([first, second], null); + expect(registry.has(HIGHLIGHT_CURRENT)).toBe(false); + clearMatches(document.body); + expect(registry.size).toBe(0); +}); + +it("marks one row as the current match's", () => { + document.body.innerHTML = + '
'; + const stream = document.getElementById("stream")!; + const a = document.getElementById("a")!; + const b = document.getElementById("b")!; + markRow(stream, a, "row"); + expect(a.getAttribute(FIND_CURRENT_ATTR)).toBe("row"); + markRow(stream, b, "text"); + expect(a.hasAttribute(FIND_CURRENT_ATTR)).toBe(false); + expect(b.getAttribute(FIND_CURRENT_ATTR)).toBe("text"); + clearMatches(stream); + expect(b.hasAttribute(FIND_CURRENT_ATTR)).toBe(false); +}); + +function box(top: number, height: number) { + return { top, bottom: top + height, height } as DOMRect; +} + +it("scrolls a match to the middle only when it is out of view", () => { + const stream = document.createElement("div"); + const target = document.createElement("div"); + stream.getBoundingClientRect = () => box(100, 600); + Object.defineProperty(stream, "scrollTop", { value: 1000 }); + const scrollTo = vi.fn(); + stream.scrollTo = scrollTo as unknown as typeof stream.scrollTo; + + target.getBoundingClientRect = () => box(300, 40); + expect(scrollToTarget(stream, target, true)).toBe(false); + expect(scrollTo).not.toHaveBeenCalled(); + + target.getBoundingClientRect = () => box(1500, 40); + expect(scrollToTarget(stream, target, true)).toBe(true); + expect(scrollTo).toHaveBeenLastCalledWith({ + top: 1000 + 1500 - 100 - 280, + behavior: "smooth", + }); + // A row taller than the view shows from its top. + target.getBoundingClientRect = () => box(-2000, 900); + scrollToTarget(stream, target, false); + expect(scrollTo).toHaveBeenLastCalledWith({ top: 0, behavior: "auto" }); +}); + +it("does not scroll smoothly when reduced motion is asked for", () => { + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + })), + ); + expect(smoothScrolling()).toBe(false); + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ matches: false })), + ); + expect(smoothScrolling()).toBe(true); +}); diff --git a/ui/src/lib/findHighlight.ts b/ui/src/lib/findHighlight.ts new file mode 100644 index 00000000..b738a09b --- /dev/null +++ b/ui/src/lib/findHighlight.ts @@ -0,0 +1,194 @@ +import { foldCase, offsetsIn } from "./find"; + +/** Find in conversation, on screen: the text a transcript row draws, the + * CSS Custom Highlight API where the webview has it (older WebKitGTK does + * not), and scrolling a match into view. */ + +/** TranscriptRows marks each searchable row with its key. */ +export const FIND_ROW_ATTR = "data-find-row"; +/** The row holding the current match. "text" when the match itself is + * highlighted, "row" when only the row can be marked (no highlight API, or + * the drawn text differs from what was counted). */ +export const FIND_CURRENT_ATTR = "data-find-current"; + +export const HIGHLIGHT_ALL = "shadow-find"; +export const HIGHLIGHT_CURRENT = "shadow-find-current"; + +/** Where a row draws the text find reads: an answer's Markdown, a prompt's + * bubble (not its edit form) and a note. */ +const TEXT = ".msg-agent > .markdown, .user-pill > div.bubble, .msg-note"; +/** Controls inside that text (a code block's Copy) are not part of it. */ +const SKIP = "button, .markdown-preview"; +/** Text in different blocks reads as separate words. */ +const BLOCK = + "p, li, pre, td, th, h1, h2, h3, h4, h5, h6, blockquote, div, dd, dt, tr"; + +/** The characters `\s` matches, so both sides collapse the same spaces. */ +function isSpace(code: number): boolean { + return ( + code === 32 || + (code >= 9 && code <= 13) || + code === 160 || + code === 0x1680 || + (code >= 0x2000 && code <= 0x200a) || + code === 0x2028 || + code === 0x2029 || + code === 0x202f || + code === 0x205f || + code === 0x3000 || + code === 0xfeff + ); +} + +/** A row's drawn text as find compares it (whitespace collapsed, blocks + * apart), with each character's text node (-1 for a gap between blocks) and + * offset in it. */ +export function rowText(row: Element): { + text: string; + nodes: Text[]; + node: number[]; + offset: number[]; +} { + const nodes: Text[] = []; + const node: number[] = []; + const offset: number[] = []; + const chars: string[] = []; + let space = true; + let block: Element | null = null; + for (const root of row.querySelectorAll(TEXT)) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let next = walker.nextNode(); next; next = walker.nextNode()) { + const parent = next.parentElement; + if (!parent || parent.closest(SKIP)) continue; + const own = parent.closest(BLOCK); + if (own !== block) { + if (!space) { + chars.push(" "); + node.push(-1); + offset.push(0); + space = true; + } + block = own; + } + const data = (next as Text).data; + const index = nodes.push(next as Text) - 1; + for (let i = 0; i < data.length; i++) { + if (isSpace(data.charCodeAt(i))) { + if (space) continue; + chars.push(" "); + space = true; + } else { + chars.push(data[i]); + space = false; + } + node.push(index); + offset.push(i); + } + } + } + return { text: chars.join(""), nodes, node, offset }; +} + +/** A Range for each match of `needle` (from findNeedle) in a row's text. */ +export function rangesIn(row: Element, needle: string): Range[] { + if (!needle) return []; + const { text, nodes, node, offset } = rowText(row); + // The needle neither starts nor ends with a space, so both ends of a + // match are characters of a text node. + return offsetsIn(foldCase(text), needle).map((start) => { + const end = start + needle.length - 1; + const range = document.createRange(); + range.setStart(nodes[node[start]], offset[start]); + range.setEnd(nodes[node[end]], offset[end] + 1); + return range; + }); +} + +/** The CSS Custom Highlight API (Chromium, newer WebKitGTK). */ +export function canHighlight(): boolean { + return ( + typeof CSS !== "undefined" && + "highlights" in CSS && + typeof Highlight === "function" + ); +} + +/** Highlights every match and the current one. False when the webview + * cannot, so the caller marks the row instead. */ +export function paintMatches(all: Range[], current: Range | null): boolean { + if (!canHighlight()) return false; + const every = new Highlight(); + for (const range of all) every.add(range); + CSS.highlights.set(HIGHLIGHT_ALL, every); + if (current) { + const highlight = new Highlight(current); + highlight.priority = 1; + CSS.highlights.set(HIGHLIGHT_CURRENT, highlight); + } else CSS.highlights.delete(HIGHLIGHT_CURRENT); + return true; +} + +export function clearMatches(root?: Element | null) { + if (canHighlight()) { + CSS.highlights.delete(HIGHLIGHT_ALL); + CSS.highlights.delete(HIGHLIGHT_CURRENT); + } + markRow(root, null, "row"); +} + +/** Marks the current match's row (and unmarks the previous one). */ +export function markRow( + root: Element | null | undefined, + row: Element | null, + how: "text" | "row", +) { + root?.querySelectorAll(`[${FIND_CURRENT_ATTR}]`).forEach((marked) => { + if (marked !== row) marked.removeAttribute(FIND_CURRENT_ATTR); + }); + if (row && row.getAttribute(FIND_CURRENT_ATTR) !== how) + row.setAttribute(FIND_CURRENT_ATTR, how); +} + +/** What to scroll to: the match, else its row's first box (rows are + * `display: contents`, so the wrapper itself has none). */ +export function revealTarget( + row: Element, + range: Range | null, +): Range | Element { + return range || row.firstElementChild || row; +} + +const MARGIN = 48; + +/** The target is inside the scrolled view, clear of its edges. */ +export function inView(stream: Element, target: Range | Element): boolean { + const box = target.getBoundingClientRect(); + const view = stream.getBoundingClientRect(); + return box.top >= view.top + MARGIN && box.bottom <= view.bottom - MARGIN; +} + +/** Scrolls the stream so the target sits in the middle (a tall row: its + * top), only when it is not already in view. */ +export function scrollToTarget( + stream: Element, + target: Range | Element, + smooth: boolean, +): boolean { + if (inView(stream, target)) return false; + const box = target.getBoundingClientRect(); + const view = stream.getBoundingClientRect(); + const offset = + box.height > view.height - 2 * MARGIN + ? MARGIN + : (view.height - box.height) / 2; + stream.scrollTo({ + top: Math.max(0, stream.scrollTop + box.top - view.top - offset), + behavior: smooth ? "smooth" : "auto", + }); + return true; +} + +/** Smooth scrolling unless the system asks for reduced motion. */ +export function smoothScrolling(): boolean { + return !window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches; +} diff --git a/ui/src/lib/fuzzy.test.ts b/ui/src/lib/fuzzy.test.ts new file mode 100644 index 00000000..a361259a --- /dev/null +++ b/ui/src/lib/fuzzy.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { fuzzyMatch, fuzzyRank, matchSegments } from "./fuzzy"; + +const labels = (hits: { item: string }[]) => hits.map((hit) => hit.item); + +describe("fuzzyMatch", () => { + it("finds the query's letters in order, across words", () => { + expect(fuzzyMatch("rev ch", "Review changes")?.indices).toEqual([ + 0, 1, 2, 7, 8, + ]); + expect(fuzzyMatch("cmp", "Compare models on this task")?.indices).toEqual([ + 0, 2, 3, + ]); + }); + + it("ignores case, accents and spaces in the query", () => { + expect(fuzzyMatch("REVIEW", "review changes")).not.toBeNull(); + expect(fuzzyMatch("resume", "Résumé the task")).not.toBeNull(); + expect(fuzzyMatch("ÉTÉ", "ete")).not.toBeNull(); + expect(fuzzyMatch("review changes", "Reviewchanges")).not.toBeNull(); + }); + + it("does not match letters out of order or missing", () => { + expect(fuzzyMatch("chrev", "Review changes")).toBeNull(); + expect(fuzzyMatch("xyz", "Review changes")).toBeNull(); + expect(fuzzyMatch("settingsx", "Settings")).toBeNull(); + }); + + it("matches everything, unscored, for an empty query", () => { + expect(fuzzyMatch("", "Anything")).toEqual({ score: 0, indices: [] }); + expect(fuzzyMatch(" ", "Anything")).toEqual({ score: 0, indices: [] }); + }); + + it("prefers a word start, including a case change, over an earlier mid-word letter", () => { + expect(fuzzyMatch("pr", "openProject")?.indices).toEqual([4, 5]); + expect(fuzzyMatch("t", "Get the tests")?.indices).toEqual([4]); + }); + + it("prefers letters that run together over scattered ones", () => { + expect(fuzzyMatch("ana", "banana")?.indices).toEqual([1, 2, 3]); + }); + + it("counts characters as code points", () => { + expect(fuzzyMatch("b", "😀 b")?.indices).toEqual([2]); + }); +}); + +describe("fuzzyRank", () => { + it("ranks word starts, runs and prefixes first", () => { + expect( + labels(fuzzyRank(["Determine", "Open a terminal"], "term", String)), + ).toEqual(["Open a terminal", "Determine"]); + expect( + labels( + fuzzyRank(["Background processes", "Browse files"], "bro", String), + ), + ).toEqual(["Browse files", "Background processes"]); + expect( + labels(fuzzyRank(["Toggle settings", "Settings"], "set", String)), + ).toEqual(["Settings", "Toggle settings"]); + }); + + it("drops items that do not match and keeps the given order on ties", () => { + const items = [ + "Commit, push and open a pull request", + "Compare models on this task", + "Comparisons in this project", + "Browse files", + ]; + expect(labels(fuzzyRank(items, "cmp", String))).toEqual([ + "Compare models on this task", + "Comparisons in this project", + "Commit, push and open a pull request", + ]); + expect(labels(fuzzyRank([items[2], items[1]], "cmp", String))).toEqual([ + items[2], + items[1], + ]); + }); + + it("keeps every item, in order, for an empty query", () => { + expect(labels(fuzzyRank(["b", "a"], "", String))).toEqual(["b", "a"]); + }); +}); + +describe("matchSegments", () => { + it("splits text into matched and unmatched runs without changing it", () => { + const segments = matchSegments("Review changes", [0, 1, 2, 7, 8]); + expect(segments).toEqual([ + { text: "Rev", match: true }, + { text: "iew ", match: false }, + { text: "ch", match: true }, + { text: "anges", match: false }, + ]); + expect(segments.map((s) => s.text).join("")).toBe("Review changes"); + }); + + it("uses the same positions as the matcher", () => { + const text = "😀 b"; + expect(matchSegments(text, fuzzyMatch("b", text)!.indices)).toEqual([ + { text: "😀 ", match: false }, + { text: "b", match: true }, + ]); + }); +}); diff --git a/ui/src/lib/fuzzy.ts b/ui/src/lib/fuzzy.ts new file mode 100644 index 00000000..91ee485c --- /dev/null +++ b/ui/src/lib/fuzzy.ts @@ -0,0 +1,167 @@ +/** Fuzzy matching for the command palette. The query's letters must appear + * in the text in order, though not side by side, ignoring case, accents and + * spaces, so "rev ch" finds "Review changes" and "cmp" finds "Compare models + * on this task". Letters that start words, run together or begin the text + * score higher, so the match a person meant ranks first. */ + +export type FuzzyMatch = { + score: number; + /** Positions of the matched characters (code points, as `Array.from` + * splits the text), ascending. */ + indices: number[]; +}; + +// Points per matched letter, the bonuses that make one alignment better +// than another, and the penalties for letters skipped between matches. +const SCORE_MATCH = 16; +const BONUS_BOUNDARY = 10; +const BONUS_PREFIX = 8; +const BONUS_CONSECUTIVE = 8; +const PENALTY_GAP_START = 3; +const PENALTY_GAP_EXTENSION = 1; +// A light preference for earlier matches: it breaks ties without +// outweighing a word start further along. +const PENALTY_LEADING = 0.5; +const MAX_LEADING_PENALTY = 5; + +const WORD = /[\p{L}\p{N}]/u; +const UPPER = /\p{Lu}/u; +const LOWER = /\p{Ll}/u; +const MARKS = /\p{M}/gu; + +/** One character, lower case and without accents ("É" → "e"). */ +function fold(ch: string) { + if (ch < "\u0080") return ch.toLowerCase(); + return ch.normalize("NFD").replace(MARKS, "").toLowerCase(); +} + +/** A word starts after a space or punctuation, or at a lower→upper case + * change ("openProject"). */ +function startsWord(chars: string[], i: number) { + if (!WORD.test(chars[i])) return false; + if (i === 0 || !WORD.test(chars[i - 1])) return true; + return LOWER.test(chars[i - 1]) && UPPER.test(chars[i]); +} + +/** The best way `query` matches `text`, or null when it does not. An empty + * query matches everything with no score. */ +export function fuzzyMatch(query: string, text: string): FuzzyMatch | null { + const needle = Array.from(query.replace(/\s+/g, "")) + .map(fold) + .filter(Boolean); + const m = needle.length; + if (!m) return { score: 0, indices: [] }; + const chars = Array.from(text); + const n = chars.length; + if (m > n) return null; + const hay = chars.map(fold); + + // Most texts do not match at all: a quick in-order scan rules them out + // before the full search. + for (let i = 0, j = 0; j < m; i++) { + if (i === n) return null; + if (hay[i] === needle[j]) j++; + } + + const bonus = chars.map( + (_, i) => + (i === 0 ? BONUS_PREFIX : 0) + + (startsWord(chars, i) ? BONUS_BOUNDARY : 0), + ); + // score[j*n + i]: the best score for the query's first j+1 letters with + // letter j matched at text position i; from[…]: where letter j-1 was. + const score = new Float64Array(m * n).fill(-Infinity); + const from = new Int32Array(m * n).fill(-1); + for (let j = 0; j < m; j++) { + const row = j * n; + const prev = row - n; + // The best earlier match at least two positions back (a gap), with the + // gap's per-letter penalty folded in so one running maximum serves + // every position. + let gapBest = -Infinity; + let gapAt = -1; + for (let i = j; i < n; i++) { + if (j > 0 && i >= 2) { + const k = i - 2; + const candidate = score[prev + k] + PENALTY_GAP_EXTENSION * k; + // On a tie the later start leaves the shorter gap. + if (candidate >= gapBest && score[prev + k] > -Infinity) { + gapBest = candidate; + gapAt = k; + } + } + if (hay[i] !== needle[j]) continue; + const own = SCORE_MATCH + bonus[i]; + if (j === 0) { + score[i] = own - Math.min(i * PENALTY_LEADING, MAX_LEADING_PENALTY); + continue; + } + let best = -Infinity; + let at = -1; + if (score[prev + i - 1] > -Infinity) { + best = score[prev + i - 1] + BONUS_CONSECUTIVE; + at = i - 1; + } + if (gapAt >= 0) { + const gap = + gapBest - PENALTY_GAP_START - PENALTY_GAP_EXTENSION * (i - 2); + if (gap > best) { + best = gap; + at = gapAt; + } + } + if (at < 0) continue; + score[row + i] = own + best; + from[row + i] = at; + } + } + + const last = (m - 1) * n; + let end = -1; + for (let i = m - 1; i < n; i++) + if ( + score[last + i] > -Infinity && + (end < 0 || score[last + i] > score[last + end]) + ) + end = i; + if (end < 0) return null; + const indices: number[] = new Array(m); + for (let j = m - 1, i = end; j >= 0; j--) { + indices[j] = i; + i = from[j * n + i]; + } + return { score: score[last + end], indices }; +} + +/** The items that match `query`, best first; equal scores keep the items' + * own order. */ +export function fuzzyRank( + items: readonly T[], + query: string, + text: (item: T) => string, +): (FuzzyMatch & { item: T })[] { + const hits: (FuzzyMatch & { item: T })[] = []; + for (const item of items) { + const match = fuzzyMatch(query, text(item)); + if (match) hits.push({ item, ...match }); + } + // Array sort is stable, so ties stay in the given order. + return hits.sort((a, b) => b.score - a.score); +} + +/** `text` split into runs of matched and unmatched characters, for showing + * the matched ones emphasised without changing the text itself. */ +export function matchSegments( + text: string, + indices: readonly number[], +): { text: string; match: boolean }[] { + const marked = new Set(indices); + const segments: { text: string; match: boolean }[] = []; + Array.from(text).forEach((ch, i) => { + const match = marked.has(i); + const last = segments.at(-1); + if (last && last.match === match) last.text += ch; + else segments.push({ text: ch, match }); + }); + return segments; +} diff --git a/ui/src/lib/messageTime.test.ts b/ui/src/lib/messageTime.test.ts new file mode 100644 index 00000000..34064849 --- /dev/null +++ b/ui/src/lib/messageTime.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import type { ChatItem } from "../components/cards"; +import { + QUIET_GAP_SECONDS, + eventTime, + messageTime, + quietTimes, +} from "./messageTime"; + +/** Local wall-clock times, so the tests hold in any time zone. */ +const at = (y: number, m: number, d: number, h: number, min: number) => + new Date(y, m - 1, d, h, min).getTime() / 1000; + +describe("message times", () => { + const now = at(2026, 10, 12, 16, 5); + const label = (ts: number, locale = "en-GB") => + messageTime(ts, now, locale)?.label; + + it("says the time today, Yesterday, the date this year, the year before", () => { + expect(label(at(2026, 10, 12, 14, 32))).toBe("14:32"); + expect(label(at(2026, 10, 12, 0, 1))).toBe("00:01"); + expect(label(at(2026, 10, 11, 14, 32))).toBe("Yesterday 14:32"); + expect(label(at(2026, 10, 11, 23, 59))).toBe("Yesterday 23:59"); + expect(label(at(2026, 10, 3, 9, 15))).toBe("3 Oct, 09:15"); + expect(label(at(2026, 1, 1, 9, 15))).toBe("1 Jan, 09:15"); + expect(label(at(2025, 12, 31, 23, 0))).toBe("31 Dec 2025"); + }); + + it("follows the viewer's locale", () => { + expect(label(at(2026, 10, 12, 14, 32), "en-US")).toMatch(/^2:32\sPM$/); + expect(label(at(2026, 10, 11, 9, 5), "en-US")).toMatch( + /^Yesterday 9:05\sAM$/, + ); + expect(label(at(2026, 10, 3, 9, 15), "en-US")).toMatch(/^Oct 3, 9:15\sAM$/); + expect(label(at(2026, 10, 3, 9, 15), "de-DE")).toBe("3. Okt., 09:15"); + }); + + it("gives the full date and time for the tooltip and a machine time", () => { + const ts = at(2026, 10, 11, 14, 32); + const time = messageTime(ts, now, "en-GB"); + expect(time?.full).toMatch(/^Sunday,? 11 October 2026.* 14:32$/); + expect(time?.iso).toBe(new Date(ts * 1000).toISOString()); + }); + + it("a later day (a clock set wrong) shows its date, not a bare time", () => { + expect(label(at(2026, 10, 13, 8, 0))).toBe("13 Oct, 08:00"); + }); + + it("words follow the clock: today's time is Yesterday after midnight", () => { + const ts = at(2026, 10, 12, 23, 50); + expect(messageTime(ts, at(2026, 10, 12, 23, 55), "en-GB")?.label).toBe( + "23:50", + ); + expect(messageTime(ts, at(2026, 10, 13, 0, 5), "en-GB")?.label).toBe( + "Yesterday 23:50", + ); + }); + + it("never makes up a time", () => { + expect(messageTime(0, now)).toBeNull(); + expect(messageTime(Number.NaN, now)).toBeNull(); + expect(eventTime(undefined)).toBeUndefined(); + expect(eventTime("1790000000")).toBeUndefined(); + expect(eventTime(-5)).toBeUndefined(); + expect(eventTime(1_790_000_000.25)).toBe(1_790_000_000.25); + }); +}); + +describe("quiet times", () => { + const user = (ts?: number): ChatItem => ({ kind: "user", text: "Q", ts }); + const agent = (ts?: number): ChatItem => ({ kind: "agent", text: "A", ts }); + const note: ChatItem = { kind: "note", text: "Using Codex" }; + const start = at(2026, 10, 12, 14, 0); + + it("a quick exchange shows its time once", () => { + const items = [user(start), note, agent(start + 40), user(start + 120)]; + const quiet = quietTimes(items); + expect(quiet.has(items[0])).toBe(false); + expect(quiet.has(items[2])).toBe(true); + expect(quiet.has(items[3])).toBe(true); + }); + + it("shows the time again a few minutes after the last one shown", () => { + const step = QUIET_GAP_SECONDS - 60; + const items = [0, 1, 2, 3].map((n) => agent(start + n * step)); + const quiet = quietTimes(items); + expect(items.map((item) => quiet.has(item))).toEqual([ + false, + true, + false, + true, + ]); + }); + + it("a new day, an earlier time or a message without one is never quiet", () => { + const late = at(2026, 10, 12, 23, 58); + const items = [ + user(late), + agent(at(2026, 10, 13, 0, 1)), + user(late), + agent(undefined), + agent(late + 30), + ]; + const quiet = quietTimes(items); + expect(quiet.has(items[1])).toBe(false); + expect(quiet.has(items[2])).toBe(false); + expect(quiet.has(items[3])).toBe(false); + expect(quiet.has(items[4])).toBe(true); + }); +}); diff --git a/ui/src/lib/messageTime.ts b/ui/src/lib/messageTime.ts new file mode 100644 index 00000000..eccf9613 --- /dev/null +++ b/ui/src/lib/messageTime.ts @@ -0,0 +1,113 @@ +import type { ChatItem } from "../components/cards"; + +/** A message within this many seconds of the one before it (on the same + * day) keeps its time quiet: it shows on hover or focus, not all the time. */ +export const QUIET_GAP_SECONDS = 5 * 60; + +/** An event's `ts` (seconds since the epoch, as the engine records it), or + * nothing when it is missing or unusable. A message never shows a made-up + * time. */ +export function eventTime(ts: unknown): number | undefined { + return typeof ts === "number" && Number.isFinite(ts) && ts > 0 + ? ts + : undefined; +} + +export type MessageTimeText = { + /** What the transcript shows: "14:32", "Yesterday 14:32", "3 Oct, 14:32", + * or "3 Oct 2025" for another year. */ + label: string; + /** The full local date and time, for the tooltip. */ + full: string; + /** Machine-readable, for `