mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +03:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f3de6ea3e | ||
|
|
55405f6cd6 | ||
|
|
b0ef759e2c | ||
|
|
9a7debcb48 | ||
|
|
922c49246d | ||
|
|
df1a0ed889 |
@@ -100,3 +100,4 @@ temp/
|
||||
exp/
|
||||
.playwright-mcp/
|
||||
bridge/node_modules/
|
||||
webui/.verify-*
|
||||
|
||||
@@ -62,7 +62,7 @@ nanobot is a self-hosted personal AI agent runtime. It can:
|
||||
|
||||
## Releases
|
||||
|
||||
**Coming next: v0.3.0 - The Agency Release**
|
||||
**Latest release: [v0.3.0 - The Agency Release](https://github.com/HKUDS/nanobot/releases/tag/v0.3.0)**
|
||||
|
||||
The Agency Release turns nanobot from a durable workbench into an agent runtime that can coordinate helpers, switch models per session, and carry authorized work through to completion.
|
||||
|
||||
@@ -71,9 +71,7 @@ The Agency Release turns nanobot from a durable workbench into an agent runtime
|
||||
- Start from a guided WebUI setup with clearer execution controls
|
||||
- Apply configuration changes live across a more reliable provider, channel, and tool runtime
|
||||
|
||||
[Follow the v0.3.0 release candidate](https://github.com/HKUDS/nanobot/pull/5081)
|
||||
|
||||
**Current stable:** [v0.2.2 - The Durability Release](https://github.com/HKUDS/nanobot/releases/tag/v0.2.2)
|
||||
[Read the v0.3.0 release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.3.0)
|
||||
|
||||
## Open Source Partners
|
||||
|
||||
@@ -127,7 +125,7 @@ Windows PowerShell:
|
||||
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes, skip the manual initialize/configure steps below and go straight to **Open the WebUI**. The installer also prints the exact command it used to run nanobot; reuse that full command below if `nanobot` is not on `PATH`.
|
||||
The default command installs or upgrades `nanobot-ai` from PyPI. On a fresh local desktop, it then starts `nanobot webui` so you can configure the first provider and model in **Settings → Models**. SSH, headless, existing-config, and older-release paths keep the terminal setup wizard. The installer avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. It also prints the exact command it used to run nanobot; reuse that full command below if `nanobot` is not on `PATH`.
|
||||
|
||||
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
|
||||
|
||||
@@ -187,97 +185,64 @@ If `nanobot` is not on `PATH`, invoke it through the method that installed it: r
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
**1. Initialize**
|
||||
|
||||
Skip this step if the one-command setup already started the wizard and Quick Start finished there.
|
||||
**Open nanobot in your browser**
|
||||
|
||||
```bash
|
||||
nanobot onboard
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
Use `nanobot onboard --wizard` if you prefer an interactive setup.
|
||||
This is the recommended first run. The launcher creates the config and workspace when needed, safely enables the local WebSocket channel after confirmation, starts the gateway, and opens [`http://127.0.0.1:8765`](http://127.0.0.1:8765). A fresh install can open before a model is configured, so setup continues in the browser instead of beginning in a JSON file. The first-run WebUI binds to localhost by default and is not exposed to your LAN.
|
||||
|
||||
**2. Configure** (`~/.nanobot/config.json`)
|
||||
**Your first three steps**
|
||||
|
||||
Skip this step if you already configured provider and model settings in the wizard.
|
||||
1. Open **Settings → Models** and choose a provider, credential, and model.
|
||||
2. Start a new topic and send `Hello!` to verify the connection.
|
||||
3. Before project work, choose the intended workspace and access mode from the composer.
|
||||
|
||||
`nanobot onboard` creates `~/.nanobot/config.json` and `~/.nanobot/workspace/`. Configure these **two parts** in the config file. Add or merge the following blocks into the existing file instead of replacing the whole file.
|
||||
Any normal reply means the provider, model, workspace, and browser gateway are working together.
|
||||
|
||||
The example below uses a generic OpenAI-compatible `custom` provider so the compact path does not recommend one hosted service. Provider examples are recipes, not rankings or endorsements. For copyable provider-specific setup, see [Provider Cookbook](./docs/provider-cookbook.md).
|
||||
**Keep nanobot running after you close the terminal**
|
||||
|
||||
*Set your API key*:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"custom": {
|
||||
"apiKey": "your-api-key",
|
||||
"apiBase": "https://api.example.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```bash
|
||||
nanobot webui --background
|
||||
```
|
||||
|
||||
*Set a model preset and make it active*:
|
||||
This starts the same full gateway as `nanobot webui`, opens the browser, and leaves channels and automations running after the launcher exits. Complete first-time model setup with foreground `nanobot webui` before switching to background mode.
|
||||
|
||||
```json
|
||||
{
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"label": "Primary",
|
||||
"provider": "custom",
|
||||
"model": "model-id-from-your-provider",
|
||||
"maxTokens": 8192,
|
||||
"contextWindowTokens": 200000,
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "primary"
|
||||
}
|
||||
}
|
||||
}
|
||||
```bash
|
||||
nanobot gateway status
|
||||
nanobot gateway logs
|
||||
nanobot gateway restart
|
||||
nanobot gateway stop
|
||||
```
|
||||
|
||||
Direct `agents.defaults.provider` and `agents.defaults.model` still work for existing configs, but named presets are the recommended path because they also power `/model` switching and `fallbackModels`.
|
||||
|
||||
For another provider, the same config shape still applies:
|
||||
|
||||
| Replace | Where |
|
||||
|---|---|
|
||||
| Provider config key | `providers.<provider>` |
|
||||
| API key | `providers.<provider>.apiKey` |
|
||||
| Preset provider name | `modelPresets.primary.provider` |
|
||||
| Model ID | `modelPresets.primary.model` |
|
||||
| Endpoint URL, only when needed | `providers.<provider>.apiBase` |
|
||||
|
||||
**3. Open the WebUI**
|
||||
|
||||
The stable-compatible path is:
|
||||
**Prefer a gateway-first workflow?**
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
Leave the terminal open and visit `http://127.0.0.1:8765`. Current source versions also provide `nanobot webui`, which prepares the local WebSocket channel if needed, starts the gateway, and opens the browser automatically. The first-run WebUI binds to `127.0.0.1` by default, so it is not exposed to your LAN. Prefer not to keep a terminal open? Use `nanobot gateway --background`, then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
|
||||
This skips WebUI setup and browser opening, then runs the same complete gateway in the current terminal. It is the familiar entry point if you are coming from OpenClaw or already operate agents as long-lived services. The WebUI remains available when its channel is configured; open it manually when needed.
|
||||
|
||||
For manual or terminal-only setup, test one CLI message:
|
||||
Use `nanobot gateway --background` for the same direct entry point without keeping the terminal attached. For automatic startup and supervision by the operating system, see [Deployment](./docs/deployment.md).
|
||||
|
||||
```bash
|
||||
nanobot status
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
In `nanobot status`, it is normal for most providers to say `not set`. The active preset's provider should be configured, and `Config` plus `Workspace` should show check marks.
|
||||
|
||||
If that works, start an interactive chat:
|
||||
**Prefer to work entirely in the terminal?**
|
||||
|
||||
```bash
|
||||
nanobot agent
|
||||
```
|
||||
|
||||
Need help with `PATH`, API keys, provider/model matching, or JSON errors? See the fuller [Install and Quick Start](./docs/quick-start.md) and [Troubleshooting](./docs/troubleshooting.md).
|
||||
This opens an interactive terminal chat with the same configured model, workspace, and tools while keeping its own CLI session history. It does not open a browser or keep chat channels and automations running after you exit. Type `exit` or press `Ctrl+C` when you are done.
|
||||
|
||||
For one request and an immediate exit, use:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
The one-shot form is useful for a quick provider check, shell scripts, and local automation. If you have not configured a model yet, run `nanobot webui` and open **Settings → Models** first.
|
||||
|
||||
Need manual JSON, another device on your LAN, or help with provider/model matching? Continue with [Install and Quick Start](./docs/quick-start.md), [WebUI](./docs/webui.md), or [Troubleshooting](./docs/troubleshooting.md).
|
||||
|
||||
- Want a pasteable provider setup? See [Provider Cookbook](./docs/provider-cookbook.md)
|
||||
- Want to understand provider/model matching? See [Providers and Models](./docs/providers.md)
|
||||
@@ -288,24 +253,20 @@ Need help with `PATH`, API keys, provider/model matching, or JSON errors? See th
|
||||
|
||||
## 🌐 WebUI
|
||||
|
||||
The WebUI ships **inside the published wheel** — no extra build step. It is the browser workbench for topics, workspace controls, Apps, Skills, Automations, and settings. For the full user guide, see [`docs/webui.md`](./docs/webui.md).
|
||||
The WebUI ships **inside the published wheel** with no separate frontend build. It is the browser workbench for persistent topics, visible agent activity, workspace controls, Apps, Skills, Automations, and settings.
|
||||
|
||||
<p align="center">
|
||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
||||
</p>
|
||||
|
||||
**Open it**
|
||||
Use it to:
|
||||
|
||||
```bash
|
||||
nanobot webui
|
||||
```
|
||||
- keep separate topics for different tasks and projects;
|
||||
- inspect reasoning, tool calls, file edits, diffs, command output, and generated artifacts;
|
||||
- switch models and workspaces without leaving the conversation;
|
||||
- configure providers, chat channels, Apps, Skills, and Automations from one place.
|
||||
|
||||
On current source versions, the command enables the local WebSocket channel after confirmation, starts the gateway, and opens [`http://127.0.0.1:8765`](http://127.0.0.1:8765). If your installed stable release does not include `nanobot webui`, run `nanobot gateway` and open that address manually. To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access).
|
||||
|
||||
The WebUI is served by the WebSocket channel on port `8765` by default. The gateway's `18790` port is for the health endpoint, not the browser UI.
|
||||
|
||||
> [!TIP]
|
||||
> Working on the WebUI itself? Check out [`webui/README.md`](./webui/README.md) for the source-tree, Vite dev server, build, and test workflow.
|
||||
See the [WebUI guide](./docs/webui.md) for LAN access, background operation, workspace controls, and the full feature tour. Working on the frontend itself? Use [`webui/README.md`](./webui/README.md).
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
|
||||
+3
-3
@@ -15,11 +15,11 @@ Repository docs follow the current source tree and can be newer than the latest
|
||||
The recommended first-run path is:
|
||||
|
||||
1. Install nanobot.
|
||||
2. Choose **Quick Start** in `nanobot onboard --wizard`.
|
||||
3. Run `nanobot gateway` and open `http://127.0.0.1:8765`.
|
||||
2. Let the installer open `nanobot webui` on a fresh local desktop.
|
||||
3. Configure a provider and model in **Settings → Models**.
|
||||
4. Send `Hello!` before configuring anything else.
|
||||
|
||||
Most people do not need to edit JSON for the first run. The wizard handles the initial provider, model, and local WebUI settings. Current source versions also provide `nanobot webui` to start the gateway and open the browser in one step. After the WebUI opens, use **Settings** for models and built-in capabilities, **Settings → Channels** for chat apps, and **Apps** for CLI App or MCP integrations.
|
||||
Most people do not need to edit JSON for the first run. The WebUI handles the initial provider, model, and local browser settings. SSH, headless, existing-config, and older-release installs retain `nanobot onboard --wizard` as a terminal fallback. After the WebUI opens, use **Settings** for models and built-in capabilities, **Settings → Channels** for chat apps, and **Apps** for CLI App or MCP integrations.
|
||||
|
||||
## Add One Capability
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
||||
| `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser |
|
||||
| `nanobot webui --port <port>` | Set the WebUI/WebSocket port |
|
||||
| `nanobot webui --gateway-port <port>` | Override the gateway health port |
|
||||
| `nanobot webui --yes` | Apply safe localhost WebUI defaults without confirmation; provider credentials still require interactive setup |
|
||||
| `nanobot webui --yes` | Apply safe localhost WebUI defaults without confirmation; configure provider credentials in **Settings → Models** |
|
||||
|
||||
First-run WebUI setup binds to `127.0.0.1` by default. Use manual configuration and a WebUI password before exposing the WebSocket channel beyond localhost.
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@ These variables are process-level switches. Set them in the same terminal, servi
|
||||
|----------|---------|-------------|
|
||||
| `NANOBOT_BIN_DIR` | `$HOME/.local/bin` | Installer launcher directory on macOS/Linux. |
|
||||
| `NANOBOT_VENV` | `$HOME/.nanobot/venv` | Managed virtual environment path used by the installer fallback. |
|
||||
| `NANOBOT_SKIP_WIZARD` | unset | Set to `1` to skip `nanobot onboard --wizard` after one-command install. |
|
||||
| `NANOBOT_SKIP_WIZARD` | unset | Set to `1` to skip automatic WebUI or wizard setup after one-command install. |
|
||||
| `NANOBOT_SKIP_WEBUI_BUILD` | unset | Set to `1` to skip bundling the WebUI during package builds. |
|
||||
| `NANOBOT_FORCE_WEBUI_BUILD` | unset | Set to `1` to rebuild the bundled WebUI even when `nanobot/web/dist/index.html` already exists. |
|
||||
| `NANOBOT_EXTRAS` | unset | Docker build argument containing comma-separated Python extras such as `bedrock`. |
|
||||
|
||||
+20
-21
@@ -16,7 +16,7 @@ Git is only needed for a source install. The published package already contains
|
||||
|
||||
## 1. Install nanobot
|
||||
|
||||
The recommended installer keeps nanobot out of the system Python environment and opens the setup wizard when installation finishes.
|
||||
The recommended installer keeps nanobot out of the system Python environment. On a fresh local desktop, it starts the WebUI when installation finishes.
|
||||
|
||||
**macOS / Linux**
|
||||
|
||||
@@ -34,31 +34,34 @@ The installer chooses an active virtual environment, `uv`, `pipx`, or a managed
|
||||
|
||||
If you prefer to inspect the scripts first, open [`install.sh`](../scripts/install.sh) or [`install.ps1`](../scripts/install.ps1).
|
||||
|
||||
## 2. Complete Quick Start
|
||||
## 2. Configure Your Model
|
||||
|
||||
The installer opens `nanobot onboard --wizard`. Choose **Quick Start** and follow the prompts:
|
||||
Keep the installer terminal open. The browser opens the local WebUI; go to **Settings → Models** and:
|
||||
|
||||
1. Choose the provider or endpoint that owns your credential.
|
||||
2. Enter its API key or base URL when requested.
|
||||
3. Enter a model ID that the same provider can run.
|
||||
4. Let Quick Start enable the local WebUI.
|
||||
5. Set a WebUI password and review the summary.
|
||||
2. Enter its API key or base URL when required.
|
||||
3. Create or select a model preset using a model ID that provider can run.
|
||||
4. Save the configuration.
|
||||
|
||||
Quick Start creates or updates:
|
||||
The WebUI launcher creates or updates:
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings |
|
||||
| `~/.nanobot/workspace/` | Sessions, memory, skills, automations, and generated files |
|
||||
|
||||
If the installer did not open the wizard, run it yourself:
|
||||
If the installer did not open the browser, run:
|
||||
|
||||
```bash
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
SSH, headless, existing-config, and older-release installs retain the terminal setup path:
|
||||
|
||||
```bash
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
Current source versions also provide `nanobot webui`. When run without a usable model, that launcher offers the same Quick Start flow before starting the browser.
|
||||
|
||||
## 3. Check the Setup
|
||||
|
||||
```bash
|
||||
@@ -75,11 +78,7 @@ Most other providers can say `not set`. This command validates local setup but d
|
||||
|
||||
## 4. Get the First Reply
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
Quick Start has already prepared the local WebSocket channel. Leave the gateway terminal open and visit `http://127.0.0.1:8765`; the first-run WebUI is bound to localhost, so other devices on your network cannot reach it. On current source versions, you can run `nanobot webui` instead to perform the local WebUI checks, start the gateway, and open the browser automatically.
|
||||
If the installer-started WebUI is no longer running, run `nanobot webui` again. Leave that terminal open; the first-run WebUI is bound to localhost, so other devices on your network cannot reach it.
|
||||
|
||||
Send:
|
||||
|
||||
@@ -131,20 +130,20 @@ After the first reply works, add one capability and test again:
|
||||
|
||||
## Other Install Methods
|
||||
|
||||
Use one method, then continue at [Complete Quick Start](#2-complete-quick-start).
|
||||
Use one method, then continue at [Configure Your Model](#2-configure-your-model).
|
||||
|
||||
**uv**
|
||||
|
||||
```bash
|
||||
uv tool install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
**pip in a virtual environment**
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
If pip reports `externally-managed-environment`, use the recommended installer, `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or create a virtual environment. Do not force a system-wide install.
|
||||
@@ -157,7 +156,7 @@ If pip reports `externally-managed-environment`, use the recommended installer,
|
||||
git clone https://github.com/HKUDS/nanobot.git
|
||||
cd nanobot
|
||||
python -m pip install .
|
||||
nanobot onboard --wizard
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
On Windows, if `python -m pip install .` reports that it cannot launch `npm`, run `cd webui`, `npm.cmd install --package-lock=false`, `npm.cmd run build`, and `cd ..` in order, then retry the install.
|
||||
@@ -172,7 +171,7 @@ pipx run --spec nanobot-ai nanobot --version
|
||||
~/.nanobot/venv/bin/python -m nanobot --version
|
||||
```
|
||||
|
||||
On Windows, the managed-environment form is `& "$HOME\.nanobot\venv\Scripts\python.exe" -m nanobot --version`. Replace `--version` with `onboard --wizard`, `gateway`, or any other arguments you need. Use plain `python -m nanobot` only when that Python executable belongs to the environment where nanobot was installed.
|
||||
On Windows, the managed-environment form is `& "$HOME\.nanobot\venv\Scripts\python.exe" -m nanobot --version`. Replace `--version` with `webui`, `onboard --wizard`, or any other arguments you need. Use plain `python -m nanobot` only when that Python executable belongs to the environment where nanobot was installed.
|
||||
|
||||
## Manual Configuration Fallback
|
||||
|
||||
|
||||
@@ -70,53 +70,35 @@ curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.
|
||||
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
The installer downloads the stable nanobot package into an isolated Python environment and opens the setup wizard. It can take a few minutes on the first run. When it finishes, it prints the exact command it used to run nanobot. Keep that command: if `nanobot` is not found later, reuse the whole printed command instead of switching to a different Python command.
|
||||
The installer downloads the stable nanobot package into an isolated Python environment. On a fresh local desktop, it then starts the WebUI and opens your browser. This can take a few minutes on the first run. Keep the terminal open. It prints the exact command used to run nanobot; if `nanobot` is not found later, reuse that whole command instead of switching to a different Python command.
|
||||
|
||||
If your organization blocks downloaded install scripts, use the [alternative install methods](./quick-start.md#other-install-methods) or ask your administrator to review the scripts first.
|
||||
|
||||
## 4. Follow Quick Start
|
||||
## 4. Configure Your Model in the WebUI
|
||||
|
||||
The wizard shows a menu similar to:
|
||||
|
||||
```text
|
||||
> What would you like to do?
|
||||
[Q] Quick Start
|
||||
[A] Advanced Settings
|
||||
[X] Exit
|
||||
```
|
||||
|
||||
Choose **Quick Start**. Use the arrow keys to highlight an option and press `Enter`.
|
||||
|
||||
The wizard asks for only the information needed for the first reply:
|
||||
In the browser, open **Settings → Models**. Then:
|
||||
|
||||
1. Choose your provider.
|
||||
2. Choose an endpoint option if the provider offers several plans.
|
||||
3. Paste the API key if asked.
|
||||
4. Enter the base URL if asked.
|
||||
5. Enter a model ID.
|
||||
6. Confirm the local WebUI setup.
|
||||
7. Choose a WebUI password.
|
||||
8. Review the summary and save.
|
||||
2. Enter its API key and base URL when required.
|
||||
3. Create or select a model preset.
|
||||
4. Enter a model ID available to your provider account.
|
||||
5. Save the configuration.
|
||||
|
||||
When you paste a password or API key, the terminal may hide the characters. That is normal.
|
||||
Treat every API key like a password. Do not include it in screenshots or support requests.
|
||||
|
||||
If the installer finishes without opening the wizard and `nanobot` is available, run:
|
||||
If the installer finishes without opening the browser and `nanobot` is available, run:
|
||||
|
||||
```bash
|
||||
nanobot onboard --wizard
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
If the terminal cannot find `nanobot`, take the exact command printed by the installer and replace its final arguments with `onboard --wizard`. That command may begin with `uv tool run`, `pipx run`, or the full path to nanobot's private Python environment.
|
||||
If the terminal cannot find `nanobot`, take the exact command printed by the installer and replace its final arguments with `webui`. That command may begin with `uv tool run`, `pipx run`, or the full path to nanobot's private Python environment.
|
||||
|
||||
## 5. Open the Browser
|
||||
On SSH, a computer without a desktop, an existing configuration, or an older nanobot release, the installer may open the terminal wizard instead. Choose **Quick Start** there and follow its prompts.
|
||||
|
||||
Run:
|
||||
## 5. Get the First Reply
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
Leave the terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password from the wizard if the browser asks for it. Current source versions also provide `nanobot webui`, which starts the gateway and opens the browser automatically.
|
||||
Leave the WebUI terminal open. If the browser did not open automatically, visit `http://127.0.0.1:8765`.
|
||||
|
||||
Send this message:
|
||||
|
||||
@@ -143,7 +125,7 @@ Do not configure every feature immediately. Choose one next goal:
|
||||
|
||||
Repository docs show the current development version. If your stable package does not yet show **Settings → Channels**, use the [Chat Apps guide](./chat-apps.md) or update to a release that includes it.
|
||||
|
||||
Some runtime changes ask you to restart nanobot. Use the restart action shown by the WebUI, or return to the terminal, press `Ctrl+C`, and run `nanobot gateway` again.
|
||||
Some runtime changes ask you to restart nanobot. Use the restart action shown by the WebUI, or return to the terminal, press `Ctrl+C`, and run `nanobot webui` again.
|
||||
|
||||
For a chat platform's account, bot, token, or permission prerequisites, use the [Chat Apps guide](./chat-apps.md). For local models and provider-specific recipes, use the [Provider Cookbook](./provider-cookbook.md).
|
||||
|
||||
@@ -175,7 +157,7 @@ Continue with the full [Troubleshooting guide](./troubleshooting.md) for an orde
|
||||
Run:
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
Leave that terminal open and visit `http://127.0.0.1:8765`. To stop nanobot, return to the terminal and press `Ctrl+C`. Use `nanobot gateway --background` only after the normal foreground start works; then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
|
||||
Leave that terminal open while you use nanobot. To stop it, return to the terminal and press `Ctrl+C`. Use `nanobot webui --background` only after the normal foreground start and model setup work; then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
|
||||
|
||||
@@ -21,7 +21,7 @@ if TYPE_CHECKING:
|
||||
|
||||
@cache
|
||||
def _warn_legacy_channel_entry_points() -> None:
|
||||
# TODO(v0.2.4): Remove this detection and warning. v0.2.3 is the final
|
||||
# TODO(v0.3.1): Remove this detection and warning. v0.3.0 is the final
|
||||
# migration window for installed legacy channel entry points.
|
||||
names = sorted({entry_point.name for entry_point in entry_points(group="nanobot.channels")})
|
||||
if not names:
|
||||
|
||||
@@ -209,7 +209,7 @@ def _migrate_config(data: dict) -> dict:
|
||||
defaults.pop("maxMessages", None)
|
||||
defaults.pop("max_messages", None)
|
||||
if had_legacy_max_messages:
|
||||
# TODO(v0.2.4): Remove this legacy cleanup branch. v0.2.3 is the
|
||||
# TODO(v0.3.1): Remove this legacy cleanup branch. v0.3.0 is the
|
||||
# final release that warns before the schema silently ignores the field.
|
||||
logger.warning(
|
||||
"agents.defaults.maxMessages/max_messages is legacy and ignored; "
|
||||
|
||||
@@ -14,14 +14,10 @@ from typing import Any
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.providers.openrouter_attribution import OPENROUTER_ATTRIBUTION_HEADERS
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.utils.helpers import detect_image_mime
|
||||
|
||||
_OPENROUTER_ATTRIBUTION_HEADERS = {
|
||||
"HTTP-Referer": "https://github.com/HKUDS/nanobot",
|
||||
"X-OpenRouter-Title": "nanobot",
|
||||
"X-OpenRouter-Categories": "cli-agent,personal-agent",
|
||||
}
|
||||
_DEFAULT_TIMEOUT_S = 120.0
|
||||
_AIHUBMIX_TIMEOUT_S = 300.0
|
||||
_AIHUBMIX_ASPECT_RATIO_SIZES = {
|
||||
@@ -305,7 +301,7 @@ class OpenRouterImageGenerationClient(ImageGenerationProvider):
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
**_OPENROUTER_ATTRIBUTION_HEADERS,
|
||||
**OPENROUTER_ATTRIBUTION_HEADERS,
|
||||
**self.extra_headers,
|
||||
}
|
||||
url = f"{self.api_base}/chat/completions"
|
||||
|
||||
@@ -35,6 +35,7 @@ from nanobot.providers.openai_responses import (
|
||||
convert_tools,
|
||||
parse_response_output,
|
||||
)
|
||||
from nanobot.providers.openrouter_attribution import OPENROUTER_ATTRIBUTION_HEADERS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openai import AsyncOpenAI as AsyncOpenAIType
|
||||
@@ -54,11 +55,6 @@ _ALNUM = string.ascii_letters + string.digits
|
||||
|
||||
_STANDARD_TC_KEYS = frozenset({"id", "type", "index", "function"})
|
||||
_STANDARD_FN_KEYS = frozenset({"name", "arguments"})
|
||||
_DEFAULT_OPENROUTER_HEADERS = {
|
||||
"HTTP-Referer": "https://github.com/HKUDS/nanobot",
|
||||
"X-OpenRouter-Title": "nanobot",
|
||||
"X-OpenRouter-Categories": "cli-agent,personal-agent",
|
||||
}
|
||||
_KIMI_K3_MODEL = "kimi-k3"
|
||||
_KIMI_THINKING_MODELS: frozenset[str] = frozenset({
|
||||
"kimi-k2.5",
|
||||
@@ -450,7 +446,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
self._effective_base = effective_base
|
||||
self._default_headers = {"x-session-affinity": uuid.uuid4().hex}
|
||||
if _uses_openrouter_attribution(spec, effective_base):
|
||||
self._default_headers.update(_DEFAULT_OPENROUTER_HEADERS)
|
||||
self._default_headers.update(OPENROUTER_ATTRIBUTION_HEADERS)
|
||||
if extra_headers:
|
||||
self._default_headers.update(extra_headers)
|
||||
self._api_key_for_client = api_key or "no-key"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Shared OpenRouter app attribution headers."""
|
||||
|
||||
OPENROUTER_ATTRIBUTION_HEADERS = {
|
||||
"HTTP-Referer": "https://nanobot.wiki",
|
||||
"X-OpenRouter-Title": "nanobot",
|
||||
"X-OpenRouter-Categories": "cli-agent,personal-agent",
|
||||
}
|
||||
@@ -513,7 +513,7 @@ class SessionManager:
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
# TODO(v0.2.4): Remove both legacy fallbacks. v0.2.3 is the final
|
||||
# TODO(v0.3.1): Remove both legacy fallbacks. v0.3.0 is the final
|
||||
# compatibility window for reading and lazily migrating legacy session files.
|
||||
fallback_paths = [
|
||||
(self._get_legacy_lossy_path(key), "legacy lossy path"),
|
||||
|
||||
+43
-4
@@ -42,7 +42,7 @@ function Show-Usage {
|
||||
Write-Host ""
|
||||
Write-Host "By default this installs or upgrades nanobot-ai from PyPI."
|
||||
Write-Host "Use --dev to install from the current main branch on GitHub."
|
||||
Write-Host "Use --dry-run to print what would happen without installing or starting the wizard."
|
||||
Write-Host "Use --dry-run to print what would happen without installing or starting setup."
|
||||
}
|
||||
|
||||
function Test-Python {
|
||||
@@ -133,6 +133,26 @@ function Get-NanobotCommand {
|
||||
}
|
||||
}
|
||||
|
||||
function Test-FreshNanobotInstall {
|
||||
$HomeDir = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)
|
||||
if (-not $HomeDir) {
|
||||
return $false
|
||||
}
|
||||
return -not (Test-Path -LiteralPath (Join-Path $HomeDir ".nanobot\config.json"))
|
||||
}
|
||||
|
||||
function Test-BrowserSession {
|
||||
if ($env:SSH_CONNECTION -or $env:SSH_TTY -or -not [Environment]::UserInteractive) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$CurrentSessionId = (Get-Process -Id $PID).SessionId
|
||||
return @(
|
||||
Get-Process -Name explorer -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.SessionId -eq $CurrentSessionId }
|
||||
).Count -gt 0
|
||||
}
|
||||
|
||||
function Install-WithActivePython {
|
||||
Write-Info "Detected an active virtual environment. Installing into it..."
|
||||
Ensure-Pip $Python
|
||||
@@ -252,7 +272,10 @@ if ($DryRun) {
|
||||
Write-Info "Dry run: would run nanobot as: $VenvDir\Scripts\python.exe -m nanobot"
|
||||
}
|
||||
if ($env:NANOBOT_SKIP_WIZARD -eq "1") {
|
||||
Write-Info "Dry run: would skip setup wizard because NANOBOT_SKIP_WIZARD=1."
|
||||
Write-Info "Dry run: would skip automatic setup because NANOBOT_SKIP_WIZARD=1."
|
||||
} elseif ((Test-FreshNanobotInstall) -and (Test-BrowserSession)) {
|
||||
Write-Info "Dry run: would start the WebUI for this fresh desktop install."
|
||||
Write-Info "Dry run: would fall back to the setup wizard for older releases."
|
||||
} else {
|
||||
Write-Info "Dry run: would run the setup wizard."
|
||||
}
|
||||
@@ -294,11 +317,27 @@ if ($LASTEXITCODE -ne 0) {
|
||||
}
|
||||
|
||||
if ($env:NANOBOT_SKIP_WIZARD -eq "1") {
|
||||
Write-Info "Skipping setup wizard because NANOBOT_SKIP_WIZARD=1."
|
||||
Write-Info "Run this later: $(Get-NanobotCommand) onboard --wizard"
|
||||
Write-Info "Skipping automatic setup because NANOBOT_SKIP_WIZARD=1."
|
||||
Write-Info "Run this later: $(Get-NanobotCommand) webui"
|
||||
return
|
||||
}
|
||||
|
||||
if ((Test-FreshNanobotInstall) -and (Test-BrowserSession)) {
|
||||
Invoke-Nanobot @("webui", "--help") *> $null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Info "Starting nanobot WebUI..."
|
||||
Write-Info "Configure your first provider and model in Settings > Models."
|
||||
Write-Info "Run this later: $(Get-NanobotCommand) webui"
|
||||
Invoke-Nanobot @("webui", "--yes")
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "WebUI did not start."
|
||||
}
|
||||
return
|
||||
}
|
||||
Write-Info "The installed release does not support nanobot webui yet."
|
||||
Write-Info "Falling back to the setup wizard..."
|
||||
}
|
||||
|
||||
Write-Info "Starting setup wizard..."
|
||||
Invoke-Nanobot @("onboard", "--wizard")
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
|
||||
+43
-4
@@ -36,7 +36,7 @@ Usage: install.sh [--dev] [--dry-run]
|
||||
|
||||
By default this installs or upgrades nanobot-ai from PyPI.
|
||||
Use --dev to install from the current main branch on GitHub.
|
||||
Use --dry-run to print what would happen without installing or starting the wizard.
|
||||
Use --dry-run to print what would happen without installing or starting setup.
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -104,6 +104,30 @@ nanobot_try_command() {
|
||||
esac
|
||||
}
|
||||
|
||||
is_fresh_nanobot_install() {
|
||||
[ -n "${HOME:-}" ] || return 1
|
||||
[ ! -e "$HOME/.nanobot/config.json" ]
|
||||
}
|
||||
|
||||
has_browser_session() {
|
||||
if [ -n "${SSH_CONNECTION:-}${SSH_TTY:-}" ]; then
|
||||
return 1
|
||||
fi
|
||||
if ! : 2>/dev/null < /dev/tty; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
command -v launchctl >/dev/null 2>&1 &&
|
||||
launchctl print "gui/$(id -u)" >/dev/null 2>&1
|
||||
;;
|
||||
*)
|
||||
[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
install_with_active_python() {
|
||||
info "Detected an active virtual environment. Installing into it..."
|
||||
ensure_pip "$python_bin" || return 1
|
||||
@@ -225,7 +249,10 @@ if [ "$dry_run" = "1" ]; then
|
||||
info "Dry run: would run nanobot as: $venv_dir/bin/python -m nanobot"
|
||||
fi
|
||||
if [ "${NANOBOT_SKIP_WIZARD:-}" = "1" ]; then
|
||||
info "Dry run: would skip setup wizard because NANOBOT_SKIP_WIZARD=1."
|
||||
info "Dry run: would skip automatic setup because NANOBOT_SKIP_WIZARD=1."
|
||||
elif is_fresh_nanobot_install && has_browser_session; then
|
||||
info "Dry run: would start the WebUI for this fresh desktop install."
|
||||
info "Dry run: would fall back to the setup wizard for older releases."
|
||||
else
|
||||
info "Dry run: would run the setup wizard."
|
||||
fi
|
||||
@@ -264,11 +291,23 @@ info "Installed nanobot:"
|
||||
run_nanobot --version
|
||||
|
||||
if [ "${NANOBOT_SKIP_WIZARD:-}" = "1" ]; then
|
||||
info "Skipping setup wizard because NANOBOT_SKIP_WIZARD=1."
|
||||
info "Run this later: $(nanobot_try_command) onboard --wizard"
|
||||
info "Skipping automatic setup because NANOBOT_SKIP_WIZARD=1."
|
||||
info "Run this later: $(nanobot_try_command) webui"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if is_fresh_nanobot_install && has_browser_session; then
|
||||
if run_nanobot webui --help >/dev/null 2>&1; then
|
||||
info "Starting nanobot WebUI..."
|
||||
info "Configure your first provider and model in Settings > Models."
|
||||
info "Run this later: $(nanobot_try_command) webui"
|
||||
run_nanobot webui --yes
|
||||
exit 0
|
||||
fi
|
||||
info "The installed release does not support nanobot webui yet."
|
||||
info "Falling back to the setup wizard..."
|
||||
fi
|
||||
|
||||
if [ -t 0 ]; then
|
||||
info "Starting setup wizard..."
|
||||
run_nanobot onboard --wizard
|
||||
|
||||
@@ -142,6 +142,9 @@ async def test_openrouter_image_generation_payload_and_response(tmp_path: Path)
|
||||
call = fake.calls[0]
|
||||
assert call["url"] == "https://openrouter.ai/api/v1/chat/completions"
|
||||
assert call["headers"]["Authorization"] == "Bearer sk-or-test"
|
||||
assert call["headers"]["HTTP-Referer"] == "https://nanobot.wiki"
|
||||
assert call["headers"]["X-OpenRouter-Title"] == "nanobot"
|
||||
assert call["headers"]["X-OpenRouter-Categories"] == "cli-agent,personal-agent"
|
||||
assert call["headers"]["X-Test"] == "1"
|
||||
body = call["json"]
|
||||
assert body["modalities"] == ["image", "text"]
|
||||
|
||||
@@ -485,7 +485,7 @@ async def test_openrouter_sets_default_attribution_headers() -> None:
|
||||
await provider._ensure_client()
|
||||
|
||||
headers = mock_client_cls.call_args.kwargs["default_headers"]
|
||||
assert headers["HTTP-Referer"] == "https://github.com/HKUDS/nanobot"
|
||||
assert headers["HTTP-Referer"] == "https://nanobot.wiki"
|
||||
assert headers["X-OpenRouter-Title"] == "nanobot"
|
||||
assert headers["X-OpenRouter-Categories"] == "cli-agent,personal-agent"
|
||||
assert "x-session-affinity" in headers
|
||||
|
||||
@@ -13,6 +13,7 @@ interface MarkdownTextProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
streaming?: boolean;
|
||||
preserveStreamingLayout?: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
@@ -74,11 +75,13 @@ export function MarkdownText({
|
||||
children,
|
||||
className,
|
||||
streaming = false,
|
||||
preserveStreamingLayout = false,
|
||||
onOpenFilePreview,
|
||||
}: MarkdownTextProps) {
|
||||
const renderedSource = children;
|
||||
const renderPhase = streaming ? "streaming" : "complete";
|
||||
const highlightCode = !streaming;
|
||||
const renderWithStreamingLayout = streaming || preserveStreamingLayout;
|
||||
|
||||
useEffect(() => {
|
||||
if (streaming) void preloadMarkdownText();
|
||||
@@ -103,7 +106,7 @@ export function MarkdownText({
|
||||
source={renderedSource}
|
||||
className={className}
|
||||
highlightCode={highlightCode}
|
||||
streaming={streaming}
|
||||
streaming={renderWithStreamingLayout}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -248,13 +248,6 @@ const rehypePlugins: NonNullable<StreamdownProps["rehypePlugins"]> = [rehypeKate
|
||||
|
||||
const DIRECT_LINKS = { enabled: false } as const;
|
||||
const SAFE_MARKDOWN_PROTOCOL = /^(https?|ircs?|mailto|xmpp)$/i;
|
||||
const STREAMING_ANIMATION = {
|
||||
animation: "fadeIn",
|
||||
duration: 180,
|
||||
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
sep: "word",
|
||||
stagger: 18,
|
||||
} as const;
|
||||
|
||||
/** Preserve react-markdown's URL policy when rendering through Streamdown. */
|
||||
const safeMarkdownUrl: NonNullable<StreamdownProps["urlTransform"]> = (url) => {
|
||||
@@ -727,9 +720,8 @@ export default function MarkdownTextRenderer({
|
||||
<Streamdown
|
||||
mode={streaming ? "streaming" : "static"}
|
||||
parseIncompleteMarkdown
|
||||
isAnimating={streaming}
|
||||
animated={streaming ? STREAMING_ANIMATION : false}
|
||||
caret={streaming ? "block" : undefined}
|
||||
isAnimating={false}
|
||||
animated={false}
|
||||
linkSafety={DIRECT_LINKS}
|
||||
urlTransform={safeMarkdownUrl}
|
||||
remarkPlugins={remarkPlugins}
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { formatTurnLatency } from "@/lib/format";
|
||||
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
|
||||
import { toMediaAttachment } from "@/lib/media";
|
||||
import { matchingSlashCommand } from "@/lib/slash-command";
|
||||
import { parseQuotedUserMessage } from "@/lib/user-message-quote";
|
||||
@@ -239,13 +239,19 @@ export function MessageBubble({
|
||||
const showCopyButton = showCopyAction && showAssistantActions;
|
||||
const showForkButton = showAssistantActions && !!onForkFromHere;
|
||||
const forkLabel = t("message.forkFromHere");
|
||||
const latencyMs = message.latencyMs;
|
||||
const showLatencyFooter =
|
||||
const completedAt = message.completedAt;
|
||||
const completedAtLabel =
|
||||
message.role === "assistant" && !message.isStreaming
|
||||
? formatMessageEndTime(completedAt)
|
||||
: "";
|
||||
const showCompletedAt =
|
||||
completedAtLabel.length > 0
|
||||
&& (!empty || hasReasoning || media.length > 0);
|
||||
const completedAtTitle = showCompletedAt ? fmtDateTime(completedAt) : "";
|
||||
const showAssistantFooterRow = showCopyButton || showForkButton || showCompletedAt;
|
||||
const showAssistantFooterSlot =
|
||||
message.role === "assistant"
|
||||
&& latencyMs != null
|
||||
&& !message.isStreaming
|
||||
&& (!empty || hasReasoning || media.length > 0);
|
||||
const showAssistantFooterRow = showCopyButton || showForkButton || showLatencyFooter;
|
||||
return (
|
||||
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
||||
{hasReasoning ? (
|
||||
@@ -266,52 +272,67 @@ export function MessageBubble({
|
||||
/>
|
||||
) : null}
|
||||
<div data-assistant-selectable={message.isStreaming ? undefined : "true"}>
|
||||
{/* A mode switch rebuilds Streamdown's subtree and moves the scroll anchor. */}
|
||||
<MarkdownText
|
||||
streaming={!!message.isStreaming}
|
||||
preserveStreamingLayout
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
>
|
||||
{message.content}
|
||||
</MarkdownText>
|
||||
</div>
|
||||
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
||||
{showAssistantFooterRow ? (
|
||||
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
||||
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
|
||||
{showCopyButton ? (
|
||||
<MessageCopyButton content={message.content} />
|
||||
) : null}
|
||||
{showForkButton ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onForkFromHere}
|
||||
aria-label={forkLabel}
|
||||
className={cn(
|
||||
"touch-target inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<ForkArrowIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showLatencyFooter ? (
|
||||
<span
|
||||
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
||||
title={t("message.turnLatencyTitle")}
|
||||
>
|
||||
{formatTurnLatency(latencyMs)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{showAssistantFooterSlot ? (
|
||||
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
||||
<div
|
||||
data-assistant-footer
|
||||
data-state={showAssistantFooterRow ? "visible" : "reserved"}
|
||||
aria-hidden={showAssistantFooterRow ? undefined : true}
|
||||
className={cn(
|
||||
"mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground",
|
||||
"transition-opacity duration-300 ease-out motion-reduce:transition-none",
|
||||
showAssistantFooterRow
|
||||
? "opacity-100"
|
||||
: "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
{showCopyButton ? (
|
||||
<MessageCopyButton content={message.content} />
|
||||
) : null}
|
||||
{showForkButton ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onForkFromHere}
|
||||
aria-label={forkLabel}
|
||||
className={cn(
|
||||
"touch-target inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<ForkArrowIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showCompletedAt ? (
|
||||
<time
|
||||
data-assistant-completed-at
|
||||
dateTime={new Date(completedAt!).toISOString()}
|
||||
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
||||
title={completedAtTitle}
|
||||
>
|
||||
{completedAtLabel}
|
||||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
import {
|
||||
findPromptElement,
|
||||
jumpToPrompt,
|
||||
type PromptAnchor,
|
||||
promptTop,
|
||||
userPromptAnchors,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
interface PromptRailProps {
|
||||
bottomOffset: number;
|
||||
messages: UIMessage[];
|
||||
onJumpToPrompt: (promptId: string) => void;
|
||||
scrollRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ const HOVER_MARKER_WIDTHS_PX = [28, 22, 16, 11];
|
||||
export function PromptRail({
|
||||
bottomOffset,
|
||||
messages,
|
||||
onJumpToPrompt,
|
||||
scrollRef,
|
||||
}: PromptRailProps) {
|
||||
const railRef = useRef<HTMLDivElement>(null);
|
||||
@@ -159,7 +160,7 @@ export function PromptRail({
|
||||
key={marker.ids.join("|")}
|
||||
type="button"
|
||||
aria-label={`Jump to prompt: ${marker.label}`}
|
||||
onClick={() => jumpToPrompt(scrollRef.current, marker.ids[marker.ids.length - 1])}
|
||||
onClick={() => onJumpToPrompt(marker.ids[marker.ids.length - 1])}
|
||||
onBlur={() => setFocusedMarkerIndex(null)}
|
||||
onFocus={() => setFocusedMarkerIndex(index)}
|
||||
onPointerEnter={() => setFocusedMarkerIndex(index)}
|
||||
|
||||
@@ -584,8 +584,6 @@ function RunElapsedStrip({
|
||||
const stripLabel = goalStateStripPreview(goalState, t);
|
||||
const showGoal = !!stripLabel?.trim();
|
||||
const active = showTimer || showGoal;
|
||||
const [renderStrip, setRenderStrip] = useState(active);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const [, setTick] = useState(0);
|
||||
const stripWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -602,20 +600,8 @@ function RunElapsedStrip({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
setRenderStrip(true);
|
||||
setLeaving(false);
|
||||
return;
|
||||
}
|
||||
setGoalPanelOpen(false);
|
||||
if (!renderStrip) return;
|
||||
setLeaving(true);
|
||||
const id = window.setTimeout(() => {
|
||||
setRenderStrip(false);
|
||||
setLeaving(false);
|
||||
}, 180);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [active, renderStrip]);
|
||||
if (!active) setGoalPanelOpen(false);
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
if (startedAt == null || !pageVisible) return;
|
||||
@@ -699,8 +685,6 @@ function RunElapsedStrip({
|
||||
};
|
||||
}, [goalPanelOpen]);
|
||||
|
||||
if (!renderStrip || !display) return null;
|
||||
|
||||
const elapsed =
|
||||
displayStartedAt != null ? Math.max(0, Math.floor(Date.now() / 1000 - displayStartedAt)) : 0;
|
||||
const m = Math.floor(elapsed / 60);
|
||||
@@ -716,8 +700,10 @@ function RunElapsedStrip({
|
||||
return (
|
||||
<div
|
||||
ref={stripWrapperRef}
|
||||
className="composer-status-strip relative z-30"
|
||||
data-state={leaving ? "exit" : "enter"}
|
||||
className="composer-status-drawer relative z-30"
|
||||
data-composer-status-drawer=""
|
||||
data-state={active ? "open" : "closed"}
|
||||
aria-hidden={active ? undefined : true}
|
||||
>
|
||||
{goalPanelOpen && canExpandGoal && markdownBody ? (
|
||||
<div
|
||||
@@ -764,50 +750,54 @@ function RunElapsedStrip({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="flex min-h-[36px] items-center gap-2 border-b border-black/[0.04] px-3 py-2 dark:border-white/[0.06]"
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{displayShowTimer ? (
|
||||
<RunPulseIcon />
|
||||
) : (
|
||||
<Target className="h-4 w-4 shrink-0 text-primary/75" aria-hidden />
|
||||
)}
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-[12px] font-medium text-foreground/75">
|
||||
{timerTitle ? <span className="shrink-0">{timerTitle}</span> : null}
|
||||
{timerTitle && displayShowGoal ? (
|
||||
<span className="shrink-0 text-muted-foreground/45" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
) : null}
|
||||
{displayShowGoal ? (
|
||||
<span className="truncate">
|
||||
{t("thread.composer.goalStateStrip", { label: displayStripLabel })}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{canExpandGoal ? (
|
||||
<button
|
||||
ref={expandToggleRef}
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"text-muted-foreground transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
aria-expanded={goalPanelOpen}
|
||||
aria-controls={goalPanelOpen ? "nanobot-goal-panel-root" : undefined}
|
||||
aria-label={t("thread.composer.goalStateExpandAria")}
|
||||
title={t("thread.composer.goalStateExpandAria")}
|
||||
onClick={() => setGoalPanelOpen((o) => !o)}
|
||||
<div className="composer-status-drawer-clip">
|
||||
{display ? (
|
||||
<div
|
||||
className="composer-status-drawer-content flex min-h-[36px] items-center gap-2 px-3 py-2"
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{goalPanelOpen ? (
|
||||
<ChevronDown className="h-4 w-4" aria-hidden />
|
||||
{displayShowTimer ? (
|
||||
<RunPulseIcon />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4" aria-hidden />
|
||||
<Target className="h-4 w-4 shrink-0 text-primary/75" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-[12px] font-medium text-foreground/75">
|
||||
{timerTitle ? <span className="shrink-0">{timerTitle}</span> : null}
|
||||
{timerTitle && displayShowGoal ? (
|
||||
<span className="shrink-0 text-muted-foreground/45" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
) : null}
|
||||
{displayShowGoal ? (
|
||||
<span className="truncate">
|
||||
{t("thread.composer.goalStateStrip", { label: displayStripLabel })}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{canExpandGoal ? (
|
||||
<button
|
||||
ref={expandToggleRef}
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"text-muted-foreground transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
aria-expanded={goalPanelOpen}
|
||||
aria-controls={goalPanelOpen ? "nanobot-goal-panel-root" : undefined}
|
||||
aria-label={t("thread.composer.goalStateExpandAria")}
|
||||
title={t("thread.composer.goalStateExpandAria")}
|
||||
onClick={() => setGoalPanelOpen((o) => !o)}
|
||||
>
|
||||
{goalPanelOpen ? (
|
||||
<ChevronDown className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,7 +92,13 @@ export function ThreadMessages({
|
||||
unit.type === "activity"
|
||||
&& next?.type === "message"
|
||||
&& next.message.role === "assistant";
|
||||
|
||||
const deferOffscreenRender =
|
||||
index < units.length - 1
|
||||
&& (
|
||||
unit.type === "activity"
|
||||
? !liveActivityClusterIndices.has(index)
|
||||
: unit.message.role === "assistant" && !unit.message.isStreaming
|
||||
);
|
||||
const userPromptId =
|
||||
unit.type === "message" && unit.message.role === "user"
|
||||
? unit.message.id
|
||||
@@ -110,6 +116,7 @@ export function ThreadMessages({
|
||||
marginTop={marginTop}
|
||||
userPromptId={userPromptId}
|
||||
hasBodyBelow={hasBodyBelow}
|
||||
deferOffscreenRender={deferOffscreenRender}
|
||||
isTurnStreaming={liveActivityClusterIndices.has(index)}
|
||||
forkIndex={forkIndex}
|
||||
showForkBoundary={index === forkBoundaryAfterUnitIndex}
|
||||
@@ -131,6 +138,7 @@ interface ThreadDisplayUnitProps {
|
||||
marginTop: string;
|
||||
userPromptId?: string;
|
||||
hasBodyBelow: boolean;
|
||||
deferOffscreenRender: boolean;
|
||||
isTurnStreaming: boolean;
|
||||
forkIndex?: number;
|
||||
showForkBoundary: boolean;
|
||||
@@ -147,6 +155,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
||||
marginTop,
|
||||
userPromptId,
|
||||
hasBodyBelow,
|
||||
deferOffscreenRender,
|
||||
isTurnStreaming,
|
||||
forkIndex,
|
||||
showForkBoundary,
|
||||
@@ -157,17 +166,20 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
||||
onOpenFilePreview,
|
||||
onForkFromMessage,
|
||||
}: ThreadDisplayUnitProps) {
|
||||
// Introducing content-visibility after a unit has painted can move the
|
||||
// browser's scroll anchor. Only units deferred on their first render may
|
||||
// remain deferred.
|
||||
const hasRenderedEagerlyRef = useRef(!deferOffscreenRender);
|
||||
if (!deferOffscreenRender) hasRenderedEagerlyRef.current = true;
|
||||
const stableDeferOffscreenRender =
|
||||
deferOffscreenRender && !hasRenderedEagerlyRef.current;
|
||||
const onForkFromHere = useCallback(() => {
|
||||
if (forkIndex !== undefined) onForkFromMessage?.(forkIndex);
|
||||
}, [forkIndex, onForkFromMessage]);
|
||||
const deferOffscreenRender = unit.type === "activity"
|
||||
? !isTurnStreaming
|
||||
: unit.message.role === "assistant" && !unit.message.isStreaming;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${marginTop}${deferOffscreenRender ? " thread-render-unit" : ""}`}
|
||||
className={`${marginTop}${stableDeferOffscreenRender ? " thread-render-unit" : ""}`}
|
||||
data-user-prompt-id={userPromptId}
|
||||
>
|
||||
{unit.type === "activity" ? (
|
||||
@@ -206,6 +218,7 @@ function threadDisplayUnitPropsEqual(
|
||||
&& previous.marginTop === next.marginTop
|
||||
&& previous.userPromptId === next.userPromptId
|
||||
&& previous.hasBodyBelow === next.hasBodyBelow
|
||||
&& previous.deferOffscreenRender === next.deferOffscreenRender
|
||||
&& previous.isTurnStreaming === next.isTurnStreaming
|
||||
&& previous.forkIndex === next.forkIndex
|
||||
&& previous.showForkBoundary === next.showForkBoundary
|
||||
|
||||
@@ -102,6 +102,18 @@ function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boo
|
||||
return snapshot.every((message, index) => sameMessageShape(current[index], message));
|
||||
}
|
||||
|
||||
function latestActiveTurnId(messages: UIMessage[]): string | null {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message.isStreaming && message.turnId) return message.turnId;
|
||||
}
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message.role === "user" && message.turnId) return message.turnId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const FILE_PREVIEW_DEFAULT_WIDTH = 544;
|
||||
const FILE_PREVIEW_MIN_WIDTH = 360;
|
||||
const FILE_PREVIEW_MAX_WIDTH = 860;
|
||||
@@ -444,8 +456,7 @@ export function ThreadShell({
|
||||
});
|
||||
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
|
||||
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
|
||||
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
||||
const [scrollToLatestUserPromptSignal, setScrollToLatestUserPromptSignal] = useState(0);
|
||||
const [submittedViewportTurnId, setSubmittedViewportTurnId] = useState<string | null>(null);
|
||||
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
|
||||
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
|
||||
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
|
||||
@@ -457,6 +468,7 @@ export function ThreadShell({
|
||||
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
||||
const [pendingFirstTargetChatId, setPendingFirstTargetChatId] = useState<string | null>(null);
|
||||
const viewportRef = useRef<ThreadViewportHandle | null>(null);
|
||||
const activeViewportTurnByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
||||
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
|
||||
const prevChatIdForCacheRef = useRef<string | null>(null);
|
||||
@@ -465,18 +477,20 @@ export function ThreadShell({
|
||||
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
||||
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
|
||||
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||
const bottomScrolledChatIdRef = useRef<string | null>(null);
|
||||
|
||||
const initial = useMemo(() => {
|
||||
if (!chatId) return historical;
|
||||
return messageCacheRef.current.get(chatId) ?? historical;
|
||||
}, [chatId, historical]);
|
||||
const handleTurnEnd = useCallback(() => {
|
||||
if (chatId) activeViewportTurnByChatIdRef.current.delete(chatId);
|
||||
setSubmittedViewportTurnId(null);
|
||||
setFallbackModelName(null);
|
||||
onTurnEnd?.();
|
||||
}, [onTurnEnd]);
|
||||
}, [chatId, onTurnEnd]);
|
||||
const {
|
||||
messages,
|
||||
messagesReady,
|
||||
isStreaming,
|
||||
runStartedAt,
|
||||
goalState,
|
||||
@@ -504,6 +518,7 @@ export function ThreadShell({
|
||||
setFilePreviewClosing(false);
|
||||
setFilePreviewPath(null);
|
||||
setQuotedContext(null);
|
||||
setSubmittedViewportTurnId(null);
|
||||
}, [historyKey]);
|
||||
|
||||
const handleQuoteSelection = useCallback((text: string) => {
|
||||
@@ -520,6 +535,28 @@ export function ThreadShell({
|
||||
}, []);
|
||||
|
||||
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
||||
const currentRunStartedAt = messagesReady ? runStartedAt : null;
|
||||
const currentGoalState = messagesReady ? goalState : undefined;
|
||||
const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
|
||||
const restoredViewportTurnId = useMemo(
|
||||
() => turnActive ? latestActiveTurnId(displayMessages) : null,
|
||||
[displayMessages, turnActive],
|
||||
);
|
||||
const rememberedViewportTurnId = chatId
|
||||
? activeViewportTurnByChatIdRef.current.get(chatId) ?? null
|
||||
: null;
|
||||
const viewportTurnId = messagesReady && turnActive
|
||||
? rememberedViewportTurnId ?? restoredViewportTurnId
|
||||
: null;
|
||||
const activeTurnStartedHere =
|
||||
viewportTurnId !== null && viewportTurnId === submittedViewportTurnId;
|
||||
useEffect(() => {
|
||||
if (!chatId || !messagesReady || turnActive) return;
|
||||
activeViewportTurnByChatIdRef.current.delete(chatId);
|
||||
setSubmittedViewportTurnId((current) =>
|
||||
current === rememberedViewportTurnId ? null : current,
|
||||
);
|
||||
}, [chatId, messagesReady, rememberedViewportTurnId, turnActive]);
|
||||
const filePreviewAvailabilityCache = useMemo(
|
||||
() => new Map<string, FilePreviewAvailabilityCacheEntry>(),
|
||||
[historyKey, token],
|
||||
@@ -695,22 +732,14 @@ export function ThreadShell({
|
||||
return client.onSessionUpdate((updatedChatId, scope) => {
|
||||
if (updatedChatId !== chatId) return;
|
||||
if (scope === "metadata") return;
|
||||
viewportRef.current?.cancelAutoScroll();
|
||||
// A turn-end thread refresh can arrive while the viewport is easing the
|
||||
// final layout change. User-driven scrolling already disables following,
|
||||
// so keep an active programmatic follow alive across canonical hydration.
|
||||
pendingCanonicalHydrateRef.current.add(chatId);
|
||||
refreshHistory();
|
||||
});
|
||||
}, [chatId, client, refreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) {
|
||||
bottomScrolledChatIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (loading || bottomScrolledChatIdRef.current === chatId) return;
|
||||
bottomScrolledChatIdRef.current = chatId;
|
||||
setScrollToBottomSignal((value) => value + 1);
|
||||
}, [chatId, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatId) return;
|
||||
setMessages(projectWebuiThreadMessages(historical));
|
||||
@@ -765,8 +794,11 @@ export function ThreadShell({
|
||||
}
|
||||
pendingFirstRef.current = null;
|
||||
setPendingFirstTargetChatId(null);
|
||||
setScrollToLatestUserPromptSignal((value) => value + 1);
|
||||
send(pending.content, pending.images, pending.options);
|
||||
const submitted = send(pending.content, pending.images, pending.options);
|
||||
if (submitted && !submitted.sideChannel) {
|
||||
activeViewportTurnByChatIdRef.current.set(chatId, submitted.turnId);
|
||||
setSubmittedViewportTurnId(submitted.turnId);
|
||||
}
|
||||
setBooting(false);
|
||||
}, [chatId, pendingFirstTargetChatId, send]);
|
||||
|
||||
@@ -809,10 +841,13 @@ export function ThreadShell({
|
||||
const handleThreadSend = useCallback(
|
||||
(content: string, images?: SendAttachment[], options?: SendOptions) => {
|
||||
setFallbackModelName(null);
|
||||
setScrollToLatestUserPromptSignal((value) => value + 1);
|
||||
send(content, images, withWorkspaceScope(options));
|
||||
const submitted = send(content, images, withWorkspaceScope(options));
|
||||
if (chatId && submitted && !submitted.sideChannel) {
|
||||
activeViewportTurnByChatIdRef.current.set(chatId, submitted.turnId);
|
||||
setSubmittedViewportTurnId(submitted.turnId);
|
||||
}
|
||||
},
|
||||
[send, withWorkspaceScope],
|
||||
[chatId, send, withWorkspaceScope],
|
||||
);
|
||||
|
||||
const handleOpenFilePreview = useCallback((path: string) => {
|
||||
@@ -927,7 +962,7 @@ export function ThreadShell({
|
||||
<ThreadComposer
|
||||
onSend={handleThreadSend}
|
||||
disabled={!chatId}
|
||||
isStreaming={isStreaming}
|
||||
isStreaming={turnActive}
|
||||
placeholder={
|
||||
showHeroComposer
|
||||
? t("thread.composer.placeholderHero")
|
||||
@@ -950,8 +985,8 @@ export function ThreadShell({
|
||||
skills={skills}
|
||||
onStop={stop}
|
||||
onTranscribeAudio={transcribeAudio}
|
||||
runStartedAt={runStartedAt}
|
||||
goalState={goalState}
|
||||
runStartedAt={currentRunStartedAt}
|
||||
goalState={currentGoalState}
|
||||
workspaceScope={workspaceScope}
|
||||
workspaceDefaultScope={workspaceDefaultScope}
|
||||
workspaceControls={workspaceControls}
|
||||
@@ -969,7 +1004,7 @@ export function ThreadShell({
|
||||
<ThreadComposer
|
||||
onSend={handleWelcomeSend}
|
||||
disabled={booting}
|
||||
isStreaming={isStreaming}
|
||||
isStreaming={turnActive}
|
||||
placeholder={
|
||||
booting
|
||||
? t("thread.composer.placeholderOpening")
|
||||
@@ -990,9 +1025,9 @@ export function ThreadShell({
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
skills={skills}
|
||||
runStartedAt={runStartedAt}
|
||||
runStartedAt={currentRunStartedAt}
|
||||
onTranscribeAudio={transcribeAudio}
|
||||
goalState={goalState}
|
||||
goalState={currentGoalState}
|
||||
workspaceScope={workspaceScope}
|
||||
workspaceDefaultScope={workspaceDefaultScope}
|
||||
workspaceControls={workspaceControls}
|
||||
@@ -1048,12 +1083,13 @@ export function ThreadShell({
|
||||
<ThreadViewport
|
||||
ref={viewportRef}
|
||||
messages={displayMessages}
|
||||
isStreaming={isStreaming}
|
||||
isStreaming={turnActive}
|
||||
emptyState={emptyState}
|
||||
composer={composer}
|
||||
scrollToBottomSignal={scrollToBottomSignal}
|
||||
scrollToLatestUserPromptSignal={scrollToLatestUserPromptSignal}
|
||||
activeTurnId={viewportTurnId}
|
||||
activeTurnStartedHere={activeTurnStartedHere}
|
||||
conversationKey={historyKey}
|
||||
conversationReady={messagesReady}
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
|
||||
@@ -15,10 +15,14 @@ import { useTranslation } from "react-i18next";
|
||||
import { PromptRail } from "@/components/thread/PromptRail";
|
||||
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
|
||||
import { ThreadCameraController } from "@/components/thread/thread-camera";
|
||||
import {
|
||||
ThreadMotionCoordinator,
|
||||
type ThreadMotionGeometry,
|
||||
} from "@/components/thread/thread-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
findPromptElement,
|
||||
jumpToPrompt,
|
||||
promptTop,
|
||||
} from "@/components/thread/promptNavigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -35,8 +39,10 @@ interface ThreadViewportProps {
|
||||
composer: ReactNode;
|
||||
emptyState?: ReactNode;
|
||||
scrollToBottomSignal?: number;
|
||||
scrollToLatestUserPromptSignal?: number;
|
||||
activeTurnId?: string | null;
|
||||
activeTurnStartedHere?: boolean;
|
||||
conversationKey?: string | null;
|
||||
conversationReady?: boolean;
|
||||
showScrollToBottomButton?: boolean;
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
@@ -56,7 +62,6 @@ const NEAR_TOP_PX = 96;
|
||||
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
||||
const KEYBOARD_SCROLL_FRAMES = 18;
|
||||
export const INITIAL_HISTORY_WINDOW = 160;
|
||||
export const HISTORY_WINDOW_INCREMENT = 120;
|
||||
|
||||
@@ -92,6 +97,49 @@ function isKeyboardEditableElement(element: Element | null): element is HTMLElem
|
||||
].includes(element.type);
|
||||
}
|
||||
|
||||
type ThreadScrollDirection = "backward" | "forward";
|
||||
|
||||
const KEYBOARD_SCROLL_DIRECTIONS: Readonly<
|
||||
Partial<Record<string, ThreadScrollDirection>>
|
||||
> = {
|
||||
ArrowUp: "backward",
|
||||
PageUp: "backward",
|
||||
Home: "backward",
|
||||
ArrowDown: "forward",
|
||||
PageDown: "forward",
|
||||
End: "forward",
|
||||
};
|
||||
|
||||
function directionFromDelta(deltaY: number): ThreadScrollDirection | null {
|
||||
return deltaY < 0 ? "backward" : deltaY > 0 ? "forward" : null;
|
||||
}
|
||||
|
||||
function keyboardScrollDirection(
|
||||
event: KeyboardEvent,
|
||||
): ThreadScrollDirection | null {
|
||||
if (event.key === " ") {
|
||||
return event.shiftKey ? "backward" : "forward";
|
||||
}
|
||||
return KEYBOARD_SCROLL_DIRECTIONS[event.key] ?? null;
|
||||
}
|
||||
|
||||
function canScrollInDirection(
|
||||
element: HTMLElement,
|
||||
direction: ThreadScrollDirection | null,
|
||||
): boolean {
|
||||
switch (direction) {
|
||||
case "backward":
|
||||
return element.scrollTop > 0;
|
||||
case "forward":
|
||||
return (
|
||||
element.scrollTop
|
||||
< Math.max(0, element.scrollHeight - element.clientHeight)
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readSoftKeyboardInsetBottom(container: HTMLElement | null): number {
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) return 0;
|
||||
@@ -108,8 +156,10 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
composer,
|
||||
emptyState,
|
||||
scrollToBottomSignal = 0,
|
||||
scrollToLatestUserPromptSignal = 0,
|
||||
activeTurnId = null,
|
||||
activeTurnStartedHere = false,
|
||||
conversationKey = null,
|
||||
conversationReady = true,
|
||||
showScrollToBottomButton = true,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
@@ -126,25 +176,65 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const messageRegionRef = useRef<HTMLDivElement>(null);
|
||||
const messageContentRef = useRef<HTMLDivElement>(null);
|
||||
const composerDockRef = useRef<HTMLDivElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
||||
const pendingConversationScrollRef = useRef(true);
|
||||
const pendingPromptJumpRef = useRef<string | null>(null);
|
||||
const scrollFrameIdsRef = useRef<number[]>([]);
|
||||
const programmaticPromptScrollTopRef = useRef<number | null>(null);
|
||||
const handledLatestPromptSignalRef = useRef(0);
|
||||
const activeTurnPromptRef = useRef<string | null>(null);
|
||||
const restoreScrollAfterPrependRef =
|
||||
useRef<{ height: number; top: number } | null>(null);
|
||||
/** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */
|
||||
const userReadingHistoryRef = useRef(false);
|
||||
const composerDockHeightRef = useRef(0);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
const [composerDockHeight, setComposerDockHeight] = useState(0);
|
||||
const [keyboardInsetBottom, setKeyboardInsetBottom] = useState(0);
|
||||
const [hasVerticalOverflow, setHasVerticalOverflow] = useState(false);
|
||||
const [visibleMessageCount, setVisibleMessageCount] =
|
||||
useState(INITIAL_HISTORY_WINDOW);
|
||||
const threadMotionRef = useRef<ThreadMotionCoordinator | null>(null);
|
||||
if (threadMotionRef.current === null) {
|
||||
const camera = new ThreadCameraController(() => scrollRef.current);
|
||||
threadMotionRef.current = new ThreadMotionCoordinator({
|
||||
camera,
|
||||
measure: (promptId) => {
|
||||
const scrollEl = scrollRef.current;
|
||||
const composerDock = composerDockRef.current;
|
||||
if (!scrollEl) return null;
|
||||
const composerHeight = composerDock
|
||||
? composerDock.getBoundingClientRect().height || composerDock.offsetHeight
|
||||
: 0;
|
||||
const prompt = promptId ? findPromptElement(scrollEl, promptId) : null;
|
||||
const scrollHeight = scrollEl.scrollHeight;
|
||||
const clientHeight = scrollEl.clientHeight;
|
||||
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
|
||||
return {
|
||||
scrollTop: scrollEl.scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
maxScrollTop,
|
||||
composerHeight,
|
||||
promptTop: prompt
|
||||
? Math.min(
|
||||
maxScrollTop,
|
||||
Math.max(0, promptTop(scrollEl, prompt) - 16),
|
||||
)
|
||||
: null,
|
||||
};
|
||||
},
|
||||
onGeometry: (geometry: ThreadMotionGeometry) => {
|
||||
if (Math.abs(composerDockHeightRef.current - geometry.composerHeight) >= 1) {
|
||||
composerDockHeightRef.current = geometry.composerHeight;
|
||||
setComposerDockHeight(geometry.composerHeight);
|
||||
}
|
||||
const nextOverflow = geometry.scrollHeight > geometry.clientHeight + 1;
|
||||
setHasVerticalOverflow((current) =>
|
||||
current === nextOverflow ? current : nextOverflow,
|
||||
);
|
||||
},
|
||||
onAutoFollow: () => setAtBottom(true),
|
||||
});
|
||||
}
|
||||
const hasMessages = messages.length > 0;
|
||||
const visibleMessages = useMemo(
|
||||
() => windowMessages(messages, visibleMessageCount),
|
||||
@@ -168,15 +258,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const scrollViewportStyle =
|
||||
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
|
||||
|
||||
const cancelScheduledBottomScroll = useCallback(() => {
|
||||
for (const id of scrollFrameIdsRef.current) {
|
||||
window.cancelAnimationFrame(id);
|
||||
}
|
||||
scrollFrameIdsRef.current = [];
|
||||
}, []);
|
||||
|
||||
const markProgrammaticPromptScroll = useCallback((top: number) => {
|
||||
programmaticPromptScrollTopRef.current = top;
|
||||
const yieldCameraToUser = useCallback(() => {
|
||||
threadMotionRef.current?.takeUserControl();
|
||||
}, []);
|
||||
|
||||
const scrollToBottomNow = useCallback((smooth = false) => {
|
||||
@@ -185,68 +268,25 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const behavior: ScrollBehavior = smooth ? "smooth" : "auto";
|
||||
if (el) {
|
||||
const top = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||
try {
|
||||
el.scrollTo?.({ top, behavior });
|
||||
if (!smooth) el.scrollTop = top;
|
||||
} catch {
|
||||
try {
|
||||
el.scrollTop = top;
|
||||
} catch {
|
||||
// Test DOMs can expose read-only scrollTop; browsers keep this writable.
|
||||
}
|
||||
if (smooth) {
|
||||
threadMotionRef.current?.animateTo(top);
|
||||
} else {
|
||||
threadMotionRef.current?.jumpTo(top);
|
||||
}
|
||||
} else if (marker) {
|
||||
marker.scrollIntoView({ block: "end", behavior });
|
||||
}
|
||||
userReadingHistoryRef.current = false;
|
||||
setAtBottom(true);
|
||||
}, []);
|
||||
|
||||
const scrollToPromptTopNow = useCallback((promptId: string) => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return false;
|
||||
const target = findPromptElement(el, promptId);
|
||||
if (!target) return false;
|
||||
const top = Math.max(0, promptTop(el, target) - 16);
|
||||
markProgrammaticPromptScroll(top);
|
||||
try {
|
||||
el.scrollTo?.({ top, behavior: "auto" });
|
||||
el.scrollTop = top;
|
||||
} catch {
|
||||
try {
|
||||
el.scrollTop = top;
|
||||
} catch {
|
||||
// Test DOMs can expose read-only scrollTop; browsers keep this writable.
|
||||
}
|
||||
}
|
||||
const near = el.scrollHeight - top - el.clientHeight < NEAR_BOTTOM_PX;
|
||||
userReadingHistoryRef.current = false;
|
||||
setAtBottom(near);
|
||||
return true;
|
||||
}, [markProgrammaticPromptScroll]);
|
||||
|
||||
const scrollToBottom = useCallback(
|
||||
(smooth = false, frames = 1, options?: { force?: boolean }) => {
|
||||
(smooth = false, options?: { force?: boolean }) => {
|
||||
const force = options?.force ?? false;
|
||||
cancelScheduledBottomScroll();
|
||||
const run = () => {
|
||||
if (!force && userReadingHistoryRef.current) return;
|
||||
scrollToBottomNow(smooth);
|
||||
};
|
||||
const scheduleNext = (remainingFrames: number) => {
|
||||
if (remainingFrames <= 0) return;
|
||||
const id = window.requestAnimationFrame(() => {
|
||||
scrollFrameIdsRef.current = scrollFrameIdsRef.current.filter((frameId) => frameId !== id);
|
||||
if (!force && userReadingHistoryRef.current) return;
|
||||
scrollToBottomNow(smooth);
|
||||
scheduleNext(remainingFrames - 1);
|
||||
});
|
||||
scrollFrameIdsRef.current.push(id);
|
||||
};
|
||||
run();
|
||||
scheduleNext(frames - 1);
|
||||
if (!force && threadMotionRef.current?.isAutoFollowPaused()) return;
|
||||
threadMotionRef.current?.resumeAutoFollow();
|
||||
scrollToBottomNow(smooth);
|
||||
},
|
||||
[cancelScheduledBottomScroll, scrollToBottomNow],
|
||||
[scrollToBottomNow],
|
||||
);
|
||||
|
||||
const loadEarlierMessages = useCallback(() => {
|
||||
@@ -257,8 +297,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
top: el.scrollTop,
|
||||
};
|
||||
}
|
||||
userReadingHistoryRef.current = true;
|
||||
activeTurnPromptRef.current = null;
|
||||
threadMotionRef.current?.takeUserControl();
|
||||
setAtBottom(false);
|
||||
if (hiddenMessageCount > 0) {
|
||||
setVisibleMessageCount((count) =>
|
||||
@@ -275,58 +314,68 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const maybeLoadEarlierFromScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !hasMessages || pendingConversationScrollRef.current) return;
|
||||
if (!userReadingHistoryRef.current) return;
|
||||
if (!threadMotionRef.current?.isBrowsingHistory()) return;
|
||||
if (el.scrollTop > NEAR_TOP_PX) return;
|
||||
if (hiddenMessageCount <= 0 && !hasMoreBefore) return;
|
||||
loadEarlierMessages();
|
||||
}, [hasMessages, hasMoreBefore, hiddenMessageCount, loadEarlierMessages]);
|
||||
|
||||
const jumpToUserPrompt = useCallback((promptId: string) => {
|
||||
const navigateToVisiblePrompt = useCallback((promptId: string) => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl && findPromptElement(scrollEl, promptId)) {
|
||||
jumpToPrompt(scrollEl, promptId);
|
||||
return;
|
||||
}
|
||||
const prompt = scrollEl ? findPromptElement(scrollEl, promptId) : null;
|
||||
if (!scrollEl || !prompt) return false;
|
||||
setAtBottom(false);
|
||||
const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
|
||||
threadMotionRef.current?.navigateHistoryTo(
|
||||
Math.min(
|
||||
maxScrollTop,
|
||||
Math.max(0, promptTop(scrollEl, prompt) - 16),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const jumpToUserPrompt = useCallback((promptId: string) => {
|
||||
if (navigateToVisiblePrompt(promptId)) return;
|
||||
const index = messages.findIndex((message) => message.id === promptId);
|
||||
if (index < 0) return;
|
||||
threadMotionRef.current?.takeUserControl();
|
||||
pendingPromptJumpRef.current = promptId;
|
||||
userReadingHistoryRef.current = true;
|
||||
activeTurnPromptRef.current = null;
|
||||
setAtBottom(false);
|
||||
setVisibleMessageCount((count) => Math.max(count, messages.length - index));
|
||||
}, [messages]);
|
||||
}, [messages, navigateToVisiblePrompt]);
|
||||
|
||||
const cancelAutoScroll = useCallback(() => {
|
||||
threadMotionRef.current?.takeUserControl();
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
jumpToUserPrompt,
|
||||
cancelAutoScroll: cancelScheduledBottomScroll,
|
||||
cancelAutoScroll,
|
||||
}),
|
||||
[cancelScheduledBottomScroll, jumpToUserPrompt],
|
||||
[cancelAutoScroll, jumpToUserPrompt],
|
||||
);
|
||||
|
||||
const measureComposerDock = useCallback(() => {
|
||||
const el = composerDockRef.current;
|
||||
if (!el) return;
|
||||
const height = el.getBoundingClientRect().height || el.offsetHeight;
|
||||
setComposerDockHeight((current) =>
|
||||
Math.abs(current - height) < 1 ? current : height,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const updateKeyboardInset = () => {
|
||||
const scrollEl = scrollRef.current;
|
||||
const next = readSoftKeyboardInsetBottom(scrollEl);
|
||||
const active = document.activeElement;
|
||||
const composerFocused =
|
||||
hasMessages && isKeyboardEditableElement(active) && Boolean(scrollEl?.contains(active));
|
||||
hasMessages
|
||||
&& isKeyboardEditableElement(active)
|
||||
&& Boolean(scrollEl?.contains(active));
|
||||
setKeyboardInsetBottom((current) =>
|
||||
Math.abs(current - next) < 1 ? current : next,
|
||||
);
|
||||
if (composerFocused) {
|
||||
userReadingHistoryRef.current = false;
|
||||
scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true });
|
||||
// Focusing the composer establishes a new reference frame at the
|
||||
// latest message. This is one immediate positioning command; viewport
|
||||
// events may issue a fresh command, but no command survives into a
|
||||
// later render as a train of retry frames.
|
||||
scrollToBottom(false, { force: true });
|
||||
}
|
||||
};
|
||||
updateKeyboardInset();
|
||||
@@ -345,76 +394,62 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
};
|
||||
}, [hasMessages, scrollToBottom]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (keyboardInsetBottom > 0) {
|
||||
userReadingHistoryRef.current = false;
|
||||
scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true });
|
||||
return;
|
||||
}
|
||||
if (userReadingHistoryRef.current) return;
|
||||
scrollToBottom(false, 4);
|
||||
}, [keyboardInsetBottom, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) return;
|
||||
|
||||
const onComposerFocus = () => {
|
||||
const active = document.activeElement;
|
||||
if (!hasMessages || !isKeyboardEditableElement(active) || !scrollEl.contains(active)) return;
|
||||
userReadingHistoryRef.current = false;
|
||||
scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true });
|
||||
};
|
||||
|
||||
document.addEventListener("focusin", onComposerFocus);
|
||||
return () => document.removeEventListener("focusin", onComposerFocus);
|
||||
}, [hasMessages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollToBottomSignal <= 0) return;
|
||||
userReadingHistoryRef.current = false;
|
||||
scrollToBottom(false, 8);
|
||||
scrollToBottom(false, { force: true });
|
||||
}, [scrollToBottomSignal, scrollToBottom]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (scrollToLatestUserPromptSignal <= handledLatestPromptSignalRef.current) return;
|
||||
const latest = messages[messages.length - 1];
|
||||
if (!latest || latest.role !== "user") return;
|
||||
handledLatestPromptSignalRef.current = scrollToLatestUserPromptSignal;
|
||||
cancelScheduledBottomScroll();
|
||||
activeTurnPromptRef.current = latest.id;
|
||||
if (!scrollToPromptTopNow(latest.id)) activeTurnPromptRef.current = null;
|
||||
}, [
|
||||
cancelScheduledBottomScroll,
|
||||
messages,
|
||||
scrollToLatestUserPromptSignal,
|
||||
scrollToPromptTopNow,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (lastConversationKeyRef.current === conversationKey) return;
|
||||
lastConversationKeyRef.current = conversationKey;
|
||||
pendingConversationScrollRef.current = true;
|
||||
userReadingHistoryRef.current = false;
|
||||
activeTurnPromptRef.current = null;
|
||||
threadMotionRef.current?.reset();
|
||||
setAtBottom(true);
|
||||
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
||||
}, [conversationKey]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const promptId = activeTurnPromptRef.current;
|
||||
if (!promptId || userReadingHistoryRef.current) return;
|
||||
const promptIndex = messages.findIndex((message) => message.id === promptId);
|
||||
if (promptIndex < 0) {
|
||||
activeTurnPromptRef.current = null;
|
||||
if (!conversationReady) {
|
||||
threadMotionRef.current?.reset();
|
||||
return;
|
||||
}
|
||||
const hasAgentOutput = messages
|
||||
.slice(promptIndex + 1)
|
||||
.some((message) => message.role !== "user");
|
||||
if (!hasAgentOutput) return;
|
||||
scrollToBottom(false, isStreaming ? 3 : 1);
|
||||
}, [isStreaming, messages, scrollToBottom]);
|
||||
if (!activeTurnId) {
|
||||
threadMotionRef.current?.completeTurn();
|
||||
return;
|
||||
}
|
||||
|
||||
const promptIndex = messages.findIndex(
|
||||
(message) => message.role === "user" && message.turnId === activeTurnId,
|
||||
);
|
||||
if (
|
||||
activeTurnStartedHere
|
||||
&& promptIndex >= 0
|
||||
&& threadMotionRef.current?.snapshot().turnId !== activeTurnId
|
||||
) {
|
||||
// A turn submitted in this mounted viewport establishes a new prompt
|
||||
// origin. A restored turn must first complete open-at-bottom instead.
|
||||
pendingConversationScrollRef.current = false;
|
||||
}
|
||||
const prompt = promptIndex >= 0 ? messages[promptIndex] : null;
|
||||
const hasOutput = promptIndex >= 0
|
||||
? messages
|
||||
.slice(promptIndex + 1)
|
||||
.some(
|
||||
(message) =>
|
||||
message.role !== "user"
|
||||
&& (!message.turnId || message.turnId === activeTurnId),
|
||||
)
|
||||
: !activeTurnStartedHere && messages.some(
|
||||
(message) =>
|
||||
message.role !== "user" && message.turnId === activeTurnId,
|
||||
);
|
||||
threadMotionRef.current?.updateTurn({
|
||||
id: activeTurnId,
|
||||
promptId: prompt?.id ?? null,
|
||||
hasOutput,
|
||||
entry: activeTurnStartedHere ? "submitted" : "restored",
|
||||
});
|
||||
}, [activeTurnId, activeTurnStartedHere, conversationReady, messages]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const pending = restoreScrollAfterPrependRef.current;
|
||||
@@ -423,16 +458,11 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
restoreScrollAfterPrependRef.current = null;
|
||||
if (!el) return;
|
||||
const delta = el.scrollHeight - pending.height;
|
||||
const nextTop = pending.top + delta;
|
||||
try {
|
||||
el.scrollTop = nextTop;
|
||||
} catch {
|
||||
try {
|
||||
el.scrollTo?.({ top: nextTop, behavior: "auto" });
|
||||
} catch {
|
||||
// Test DOMs can expose read-only scrollTop; browsers keep this writable.
|
||||
}
|
||||
}
|
||||
const nextTop = Math.min(
|
||||
Math.max(0, el.scrollHeight - el.clientHeight),
|
||||
Math.max(0, pending.top + delta),
|
||||
);
|
||||
threadMotionRef.current?.jumpTo(nextTop);
|
||||
}, [visibleMessages.length, messages.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -440,65 +470,61 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!promptId || !scrollEl || !findPromptElement(scrollEl, promptId)) return;
|
||||
pendingPromptJumpRef.current = null;
|
||||
const frame = window.requestAnimationFrame(() => jumpToPrompt(scrollEl, promptId));
|
||||
const frame = window.requestAnimationFrame(() => navigateToVisiblePrompt(promptId));
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [visibleMessages.length]);
|
||||
}, [navigateToVisiblePrompt, visibleMessages.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!pendingConversationScrollRef.current) return;
|
||||
if (!conversationReady) return;
|
||||
if (!conversationKey) {
|
||||
pendingConversationScrollRef.current = false;
|
||||
scrollToBottom(false, 4);
|
||||
scrollToBottom(false, { force: true });
|
||||
return;
|
||||
}
|
||||
scrollToBottom(false, 8);
|
||||
scrollToBottom(false, { force: true });
|
||||
if (!hasMessages) return;
|
||||
pendingConversationScrollRef.current = false;
|
||||
}, [conversationKey, hasMessages, messages, scrollToBottom]);
|
||||
}, [
|
||||
conversationKey,
|
||||
conversationReady,
|
||||
hasMessages,
|
||||
messages,
|
||||
scrollToBottom,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measureComposerDock();
|
||||
}, [composer, hasMessages, measureComposerDock]);
|
||||
threadMotionRef.current?.invalidateGeometry();
|
||||
}, [composer, hasMessages, visibleMessages.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hasMessages || userReadingHistoryRef.current) return;
|
||||
const promptId = activeTurnPromptRef.current;
|
||||
if (promptId && scrollToPromptTopNow(promptId)) return;
|
||||
scrollToBottom(false, 2);
|
||||
}, [composerDockHeight, hasMessages, scrollToBottom, scrollToPromptTopNow]);
|
||||
|
||||
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = composerDockRef.current;
|
||||
if (!target || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => measureComposerDock());
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMessages, measureComposerDock]);
|
||||
|
||||
const measureVerticalOverflow = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const next = el.scrollHeight > el.clientHeight + 1;
|
||||
setHasVerticalOverflow((current) => (current === next ? current : next));
|
||||
}, []);
|
||||
useEffect(() => () => threadMotionRef.current?.dispose(), []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
const content = contentRef.current;
|
||||
const messageRegion = messageRegionRef.current;
|
||||
const messageContent = messageContentRef.current;
|
||||
const composerDock = composerDockRef.current;
|
||||
if (!el) return;
|
||||
|
||||
measureVerticalOverflow();
|
||||
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measureVerticalOverflow);
|
||||
const invalidateGeometry = () => {
|
||||
threadMotionRef.current?.invalidateGeometry();
|
||||
};
|
||||
invalidateGeometry();
|
||||
const observer = typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(invalidateGeometry);
|
||||
observer?.observe(el);
|
||||
if (content) observer?.observe(content);
|
||||
window.addEventListener("resize", measureVerticalOverflow);
|
||||
if (messageRegion) observer?.observe(messageRegion);
|
||||
if (messageContent) observer?.observe(messageContent);
|
||||
if (composerDock) observer?.observe(composerDock);
|
||||
window.addEventListener("resize", invalidateGeometry);
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", measureVerticalOverflow);
|
||||
window.removeEventListener("resize", invalidateGeometry);
|
||||
};
|
||||
}, [composerDockHeight, hasMessages, measureVerticalOverflow, visibleMessages.length]);
|
||||
}, [hasMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -507,48 +533,114 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const onScroll = (allowHistoryLoad = true) => {
|
||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
const near = distance < NEAR_BOTTOM_PX;
|
||||
const programmaticPromptTop = programmaticPromptScrollTopRef.current;
|
||||
const programmatic =
|
||||
programmaticPromptTop !== null && Math.abs(el.scrollTop - programmaticPromptTop) < 2;
|
||||
setAtBottom((current) => current === near ? current : near);
|
||||
if (programmatic) {
|
||||
programmaticPromptScrollTopRef.current = null;
|
||||
if (near) userReadingHistoryRef.current = false;
|
||||
return;
|
||||
}
|
||||
programmaticPromptScrollTopRef.current = null;
|
||||
userReadingHistoryRef.current = !near;
|
||||
if (!near) activeTurnPromptRef.current = null;
|
||||
if (allowHistoryLoad && !near) maybeLoadEarlierFromScroll();
|
||||
const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic";
|
||||
const logicallyAtBottom = owner === "automatic" || near;
|
||||
setAtBottom((current) =>
|
||||
current === logicallyAtBottom ? current : logicallyAtBottom,
|
||||
);
|
||||
if (allowHistoryLoad && owner === "user") maybeLoadEarlierFromScroll();
|
||||
};
|
||||
|
||||
onScroll(false);
|
||||
const handleScroll = () => onScroll(true);
|
||||
const handleDirectionalInput = (
|
||||
direction: ThreadScrollDirection | null,
|
||||
) => {
|
||||
if (!direction) return;
|
||||
threadMotionRef.current?.handleUserScrollIntent(
|
||||
canScrollInDirection(el, direction),
|
||||
);
|
||||
};
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (
|
||||
event.defaultPrevented
|
||||
|| event.ctrlKey
|
||||
|| Math.abs(event.deltaY) <= Math.abs(event.deltaX)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handleDirectionalInput(directionFromDelta(event.deltaY));
|
||||
};
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (event.button === 0 && event.target === el) yieldCameraToUser();
|
||||
};
|
||||
let touchStartY: number | null = null;
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
touchStartY = event.touches[0]?.clientY ?? null;
|
||||
};
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
const currentY = event.touches[0]?.clientY;
|
||||
const scrollDeltaY =
|
||||
touchStartY !== null && currentY !== undefined
|
||||
? touchStartY - currentY
|
||||
: 0;
|
||||
handleDirectionalInput(directionFromDelta(scrollDeltaY));
|
||||
};
|
||||
const handleTouchEnd = () => {
|
||||
touchStartY = null;
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.defaultPrevented
|
||||
|| event.altKey
|
||||
|| event.ctrlKey
|
||||
|| event.metaKey
|
||||
|| isKeyboardEditableElement(event.target as Element | null)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handleDirectionalInput(keyboardScrollDirection(event));
|
||||
};
|
||||
el.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", handleScroll);
|
||||
}, [maybeLoadEarlierFromScroll]);
|
||||
el.addEventListener("wheel", handleWheel, { passive: true });
|
||||
el.addEventListener("touchstart", handleTouchStart, { passive: true });
|
||||
el.addEventListener("touchmove", handleTouchMove, { passive: true });
|
||||
el.addEventListener("touchend", handleTouchEnd, { passive: true });
|
||||
el.addEventListener("touchcancel", handleTouchEnd, { passive: true });
|
||||
el.addEventListener("pointerdown", handlePointerDown);
|
||||
el.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
el.removeEventListener("scroll", handleScroll);
|
||||
el.removeEventListener("wheel", handleWheel);
|
||||
el.removeEventListener("touchstart", handleTouchStart);
|
||||
el.removeEventListener("touchmove", handleTouchMove);
|
||||
el.removeEventListener("touchend", handleTouchEnd);
|
||||
el.removeEventListener("touchcancel", handleTouchEnd);
|
||||
el.removeEventListener("pointerdown", handlePointerDown);
|
||||
el.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [maybeLoadEarlierFromScroll, yieldCameraToUser]);
|
||||
|
||||
return (
|
||||
<div className="thread-viewport relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
"thread-viewport-scrollbar absolute inset-0 scroll-auto scrollbar-thin",
|
||||
"thread-viewport-scrollbar absolute inset-0 scroll-auto",
|
||||
"[overflow-anchor:none] [scrollbar-width:none]",
|
||||
"[&::-webkit-scrollbar]:hidden",
|
||||
hasVerticalOverflow ? "overflow-y-auto" : "overflow-hidden",
|
||||
"[&::-webkit-scrollbar]:w-1.5",
|
||||
"[&::-webkit-scrollbar-thumb]:rounded-full",
|
||||
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
|
||||
"[&::-webkit-scrollbar-track]:bg-transparent",
|
||||
)}
|
||||
style={scrollViewportStyle}
|
||||
>
|
||||
{hasMessages ? (
|
||||
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
|
||||
<div
|
||||
ref={contentRef}
|
||||
data-testid={!hasMessages ? "thread-welcome-layout" : undefined}
|
||||
data-layout={hasMessages ? "thread" : "hero"}
|
||||
className={cn(
|
||||
"thread-layout mx-auto grid min-h-full w-full",
|
||||
hasMessages
|
||||
? "max-w-[64rem]"
|
||||
: "max-w-[72rem] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-6 sm:px-4 sm:py-12",
|
||||
)}
|
||||
>
|
||||
{hasMessages ? (
|
||||
<div
|
||||
ref={messageRegionRef}
|
||||
data-testid="thread-message-region"
|
||||
className="flex min-h-0 flex-1 flex-col justify-start px-3 pb-4 pt-4 sm:px-4"
|
||||
className="row-start-1 flex min-h-0 flex-col justify-start px-3 pb-4 pt-4 sm:px-4"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
||||
<div ref={messageContentRef} className="mx-auto w-full max-w-[49.5rem]">
|
||||
<ThreadMessages
|
||||
messages={visibleMessages}
|
||||
isStreaming={isStreaming}
|
||||
@@ -563,32 +655,41 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center sm:items-end sm:pb-8">
|
||||
{emptyState}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={composerDockRef}
|
||||
data-testid="thread-composer-dock"
|
||||
className={cn(
|
||||
"row-start-2 z-10 w-full",
|
||||
hasMessages ? "sticky bottom-0 bg-background" : "relative self-center",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={composerDockRef}
|
||||
data-testid="thread-composer-dock"
|
||||
className="sticky bottom-0 z-10 bg-background"
|
||||
className={cn(
|
||||
hasMessages
|
||||
? "px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<div className="px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4">
|
||||
<div
|
||||
data-testid="thread-composer-motion"
|
||||
className="mx-auto w-full max-w-[58rem]"
|
||||
>
|
||||
{composer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-3 sm:px-4">
|
||||
<div className="flex w-full flex-1 flex-col items-center pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-6 sm:justify-center sm:py-12">
|
||||
<div
|
||||
data-testid="thread-welcome-layout"
|
||||
className="relative grid w-full max-w-[58rem] flex-1 grid-rows-[minmax(min-content,1fr)_auto] gap-8 sm:block sm:flex-none"
|
||||
>
|
||||
<div className="flex min-h-0 min-w-0 w-full items-center justify-center sm:absolute sm:inset-x-0 sm:bottom-[calc(100%+2rem)]">
|
||||
{emptyState}
|
||||
</div>
|
||||
<div className="w-full">{composer}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
<div ref={bottomRef} aria-hidden className="h-px" />
|
||||
</div>
|
||||
|
||||
@@ -602,6 +703,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
messages={visibleMessages}
|
||||
scrollRef={scrollRef}
|
||||
bottomOffset={scrollButtonBottom}
|
||||
onJumpToPrompt={navigateToVisiblePrompt}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -613,7 +715,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => scrollToBottom(true, 1, { force: true })}
|
||||
onClick={() => scrollToBottom(true, { force: true })}
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full shadow-md",
|
||||
"bg-background/90 backdrop-blur",
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
interface ThreadCameraMotionProfile {
|
||||
/**
|
||||
* Time constant for the ease-out chase. Smaller values react faster; the
|
||||
* camera closes roughly 95% of an uncapped distance in three time constants.
|
||||
*/
|
||||
responseTimeMs: number;
|
||||
/** Prevents a large completion batch from turning into a one-frame jump. */
|
||||
maxSpeedPxPerSecond: number;
|
||||
/** Avoids spending frames chasing sub-pixel layout noise. */
|
||||
settleDistancePx: number;
|
||||
/** Limits catch-up after a throttled or backgrounded animation frame. */
|
||||
maxFrameDeltaMs: number;
|
||||
}
|
||||
|
||||
const THREAD_CAMERA_FOLLOW_MOTION: Readonly<ThreadCameraMotionProfile> = {
|
||||
responseTimeMs: 90,
|
||||
maxSpeedPxPerSecond: 1_200,
|
||||
settleDistancePx: 0.5,
|
||||
maxFrameDeltaMs: 50,
|
||||
};
|
||||
|
||||
const THREAD_CAMERA_NAVIGATION_MOTION: Readonly<ThreadCameraMotionProfile> = {
|
||||
responseTimeMs: 110,
|
||||
maxSpeedPxPerSecond: 12_000,
|
||||
settleDistancePx: 0.5,
|
||||
maxFrameDeltaMs: 50,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reduced motion still preserves spatial continuity. Snapping a long thread
|
||||
* to its destination removes the very context that helps users understand
|
||||
* where the viewport moved; this profile shortens that motion instead.
|
||||
*/
|
||||
const THREAD_CAMERA_REDUCED_MOTION: Readonly<ThreadCameraMotionProfile> = {
|
||||
responseTimeMs: 55,
|
||||
maxSpeedPxPerSecond: 2_400,
|
||||
settleDistancePx: 0.5,
|
||||
maxFrameDeltaMs: 50,
|
||||
};
|
||||
|
||||
const THREAD_CAMERA_REDUCED_NAVIGATION_MOTION: Readonly<ThreadCameraMotionProfile> = {
|
||||
responseTimeMs: 45,
|
||||
maxSpeedPxPerSecond: 24_000,
|
||||
settleDistancePx: 0.5,
|
||||
maxFrameDeltaMs: 50,
|
||||
};
|
||||
|
||||
export interface ThreadCameraViewport {
|
||||
scrollTop: number;
|
||||
scrollTo?: (options?: ScrollToOptions) => void;
|
||||
}
|
||||
|
||||
export interface ThreadCameraScheduler {
|
||||
request: (callback: FrameRequestCallback) => number;
|
||||
cancel: (id: number) => void;
|
||||
now: () => number;
|
||||
}
|
||||
|
||||
export type ThreadCameraFollowResult = "started" | "retargeted" | "settled";
|
||||
|
||||
interface ThreadCameraOptions {
|
||||
scheduler?: ThreadCameraScheduler;
|
||||
prefersReducedMotion?: () => boolean;
|
||||
}
|
||||
|
||||
type ThreadCameraMotionKind = "follow" | "navigation";
|
||||
|
||||
/**
|
||||
* A time-based ease-out chase rather than a start/end tween. The target can
|
||||
* move on every streamed line without restarting a duration or adding another
|
||||
* frame loop.
|
||||
*/
|
||||
function easeOutChase(
|
||||
current: number,
|
||||
target: number,
|
||||
deltaSeconds: number,
|
||||
profile: Pick<ThreadCameraMotionProfile, "responseTimeMs" | "maxSpeedPxPerSecond">,
|
||||
): number {
|
||||
const distance = target - current;
|
||||
const responseSeconds = Math.max(0.001, profile.responseTimeMs / 1000);
|
||||
const timeStep = Math.max(0.001, deltaSeconds);
|
||||
const easeOutFraction = 1 - Math.exp(-timeStep / responseSeconds);
|
||||
const uncappedStep = distance * easeOutFraction;
|
||||
const maxStep = Math.max(0, profile.maxSpeedPxPerSecond) * timeStep;
|
||||
const step = Math.max(-maxStep, Math.min(maxStep, uncappedStep));
|
||||
return current + step;
|
||||
}
|
||||
|
||||
function defaultScheduler(): ThreadCameraScheduler {
|
||||
return {
|
||||
request: (callback) => window.requestAnimationFrame(callback),
|
||||
cancel: (id) => window.cancelAnimationFrame(id),
|
||||
now: () => performance.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultPrefersReducedMotion(): boolean {
|
||||
return typeof window !== "undefined"
|
||||
&& typeof window.matchMedia === "function"
|
||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
export class ThreadCameraController {
|
||||
private readonly getViewport: () => ThreadCameraViewport | null;
|
||||
private readonly scheduler: ThreadCameraScheduler;
|
||||
private readonly prefersReducedMotion: () => boolean;
|
||||
private frameId: number | null = null;
|
||||
private phase: "idle" | "following" = "idle";
|
||||
private target = 0;
|
||||
private lastTimestamp: number | null = null;
|
||||
private motionKind: ThreadCameraMotionKind = "follow";
|
||||
|
||||
constructor(
|
||||
getViewport: () => ThreadCameraViewport | null,
|
||||
options: ThreadCameraOptions = {},
|
||||
) {
|
||||
this.getViewport = getViewport;
|
||||
this.scheduler = options.scheduler ?? defaultScheduler();
|
||||
this.prefersReducedMotion = options.prefersReducedMotion ?? defaultPrefersReducedMotion;
|
||||
}
|
||||
|
||||
isFollowing(): boolean {
|
||||
return this.phase === "following";
|
||||
}
|
||||
|
||||
jumpTo(top: number): void {
|
||||
const viewport = this.getViewport();
|
||||
if (!viewport) return;
|
||||
this.cancel();
|
||||
this.target = Math.max(0, top);
|
||||
this.write(viewport, this.target);
|
||||
}
|
||||
|
||||
followTo(top: number): ThreadCameraFollowResult | null {
|
||||
return this.moveTo(top, "follow");
|
||||
}
|
||||
|
||||
navigateTo(top: number): ThreadCameraFollowResult | null {
|
||||
return this.moveTo(top, "navigation");
|
||||
}
|
||||
|
||||
private moveTo(
|
||||
top: number,
|
||||
motionKind: ThreadCameraMotionKind,
|
||||
): ThreadCameraFollowResult | null {
|
||||
const viewport = this.getViewport();
|
||||
if (!viewport) return null;
|
||||
const current = viewport.scrollTop;
|
||||
this.target = Math.max(0, top);
|
||||
this.motionKind = motionKind;
|
||||
|
||||
const motion = this.currentMotion(motionKind);
|
||||
if (this.phase === "following") {
|
||||
return "retargeted";
|
||||
}
|
||||
if (Math.abs(this.target - current) <= motion.settleDistancePx) {
|
||||
this.write(viewport, this.target);
|
||||
return "settled";
|
||||
}
|
||||
|
||||
this.phase = "following";
|
||||
this.lastTimestamp = this.scheduler.now();
|
||||
this.frameId = this.scheduler.request(this.advance);
|
||||
return "started";
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.frameId !== null) {
|
||||
this.scheduler.cancel(this.frameId);
|
||||
this.frameId = null;
|
||||
}
|
||||
this.phase = "idle";
|
||||
this.lastTimestamp = null;
|
||||
this.motionKind = "follow";
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.cancel();
|
||||
}
|
||||
|
||||
private readonly advance = (timestamp: number): void => {
|
||||
this.frameId = null;
|
||||
const viewport = this.getViewport();
|
||||
if (!viewport || this.phase !== "following") {
|
||||
this.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
const motion = this.currentMotion(this.motionKind);
|
||||
const previousTimestamp = this.lastTimestamp ?? timestamp - (1000 / 60);
|
||||
const deltaMs = Math.min(
|
||||
motion.maxFrameDeltaMs,
|
||||
Math.max(1, timestamp - previousTimestamp),
|
||||
);
|
||||
this.lastTimestamp = timestamp;
|
||||
const current = viewport.scrollTop;
|
||||
const remainingDistance = this.target - current;
|
||||
if (Math.abs(remainingDistance) <= motion.settleDistancePx) {
|
||||
this.write(viewport, this.target);
|
||||
this.phase = "idle";
|
||||
this.lastTimestamp = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaSeconds = deltaMs / 1000;
|
||||
const nextTop = easeOutChase(
|
||||
current,
|
||||
this.target,
|
||||
deltaSeconds,
|
||||
motion,
|
||||
);
|
||||
const settled = Math.abs(this.target - nextTop) <= motion.settleDistancePx;
|
||||
this.write(viewport, settled ? this.target : nextTop);
|
||||
|
||||
if (settled) {
|
||||
this.phase = "idle";
|
||||
this.lastTimestamp = null;
|
||||
return;
|
||||
}
|
||||
this.frameId = this.scheduler.request(this.advance);
|
||||
};
|
||||
|
||||
private currentMotion(kind: ThreadCameraMotionKind): ThreadCameraMotionProfile {
|
||||
if (kind === "navigation") {
|
||||
return this.prefersReducedMotion()
|
||||
? THREAD_CAMERA_REDUCED_NAVIGATION_MOTION
|
||||
: THREAD_CAMERA_NAVIGATION_MOTION;
|
||||
}
|
||||
return this.prefersReducedMotion()
|
||||
? THREAD_CAMERA_REDUCED_MOTION
|
||||
: THREAD_CAMERA_FOLLOW_MOTION;
|
||||
}
|
||||
|
||||
private write(viewport: ThreadCameraViewport, top: number): void {
|
||||
try {
|
||||
viewport.scrollTop = top;
|
||||
} catch {
|
||||
try {
|
||||
viewport.scrollTo?.({ top, behavior: "auto" });
|
||||
} catch {
|
||||
// Test DOMs can expose read-only scrollTop; browsers keep this writable.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import type {
|
||||
ThreadCameraController,
|
||||
ThreadCameraFollowResult,
|
||||
} from "@/components/thread/thread-camera";
|
||||
|
||||
type ThreadMotionMode =
|
||||
| "idle"
|
||||
| "anchor-prompt"
|
||||
| "follow-output"
|
||||
| "follow-completion"
|
||||
| "navigating-history"
|
||||
| "browsing-history";
|
||||
|
||||
type AutomaticThreadMotionMode =
|
||||
| "idle"
|
||||
| "anchor-prompt"
|
||||
| "follow-output";
|
||||
|
||||
type ThreadMotionEvent =
|
||||
| "navigate-history"
|
||||
| "navigation-settled"
|
||||
| "user-scroll"
|
||||
| "boundary-scroll"
|
||||
| "turn-completed"
|
||||
| "resume-follow";
|
||||
|
||||
type ThreadMotionTransition = ThreadMotionMode | "current-automatic-mode";
|
||||
|
||||
const THREAD_MOTION_TRANSITIONS: Readonly<
|
||||
Record<
|
||||
ThreadMotionMode,
|
||||
Readonly<Partial<Record<ThreadMotionEvent, ThreadMotionTransition>>>
|
||||
>
|
||||
> = {
|
||||
idle: {
|
||||
"navigate-history": "navigating-history",
|
||||
"user-scroll": "browsing-history",
|
||||
},
|
||||
"anchor-prompt": {
|
||||
"navigate-history": "navigating-history",
|
||||
"user-scroll": "browsing-history",
|
||||
"turn-completed": "follow-completion",
|
||||
},
|
||||
"follow-output": {
|
||||
"navigate-history": "navigating-history",
|
||||
"user-scroll": "browsing-history",
|
||||
"turn-completed": "follow-completion",
|
||||
},
|
||||
"follow-completion": {
|
||||
"navigate-history": "navigating-history",
|
||||
"user-scroll": "browsing-history",
|
||||
},
|
||||
"navigating-history": {
|
||||
"navigate-history": "navigating-history",
|
||||
"navigation-settled": "browsing-history",
|
||||
"user-scroll": "browsing-history",
|
||||
"boundary-scroll": "browsing-history",
|
||||
"resume-follow": "current-automatic-mode",
|
||||
},
|
||||
"browsing-history": {
|
||||
"navigate-history": "navigating-history",
|
||||
"resume-follow": "current-automatic-mode",
|
||||
},
|
||||
};
|
||||
|
||||
export interface ThreadMotionGeometry {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
maxScrollTop: number;
|
||||
composerHeight: number;
|
||||
promptTop: number | null;
|
||||
}
|
||||
|
||||
interface ThreadMotionTurn {
|
||||
id: string | null;
|
||||
promptId: string | null;
|
||||
hasOutput: boolean;
|
||||
entry?: "submitted" | "restored";
|
||||
}
|
||||
|
||||
interface ThreadMotionSnapshot {
|
||||
mode: ThreadMotionMode;
|
||||
turnId: string | null;
|
||||
promptId: string | null;
|
||||
promptPositioned: boolean;
|
||||
measurementPending: boolean;
|
||||
}
|
||||
|
||||
export interface ThreadMotionScheduler {
|
||||
request: (callback: FrameRequestCallback) => number;
|
||||
cancel: (id: number) => void;
|
||||
}
|
||||
|
||||
type ThreadMotionCamera = Pick<
|
||||
ThreadCameraController,
|
||||
| "cancel"
|
||||
| "dispose"
|
||||
| "followTo"
|
||||
| "isFollowing"
|
||||
| "jumpTo"
|
||||
| "navigateTo"
|
||||
>;
|
||||
|
||||
interface ThreadMotionCoordinatorOptions {
|
||||
camera: ThreadMotionCamera;
|
||||
measure: (promptId: string | null) => ThreadMotionGeometry | null;
|
||||
onGeometry?: (geometry: ThreadMotionGeometry) => void;
|
||||
onAutoFollow?: () => void;
|
||||
scheduler?: ThreadMotionScheduler;
|
||||
}
|
||||
|
||||
const GEOMETRY_EPSILON_PX = 0.5;
|
||||
|
||||
type ThreadScrollOwner = "automatic" | "navigation" | "user";
|
||||
|
||||
function defaultScheduler(): ThreadMotionScheduler {
|
||||
return {
|
||||
request: (callback) => window.requestAnimationFrame(callback),
|
||||
cancel: (id) => window.cancelAnimationFrame(id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the policy that turns discrete layout events into continuous camera
|
||||
* motion. Callers only invalidate geometry; one display frame coalesces those
|
||||
* notifications, reads the authoritative layout, and retargets the camera.
|
||||
*/
|
||||
export class ThreadMotionCoordinator {
|
||||
private readonly camera: ThreadMotionCamera;
|
||||
private readonly measure: ThreadMotionCoordinatorOptions["measure"];
|
||||
private readonly onGeometry?: ThreadMotionCoordinatorOptions["onGeometry"];
|
||||
private readonly onAutoFollow?: ThreadMotionCoordinatorOptions["onAutoFollow"];
|
||||
private readonly scheduler: ThreadMotionScheduler;
|
||||
private turn: ThreadMotionTurn = {
|
||||
id: null,
|
||||
promptId: null,
|
||||
hasOutput: false,
|
||||
};
|
||||
private mode: ThreadMotionMode = "idle";
|
||||
private promptPositioned = false;
|
||||
private measurementFrameId: number | null = null;
|
||||
private geometryDirty = false;
|
||||
|
||||
constructor(options: ThreadMotionCoordinatorOptions) {
|
||||
this.camera = options.camera;
|
||||
this.measure = options.measure;
|
||||
this.onGeometry = options.onGeometry;
|
||||
this.onAutoFollow = options.onAutoFollow;
|
||||
this.scheduler = options.scheduler ?? defaultScheduler();
|
||||
}
|
||||
|
||||
snapshot(): ThreadMotionSnapshot {
|
||||
return {
|
||||
mode: this.mode,
|
||||
turnId: this.turn.id,
|
||||
promptId: this.turn.promptId,
|
||||
promptPositioned: this.promptPositioned,
|
||||
measurementPending: this.measurementFrameId !== null,
|
||||
};
|
||||
}
|
||||
|
||||
updateTurn(turn: ThreadMotionTurn): void {
|
||||
if (!turn.id) {
|
||||
this.completeTurn();
|
||||
return;
|
||||
}
|
||||
|
||||
const isNewTurn = this.turn.id !== turn.id;
|
||||
this.turn = turn;
|
||||
if (isNewTurn) {
|
||||
this.camera.cancel();
|
||||
this.promptPositioned = turn.entry === "restored";
|
||||
this.mode = this.promptPositioned && turn.hasOutput
|
||||
? "follow-output"
|
||||
: "anchor-prompt";
|
||||
} else if (!this.isHistoryMode() && this.promptPositioned) {
|
||||
this.mode = turn.hasOutput ? "follow-output" : "anchor-prompt";
|
||||
}
|
||||
this.invalidateGeometry();
|
||||
}
|
||||
|
||||
completeTurn(): void {
|
||||
if (!this.turn.id) {
|
||||
this.invalidateGeometry();
|
||||
return;
|
||||
}
|
||||
// Protocol completion can share a React commit with the last large text
|
||||
// batch and begins the run-drawer exit. Keep camera ownership through
|
||||
// those final layout changes; only a new turn or explicit user navigation
|
||||
// may end completion follow.
|
||||
this.transition("turn-completed");
|
||||
this.turn = { id: null, promptId: null, hasOutput: false };
|
||||
this.promptPositioned = false;
|
||||
this.invalidateGeometry();
|
||||
}
|
||||
|
||||
invalidateGeometry(): void {
|
||||
this.geometryDirty = true;
|
||||
if (this.measurementFrameId !== null) return;
|
||||
this.measurementFrameId = this.scheduler.request(this.flushGeometry);
|
||||
}
|
||||
|
||||
takeUserControl(): void {
|
||||
this.handleUserScrollIntent(true);
|
||||
}
|
||||
|
||||
handleUserScrollIntent(canScroll: boolean): void {
|
||||
const event = canScroll ? "user-scroll" : "boundary-scroll";
|
||||
if (!this.transition(event)) return;
|
||||
this.camera.cancel();
|
||||
}
|
||||
|
||||
resumeAutoFollow(): void {
|
||||
if (!this.transition("resume-follow")) return;
|
||||
this.camera.cancel();
|
||||
this.invalidateGeometry();
|
||||
}
|
||||
|
||||
jumpTo(top: number): void {
|
||||
this.camera.jumpTo(top);
|
||||
}
|
||||
|
||||
animateTo(top: number): ThreadCameraFollowResult | null {
|
||||
return this.camera.navigateTo(top);
|
||||
}
|
||||
|
||||
navigateHistoryTo(top: number): ThreadCameraFollowResult | null {
|
||||
this.camera.cancel();
|
||||
this.transition("navigate-history");
|
||||
const result = this.camera.navigateTo(top);
|
||||
if (!result || result === "settled") {
|
||||
this.transition("navigation-settled");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
isAutoFollowPaused(): boolean {
|
||||
return this.isHistoryMode();
|
||||
}
|
||||
|
||||
isBrowsingHistory(): boolean {
|
||||
return this.mode === "browsing-history";
|
||||
}
|
||||
|
||||
/**
|
||||
* A scroll event reports geometry; it does not prove user intent. Explicit
|
||||
* input handlers call takeUserControl() before the browser scrolls. Layout,
|
||||
* sticky positioning, and camera writes therefore remain automatic even
|
||||
* when the browser emits an intermediate scroll event for them.
|
||||
*/
|
||||
observeScroll(nearBottom: boolean): ThreadScrollOwner {
|
||||
switch (this.mode) {
|
||||
case "navigating-history":
|
||||
if (!this.camera.isFollowing()) {
|
||||
this.transition("navigation-settled");
|
||||
if (nearBottom) this.resumeAutoFollow();
|
||||
}
|
||||
return "navigation";
|
||||
case "browsing-history":
|
||||
if (!nearBottom) return "user";
|
||||
this.resumeAutoFollow();
|
||||
return "automatic";
|
||||
default:
|
||||
if (!nearBottom) this.invalidateGeometry();
|
||||
return "automatic";
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
if (this.measurementFrameId !== null) {
|
||||
this.scheduler.cancel(this.measurementFrameId);
|
||||
this.measurementFrameId = null;
|
||||
}
|
||||
this.geometryDirty = false;
|
||||
this.camera.cancel();
|
||||
this.turn = { id: null, promptId: null, hasOutput: false };
|
||||
this.mode = "idle";
|
||||
this.promptPositioned = false;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.reset();
|
||||
this.camera.dispose();
|
||||
}
|
||||
|
||||
private isHistoryMode(): boolean {
|
||||
return (
|
||||
this.mode === "navigating-history"
|
||||
|| this.mode === "browsing-history"
|
||||
);
|
||||
}
|
||||
|
||||
private automaticMode(): AutomaticThreadMotionMode {
|
||||
if (!this.turn.id) return "idle";
|
||||
return this.promptPositioned && this.turn.hasOutput
|
||||
? "follow-output"
|
||||
: "anchor-prompt";
|
||||
}
|
||||
|
||||
private transition(event: ThreadMotionEvent): boolean {
|
||||
const transition = THREAD_MOTION_TRANSITIONS[this.mode][event];
|
||||
if (!transition) return false;
|
||||
const nextMode =
|
||||
transition === "current-automatic-mode"
|
||||
? this.automaticMode()
|
||||
: transition;
|
||||
if (nextMode === this.mode) return false;
|
||||
this.mode = nextMode;
|
||||
return true;
|
||||
}
|
||||
|
||||
private followGeometry(geometry: ThreadMotionGeometry): void {
|
||||
const target = geometry.maxScrollTop;
|
||||
const result = this.camera.followTo(target);
|
||||
if (
|
||||
result
|
||||
&& (
|
||||
Math.abs(target - geometry.scrollTop) > GEOMETRY_EPSILON_PX
|
||||
|| result === "retargeted"
|
||||
)
|
||||
) {
|
||||
this.onAutoFollow?.();
|
||||
}
|
||||
}
|
||||
|
||||
private readonly flushGeometry = (): void => {
|
||||
this.measurementFrameId = null;
|
||||
if (!this.geometryDirty) return;
|
||||
this.geometryDirty = false;
|
||||
|
||||
const needsPromptGeometry =
|
||||
!this.isHistoryMode()
|
||||
&& this.turn.id !== null
|
||||
&& !this.promptPositioned;
|
||||
const geometry = this.measure(needsPromptGeometry ? this.turn.promptId : null);
|
||||
if (!geometry) return;
|
||||
this.onGeometry?.(geometry);
|
||||
|
||||
if (this.mode === "follow-completion") {
|
||||
this.followGeometry(geometry);
|
||||
return;
|
||||
}
|
||||
if (this.isHistoryMode() || !this.turn.id) return;
|
||||
if (!this.turn.promptId && this.turn.entry !== "restored") {
|
||||
this.mode = "anchor-prompt";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.promptPositioned) {
|
||||
if (geometry.promptTop === null) {
|
||||
this.mode = "anchor-prompt";
|
||||
return;
|
||||
}
|
||||
// Before output exists, the real lower scroll boundary is the only
|
||||
// position with zero hidden downward travel. Once output exists, start
|
||||
// from the prompt origin and let the follow camera reveal its growth.
|
||||
this.camera.jumpTo(
|
||||
this.turn.hasOutput ? geometry.promptTop : geometry.maxScrollTop,
|
||||
);
|
||||
this.promptPositioned = true;
|
||||
} else if (
|
||||
!this.turn.hasOutput
|
||||
&& Math.abs(geometry.maxScrollTop - geometry.scrollTop)
|
||||
> GEOMETRY_EPSILON_PX
|
||||
) {
|
||||
// Hero docking, the run drawer, fonts, and responsive chrome can all
|
||||
// change document height while the model is still silent. Every
|
||||
// authoritative geometry frame reasserts the real lower boundary so no
|
||||
// stale downward scroll pocket survives a layout transition.
|
||||
this.camera.jumpTo(geometry.maxScrollTop);
|
||||
}
|
||||
|
||||
if (!this.turn.hasOutput) {
|
||||
this.mode = "anchor-prompt";
|
||||
return;
|
||||
}
|
||||
|
||||
this.mode = "follow-output";
|
||||
this.followGeometry(geometry);
|
||||
};
|
||||
}
|
||||
+60
-27
@@ -265,33 +265,6 @@
|
||||
@apply list-none pl-0;
|
||||
}
|
||||
|
||||
/* Show a caret only while the full markdown renderer is still loading. */
|
||||
@keyframes streaming-caret-blink {
|
||||
0%, 45% { opacity: 1; }
|
||||
55%, 100% { opacity: 0; }
|
||||
}
|
||||
.streaming-text-fallback::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 1.5px;
|
||||
height: 1em;
|
||||
margin-left: 3px;
|
||||
vertical-align: -0.12em;
|
||||
border-radius: 1px;
|
||||
background: hsl(var(--foreground) / 0.8);
|
||||
animation: streaming-caret-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-sd-animate] {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.streaming-text-fallback::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* CJK-friendly line-height: prose paragraphs default to 1.625 which is
|
||||
tight for Chinese/Japanese/Korean characters. Bump to 1.8 for better
|
||||
readability when the browser detects a CJK primary font. */
|
||||
@@ -371,6 +344,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer is one persistent grid item in both landing and thread
|
||||
* layouts. On desktop, the landing's trailing flexible row holds it at the
|
||||
* visual center; collapsing only that row lets the same element dock at the
|
||||
* bottom without a remount or a transform clone.
|
||||
*/
|
||||
.thread-layout {
|
||||
grid-template-rows: minmax(min-content, 1fr) auto 0fr;
|
||||
transition:
|
||||
grid-template-rows 900ms cubic-bezier(0.33, 1, 0.68, 1);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.thread-layout[data-layout="hero"] {
|
||||
grid-template-rows: minmax(min-content, 1fr) auto 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes composer-status-strip-enter {
|
||||
0% {
|
||||
max-height: 0;
|
||||
@@ -410,6 +400,39 @@
|
||||
.composer-status-strip[data-state="exit"] {
|
||||
animation: composer-status-strip-exit 180ms ease-in both;
|
||||
}
|
||||
.composer-status-drawer {
|
||||
--composer-status-drawer-duration: 600ms;
|
||||
--composer-status-drawer-easing: ease-out;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
grid-template-rows var(--composer-status-drawer-duration)
|
||||
var(--composer-status-drawer-easing);
|
||||
}
|
||||
.composer-status-drawer[data-state="open"] {
|
||||
grid-template-rows: 1fr;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.composer-status-drawer-clip {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.composer-status-drawer-content {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transform-origin: bottom center;
|
||||
transition:
|
||||
opacity var(--composer-status-drawer-duration)
|
||||
var(--composer-status-drawer-easing),
|
||||
transform var(--composer-status-drawer-duration)
|
||||
var(--composer-status-drawer-easing);
|
||||
}
|
||||
.composer-status-drawer[data-state="open"] .composer-status-drawer-content {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
@keyframes run-pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
@@ -474,6 +497,16 @@
|
||||
.composer-status-strip[data-state] {
|
||||
animation: none;
|
||||
}
|
||||
.composer-status-drawer {
|
||||
transition-duration: 220ms;
|
||||
}
|
||||
.composer-status-drawer-content {
|
||||
transform: translateY(2px);
|
||||
transition-duration: 140ms, 220ms;
|
||||
}
|
||||
.thread-layout {
|
||||
transition-duration: 360ms;
|
||||
}
|
||||
.run-pulse-icon,
|
||||
.run-pulse-icon * {
|
||||
animation: none;
|
||||
|
||||
@@ -227,9 +227,9 @@ function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
|
||||
});
|
||||
}
|
||||
|
||||
function stampLastAssistantLatency(
|
||||
function stampLastAssistantCompletion(
|
||||
prev: UIMessage[],
|
||||
latencyMs: number,
|
||||
completion: Pick<UIMessage, "latencyMs" | "completedAt">,
|
||||
turnId?: string,
|
||||
): UIMessage[] {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
@@ -239,7 +239,7 @@ function stampLastAssistantLatency(
|
||||
&& m.kind !== "trace"
|
||||
&& (!turnId || !m.turnId || m.turnId === turnId)
|
||||
) {
|
||||
const merged: UIMessage = { ...m, latencyMs, isStreaming: false };
|
||||
const merged: UIMessage = { ...m, ...completion, isStreaming: false };
|
||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||
}
|
||||
}
|
||||
@@ -488,6 +488,12 @@ export interface SendOptions {
|
||||
continueActiveTurn?: boolean;
|
||||
}
|
||||
|
||||
export interface SubmittedTurn {
|
||||
turnId: string;
|
||||
userMessageId: string;
|
||||
sideChannel: boolean;
|
||||
}
|
||||
|
||||
function eventExtendsModelActivity(ev: InboundEvent): boolean {
|
||||
if (
|
||||
ev.event === "delta"
|
||||
@@ -520,12 +526,18 @@ export function useNanobotStream(
|
||||
onTurnEnd?: () => void,
|
||||
): {
|
||||
messages: UIMessage[];
|
||||
/** Whether ``messages`` belongs to the current ``chatId`` after a session switch. */
|
||||
messagesReady: boolean;
|
||||
isStreaming: boolean;
|
||||
/** Unix epoch seconds when the current user turn started (WebSocket ``goal_status``). */
|
||||
runStartedAt: number | null;
|
||||
/** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */
|
||||
goalState: GoalStateWsPayload | undefined;
|
||||
send: (content: string, images?: SendAttachment[], options?: SendOptions) => void;
|
||||
send: (
|
||||
content: string,
|
||||
images?: SendAttachment[],
|
||||
options?: SendOptions,
|
||||
) => SubmittedTurn | null;
|
||||
transcribeAudio: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
|
||||
stop: () => void;
|
||||
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
|
||||
@@ -537,12 +549,16 @@ export function useNanobotStream(
|
||||
dismissStreamError: () => void;
|
||||
} {
|
||||
const { client } = useClient();
|
||||
const initialRunStartedAt = chatId ? client.getRunStartedAt(chatId) : null;
|
||||
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
|
||||
const [messageOwnerChatId, setMessageOwnerChatId] = useState(chatId);
|
||||
/** If history ends in unfinished agent activity, keep the loading spinner alive. */
|
||||
const initialStreaming = hasPendingAgentActivity(initialMessages);
|
||||
const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls);
|
||||
const [isStreaming, setIsStreaming] = useState(
|
||||
initialStreaming || hasPendingToolCalls || initialRunStartedAt !== null,
|
||||
);
|
||||
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
|
||||
const [runStartedAt, setRunStartedAt] = useState<number | null>(null);
|
||||
const [runStartedAt, setRunStartedAt] = useState<number | null>(initialRunStartedAt);
|
||||
const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
|
||||
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
||||
const buffer = useRef<StreamBuffer | null>(null);
|
||||
@@ -826,12 +842,16 @@ export function useNanobotStream(
|
||||
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
||||
// history response after the optimistic first message has already rendered.
|
||||
useEffect(() => {
|
||||
const restoredRunStartedAt = chatId ? client.getRunStartedAt(chatId) : null;
|
||||
setMessages(initialMessages);
|
||||
setMessageOwnerChatId(chatId);
|
||||
setIsStreaming(
|
||||
hasPendingAgentActivity(initialMessages) || hasPendingToolCalls,
|
||||
hasPendingAgentActivity(initialMessages)
|
||||
|| hasPendingToolCalls
|
||||
|| restoredRunStartedAt !== null,
|
||||
);
|
||||
setStreamError(null);
|
||||
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
|
||||
setRunStartedAt(restoredRunStartedAt);
|
||||
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
@@ -929,8 +949,10 @@ export function useNanobotStream(
|
||||
if (ev.event === "goal_status") {
|
||||
if (ev.status === "running" && typeof ev.started_at === "number") {
|
||||
setRunStartedAt(ev.started_at);
|
||||
setIsStreaming(true);
|
||||
} else {
|
||||
setRunStartedAt(null);
|
||||
setIsStreaming(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -944,16 +966,22 @@ export function useNanobotStream(
|
||||
// pending debounce timer and stop the loading indicator immediately.
|
||||
cancelStreamEndTimer();
|
||||
setIsStreaming(false);
|
||||
const completedAt = Date.now();
|
||||
setMessages((prev) => {
|
||||
let finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
||||
finalized = pruneReasoningOnlyPlaceholders(finalized);
|
||||
if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) {
|
||||
finalized = stampLastAssistantLatency(
|
||||
finalized,
|
||||
Math.round(ev.latency_ms),
|
||||
ev.turn_id,
|
||||
);
|
||||
}
|
||||
const latencyMs =
|
||||
typeof ev.latency_ms === "number" && ev.latency_ms >= 0
|
||||
? Math.round(ev.latency_ms)
|
||||
: undefined;
|
||||
finalized = stampLastAssistantCompletion(
|
||||
finalized,
|
||||
{
|
||||
...(latencyMs !== undefined ? { latencyMs } : {}),
|
||||
completedAt,
|
||||
},
|
||||
ev.turn_id,
|
||||
);
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
clearActivitySegment();
|
||||
@@ -1176,11 +1204,11 @@ export function useNanobotStream(
|
||||
|
||||
const send = useCallback(
|
||||
(content: string, images?: SendAttachment[], options?: SendOptions) => {
|
||||
if (!chatId) return;
|
||||
if (!chatId) return null;
|
||||
const hasAttachments = !!images && images.length > 0;
|
||||
// Text is optional when files are attached — the agent will still see
|
||||
// them via ``media`` paths.
|
||||
if (!hasAttachments && !content.trim()) return;
|
||||
if (!hasAttachments && !content.trim()) return null;
|
||||
|
||||
const sideChannel = options?.sideChannel === true;
|
||||
const finalizeActiveTurn = options?.finalizeActiveTurn === true;
|
||||
@@ -1194,6 +1222,7 @@ export function useNanobotStream(
|
||||
setIsStreaming(false);
|
||||
}
|
||||
const turnId = crypto.randomUUID();
|
||||
const userMessageId = crypto.randomUUID();
|
||||
if (sideChannel) sideChannelTurnIdsRef.current.add(turnId);
|
||||
const previews = hasAttachments ? images!.map((i) => i.preview) : undefined;
|
||||
setMessages((prev) => {
|
||||
@@ -1213,7 +1242,7 @@ export function useNanobotStream(
|
||||
return [
|
||||
...(sideChannel || continueActiveTurn ? base : pruneReasoningOnlyPlaceholders(base)),
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
id: userMessageId,
|
||||
role: "user",
|
||||
content: outboundContent,
|
||||
turnId,
|
||||
@@ -1234,6 +1263,7 @@ export function useNanobotStream(
|
||||
delete wireOptions.finalizeActiveTurn;
|
||||
delete wireOptions.continueActiveTurn;
|
||||
client.sendMessage(chatId, outboundContent, wireMedia, wireOptions);
|
||||
return { turnId, userMessageId, sideChannel };
|
||||
},
|
||||
[cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
||||
);
|
||||
@@ -1261,6 +1291,7 @@ export function useNanobotStream(
|
||||
|
||||
return {
|
||||
messages,
|
||||
messagesReady: messageOwnerChatId === chatId,
|
||||
isStreaming,
|
||||
runStartedAt,
|
||||
goalState,
|
||||
|
||||
@@ -71,6 +71,7 @@ const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [
|
||||
|
||||
const relativeTimeFormatters = new Map<string, Intl.RelativeTimeFormat>();
|
||||
const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||
const clockTimeFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function activeLocale(locale?: string): string {
|
||||
return locale || i18n.resolvedLanguage || i18n.language || currentLocale();
|
||||
@@ -95,6 +96,25 @@ function dateTimeFormatter(locale: string): Intl.DateTimeFormat {
|
||||
return formatter;
|
||||
}
|
||||
|
||||
function clockTimeFormatter(locale: string): Intl.DateTimeFormat {
|
||||
const existing = clockTimeFormatters.get(locale);
|
||||
if (existing) return existing;
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
clockTimeFormatters.set(locale, formatter);
|
||||
return formatter;
|
||||
}
|
||||
|
||||
function isSameLocalCalendarDay(left: Date, right: Date): boolean {
|
||||
return (
|
||||
left.getFullYear() === right.getFullYear()
|
||||
&& left.getMonth() === right.getMonth()
|
||||
&& left.getDate() === right.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
export function relativeTime(
|
||||
value: string | number | null | undefined,
|
||||
locale?: string,
|
||||
@@ -120,6 +140,22 @@ export function fmtDateTime(
|
||||
return date ? dateTimeFormatter(activeLocale(locale)).format(date) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a completion timestamp in the browser's local timezone.
|
||||
* Today's messages stay compact; older messages include their date for orientation.
|
||||
*/
|
||||
export function formatMessageEndTime(
|
||||
value: number | null | undefined,
|
||||
locale?: string,
|
||||
): string {
|
||||
const date = parseDate(value);
|
||||
if (!date) return "";
|
||||
const loc = activeLocale(locale);
|
||||
return isSameLocalCalendarDay(date, new Date())
|
||||
? clockTimeFormatter(loc).format(date)
|
||||
: dateTimeFormatter(loc).format(date);
|
||||
}
|
||||
|
||||
/** Human-readable turn duration (wall-clock), locale-aware via ``Intl`` (seconds/minutes). */
|
||||
export function formatTurnLatency(ms: number, locale?: string): string {
|
||||
const loc = activeLocale(locale);
|
||||
|
||||
@@ -24,6 +24,44 @@ export function normalizeLegacyLongTaskMessages(messages: UIMessage[]): UIMessag
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay timestamps an assistant row when its first output is recorded, while
|
||||
* latency covers the whole turn. Derive the end from the matching user start
|
||||
* so the displayed time cannot double-count the pre-output interval.
|
||||
*/
|
||||
function deriveAssistantCompletionTimes(messages: UIMessage[]): UIMessage[] {
|
||||
const userStartedAtByTurn = new Map<string, number>();
|
||||
let latestUserStartedAt: number | undefined;
|
||||
|
||||
return messages.map((message) => {
|
||||
if (message.role === "user") {
|
||||
if (Number.isFinite(message.createdAt)) {
|
||||
latestUserStartedAt = message.createdAt;
|
||||
if (message.turnId) userStartedAtByTurn.set(message.turnId, message.createdAt);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
if (
|
||||
message.role !== "assistant"
|
||||
|| message.kind === "trace"
|
||||
|| message.completedAt !== undefined
|
||||
|| message.latencyMs === undefined
|
||||
|| !Number.isFinite(message.latencyMs)
|
||||
|| message.latencyMs < 0
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const startedAt = message.turnId
|
||||
? userStartedAtByTurn.get(message.turnId)
|
||||
: message.source
|
||||
? undefined
|
||||
: latestUserStartedAt;
|
||||
if (startedAt === undefined) return message;
|
||||
return { ...message, completedAt: startedAt + message.latencyMs };
|
||||
});
|
||||
}
|
||||
|
||||
export function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
|
||||
const normalized = scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
|
||||
const hiddenTurns = new Set(normalized.flatMap((message) => (
|
||||
@@ -31,10 +69,11 @@ export function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
|
||||
? [message.turnId]
|
||||
: []
|
||||
)));
|
||||
return normalized.filter((message) => (
|
||||
const visible = normalized.filter((message) => (
|
||||
!isSystemCommandTurnId(message.turnId)
|
||||
&& (!message.turnId || !hiddenTurns.has(message.turnId))
|
||||
&& !(message.role === "user" && isModelCommandText(message.content))
|
||||
&& !(message.role === "assistant" && isModelCommandResponseText(message.content))
|
||||
));
|
||||
return deriveAssistantCompletionTimes(visible);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface UIMessage {
|
||||
reasoningStreaming?: boolean;
|
||||
/** End-to-end wall time for this assistant turn (persisted ``latency_ms`` / ``turn_end``). */
|
||||
latencyMs?: number;
|
||||
/** Client epoch milliseconds when the definitive ``turn_end`` was received. */
|
||||
completedAt?: number;
|
||||
/** Lightweight provenance for proactive assistant messages. */
|
||||
source?: UIMessageSource;
|
||||
/** Stable protocol metadata for grouping all activity emitted by one user turn. */
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { setAppLanguage } from "@/i18n";
|
||||
import { fmtDateTime, formatTurnLatency, relativeTime } from "@/lib/format";
|
||||
import {
|
||||
fmtDateTime,
|
||||
formatMessageEndTime,
|
||||
formatTurnLatency,
|
||||
relativeTime,
|
||||
} from "@/lib/format";
|
||||
|
||||
describe("localized format helpers", () => {
|
||||
beforeEach(() => {
|
||||
@@ -62,6 +67,34 @@ describe("localized format helpers", () => {
|
||||
expect(english).not.toBe(french);
|
||||
});
|
||||
|
||||
it("shows only the local clock time for messages completed today", async () => {
|
||||
const value = Date.parse("2026-04-18T08:34:56Z");
|
||||
const date = new Date(value);
|
||||
|
||||
await setAppLanguage("zh-CN");
|
||||
|
||||
expect(formatMessageEndTime(value)).toBe(
|
||||
new Intl.DateTimeFormat("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the local date for messages completed before today", async () => {
|
||||
const value = Date.parse("2026-04-16T08:34:56Z");
|
||||
const date = new Date(value);
|
||||
|
||||
await setAppLanguage("zh-CN");
|
||||
|
||||
expect(formatMessageEndTime(value)).toBe(
|
||||
new Intl.DateTimeFormat("zh-CN", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date),
|
||||
);
|
||||
});
|
||||
|
||||
it("formats turn latency with locale-aware units", async () => {
|
||||
await setAppLanguage("en");
|
||||
const subMinute = formatTurnLatency(2400, "en");
|
||||
|
||||
@@ -424,31 +424,28 @@ describe("MarkdownTextRenderer", () => {
|
||||
expect(container.firstElementChild).not.toHaveClass("space-y-0");
|
||||
});
|
||||
|
||||
it("uses Streamdown's incremental reveal while content is streaming", () => {
|
||||
it("keeps streaming parsing without a competing reveal animation", () => {
|
||||
const { container } = render(
|
||||
<MarkdownTextRenderer streaming>春天</MarkdownTextRenderer>,
|
||||
);
|
||||
|
||||
expect(container.firstElementChild).toHaveClass(
|
||||
expect(container).toHaveTextContent("春天");
|
||||
expect(container.firstElementChild).not.toHaveClass(
|
||||
"[&>*:last-child]:after:content-[var(--streamdown-caret)]",
|
||||
);
|
||||
const animatedUnits = container.querySelectorAll<HTMLElement>("[data-sd-animate]");
|
||||
expect(animatedUnits).toHaveLength(1);
|
||||
expect(animatedUnits[0]).toHaveTextContent("春天");
|
||||
expect(animatedUnits[0].getAttribute("style")).toContain("--sd-duration: 180ms");
|
||||
expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes animation markup when a streamed response completes", async () => {
|
||||
it("does not add animation markup when a streamed response completes", () => {
|
||||
const { container, rerender } = render(
|
||||
<MarkdownTextRenderer streaming>春天</MarkdownTextRenderer>,
|
||||
);
|
||||
expect(container.querySelector("[data-sd-animate]")).toBeInTheDocument();
|
||||
expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<MarkdownTextRenderer>春天</MarkdownTextRenderer>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(container).toHaveTextContent("春天");
|
||||
expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not create one DOM node per CJK character for long responses", () => {
|
||||
@@ -456,7 +453,7 @@ describe("MarkdownTextRenderer", () => {
|
||||
<MarkdownTextRenderer streaming>{"长".repeat(6_001)}</MarkdownTextRenderer>,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll("[data-sd-animate]")).toHaveLength(1);
|
||||
expect(container.querySelector("[data-sd-animate]")).not.toBeInTheDocument();
|
||||
expect(container.querySelector("[data-nanobot-stream-unit]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock("@/components/MarkdownTextRenderer", () => ({
|
||||
<div
|
||||
data-testid="markdown-renderer"
|
||||
data-highlight-code={String(highlightCode)}
|
||||
data-streaming-layout={String(streaming)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -106,6 +107,33 @@ describe("MarkdownText", () => {
|
||||
expect(rendererMountSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("can complete without replacing the streaming renderer layout", async () => {
|
||||
const source = "A layout-stable answer";
|
||||
const { rerender } = render(
|
||||
<MarkdownText streaming preserveStreamingLayout>{source}</MarkdownText>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
|
||||
"data-streaming-layout",
|
||||
"true",
|
||||
);
|
||||
|
||||
rerender(<MarkdownText preserveStreamingLayout>{source}</MarkdownText>);
|
||||
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
|
||||
"data-streaming-layout",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
|
||||
"data-highlight-code",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("defers syntax highlighting until the final render", async () => {
|
||||
rendererSpy.mockClear();
|
||||
const largeCode = `\`\`\`ts\n${"const value = 1;\n".repeat(1_100)}\`\`\``;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
McpPresetInfo,
|
||||
@@ -298,6 +299,52 @@ describe("MessageBubble", () => {
|
||||
expect(onForkFromHere).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the assistant completion time in the former latency slot", () => {
|
||||
const completedAt = Date.UTC(2026, 6, 25, 12, 34, 56);
|
||||
const { container } = render(
|
||||
<MessageBubble
|
||||
message={{
|
||||
id: "a-completed-at",
|
||||
role: "assistant",
|
||||
content: "Finished answer",
|
||||
latencyMs: 13_000,
|
||||
completedAt,
|
||||
createdAt: Date.now(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||
const time = container.querySelector("[data-assistant-completed-at]");
|
||||
expect(time).toHaveTextContent(formatMessageEndTime(completedAt));
|
||||
expect(time).toHaveAttribute("dateTime", new Date(completedAt).toISOString());
|
||||
expect(time).toHaveAttribute("title", fmtDateTime(completedAt));
|
||||
expect(time).toHaveClass(
|
||||
"text-[11px]",
|
||||
"leading-none",
|
||||
"text-muted-foreground/70",
|
||||
"tabular-nums",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not infer completion time from the assistant creation timestamp", () => {
|
||||
const createdAt = Date.UTC(2026, 6, 25, 12, 34, 0);
|
||||
const latencyMs = 13_000;
|
||||
const { container } = render(
|
||||
<MessageBubble
|
||||
message={{
|
||||
id: "a-replayed-completion",
|
||||
role: "assistant",
|
||||
content: "Replayed answer",
|
||||
latencyMs,
|
||||
createdAt,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("[data-assistant-completed-at]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders installed CLI app mentions inside sent user messages", () => {
|
||||
const message: UIMessage = {
|
||||
id: "u-cli",
|
||||
@@ -482,6 +529,37 @@ describe("MessageBubble", () => {
|
||||
expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps assistant footer geometry mounted across stream completion", () => {
|
||||
const streaming: UIMessage = {
|
||||
id: "a-footer-stable",
|
||||
role: "assistant",
|
||||
content: "Stable answer",
|
||||
isStreaming: true,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const { container, rerender } = render(<MessageBubble message={streaming} />);
|
||||
|
||||
const reservedFooter = container.querySelector("[data-assistant-footer]");
|
||||
expect(reservedFooter).not.toBeNull();
|
||||
expect(reservedFooter).toHaveAttribute("data-state", "reserved");
|
||||
expect(reservedFooter).toHaveClass("mt-2", "min-h-8", "opacity-0");
|
||||
|
||||
rerender(
|
||||
<MessageBubble
|
||||
message={{
|
||||
...streaming,
|
||||
isStreaming: false,
|
||||
completedAt: Date.now(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const visibleFooter = container.querySelector("[data-assistant-footer]");
|
||||
expect(visibleFooter).toBe(reservedFooter);
|
||||
expect(visibleFooter).toHaveAttribute("data-state", "visible");
|
||||
expect(visibleFooter).toHaveClass("mt-2", "min-h-8", "opacity-100");
|
||||
});
|
||||
|
||||
it("does not show copy when showCopyAction is false", () => {
|
||||
const message: UIMessage = {
|
||||
id: "a-mid",
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ThreadCameraController,
|
||||
type ThreadCameraScheduler,
|
||||
type ThreadCameraViewport,
|
||||
} from "@/components/thread/thread-camera";
|
||||
|
||||
function cameraHarness(prefersReducedMotion = false) {
|
||||
let now = 0;
|
||||
let nextFrameId = 1;
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
const viewport: ThreadCameraViewport = {
|
||||
scrollTop: 0,
|
||||
};
|
||||
const scheduler: ThreadCameraScheduler = {
|
||||
request: vi.fn((callback) => {
|
||||
const id = nextFrameId;
|
||||
nextFrameId += 1;
|
||||
frames.set(id, callback);
|
||||
return id;
|
||||
}),
|
||||
cancel: vi.fn((id) => {
|
||||
frames.delete(id);
|
||||
}),
|
||||
now: () => now,
|
||||
};
|
||||
const camera = new ThreadCameraController(() => viewport, {
|
||||
scheduler,
|
||||
prefersReducedMotion: () => prefersReducedMotion,
|
||||
});
|
||||
const advance = (deltaMs: number) => {
|
||||
now += deltaMs;
|
||||
const pending = [...frames.entries()];
|
||||
frames.clear();
|
||||
for (const [, callback] of pending) callback(now);
|
||||
};
|
||||
return { camera, viewport, scheduler, frames, advance };
|
||||
}
|
||||
|
||||
describe("ThreadCameraController", () => {
|
||||
it("responds immediately, then eases out as a static target gets closer", () => {
|
||||
const { camera, viewport, advance } = cameraHarness();
|
||||
|
||||
camera.followTo(60);
|
||||
advance(16);
|
||||
const firstStep = viewport.scrollTop;
|
||||
advance(16);
|
||||
const secondStep = viewport.scrollTop - firstStep;
|
||||
advance(16);
|
||||
const thirdStep = viewport.scrollTop - firstStep - secondStep;
|
||||
|
||||
expect(firstStep).toBeGreaterThan(0);
|
||||
expect(secondStep).toBeGreaterThan(0);
|
||||
expect(thirdStep).toBeGreaterThan(0);
|
||||
expect(secondStep).toBeLessThan(firstStep);
|
||||
expect(thirdStep).toBeLessThan(secondStep);
|
||||
});
|
||||
|
||||
it("retargets an active follow without adding another loop", () => {
|
||||
const { camera, viewport, frames, advance } = cameraHarness();
|
||||
|
||||
expect(camera.followTo(100)).toBe("started");
|
||||
expect(frames).toHaveLength(1);
|
||||
advance(16);
|
||||
expect(frames).toHaveLength(1);
|
||||
|
||||
expect(camera.followTo(180)).toBe("retargeted");
|
||||
expect(frames).toHaveLength(1);
|
||||
for (let frame = 0; frame < 120; frame += 1) advance(16);
|
||||
expect(viewport.scrollTop).toBe(180);
|
||||
});
|
||||
|
||||
it("tracks repeated target growth as one monotonic camera movement", () => {
|
||||
const { camera, viewport, advance } = cameraHarness();
|
||||
|
||||
camera.followTo(80);
|
||||
advance(16);
|
||||
const first = viewport.scrollTop;
|
||||
camera.followTo(140);
|
||||
advance(16);
|
||||
const second = viewport.scrollTop;
|
||||
camera.followTo(220);
|
||||
advance(16);
|
||||
const third = viewport.scrollTop;
|
||||
|
||||
expect(first).toBeGreaterThan(0);
|
||||
expect(second).toBeGreaterThan(first);
|
||||
expect(third).toBeGreaterThan(second);
|
||||
expect(camera.isFollowing()).toBe(true);
|
||||
});
|
||||
|
||||
it("uses a faster motion profile for explicit long-distance navigation", () => {
|
||||
const follow = cameraHarness();
|
||||
const navigation = cameraHarness();
|
||||
|
||||
follow.camera.followTo(1_000);
|
||||
navigation.camera.navigateTo(1_000);
|
||||
follow.advance(16);
|
||||
navigation.advance(16);
|
||||
|
||||
expect(navigation.viewport.scrollTop).toBeGreaterThan(follow.viewport.scrollTop);
|
||||
expect(navigation.viewport.scrollTop).toBeLessThan(1_000);
|
||||
});
|
||||
|
||||
it("gives an immediate jump command priority over an active follow", () => {
|
||||
const { camera, viewport, scheduler, frames } = cameraHarness();
|
||||
|
||||
camera.followTo(240);
|
||||
expect(frames).toHaveLength(1);
|
||||
camera.jumpTo(40);
|
||||
|
||||
expect(camera.isFollowing()).toBe(false);
|
||||
expect(viewport.scrollTop).toBe(40);
|
||||
expect(scheduler.cancel).toHaveBeenCalledTimes(1);
|
||||
expect(frames).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves spatial continuity with a shorter reduced-motion chase", () => {
|
||||
const regular = cameraHarness();
|
||||
const reduced = cameraHarness(true);
|
||||
|
||||
expect(regular.camera.followTo(240)).toBe("started");
|
||||
expect(reduced.camera.followTo(240)).toBe("started");
|
||||
|
||||
regular.advance(16);
|
||||
reduced.advance(16);
|
||||
|
||||
expect(reduced.viewport.scrollTop).toBeGreaterThan(regular.viewport.scrollTop);
|
||||
expect(reduced.viewport.scrollTop).toBeLessThan(240);
|
||||
expect(reduced.camera.isFollowing()).toBe(true);
|
||||
|
||||
for (let frame = 0; frame < 60; frame += 1) reduced.advance(16);
|
||||
expect(reduced.camera.isFollowing()).toBe(false);
|
||||
expect(reduced.viewport.scrollTop).toBe(240);
|
||||
});
|
||||
});
|
||||
@@ -1073,13 +1073,59 @@ describe("ThreadComposer", () => {
|
||||
const status = screen.getByRole("status");
|
||||
expect(status).toHaveTextContent(/Running/);
|
||||
expect(status).toHaveTextContent(/2:05/);
|
||||
expect(status.parentElement).toHaveClass("composer-status-strip");
|
||||
expect(status.parentElement).toHaveAttribute("data-state", "enter");
|
||||
expect(status).toHaveClass("composer-status-drawer-content");
|
||||
expect(status.closest("[data-composer-status-drawer]")).toHaveAttribute(
|
||||
"data-state",
|
||||
"open",
|
||||
);
|
||||
expect(status.querySelector(".run-pulse-icon")).not.toBeNull();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("opens and closes the run timer through one persistent drawer", () => {
|
||||
const { container, rerender } = render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
runStartedAt={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const drawer = container.querySelector("[data-composer-status-drawer]");
|
||||
expect(drawer).not.toBeNull();
|
||||
expect(drawer).toHaveAttribute("data-state", "closed");
|
||||
expect(drawer).toHaveAttribute("aria-hidden", "true");
|
||||
|
||||
rerender(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
runStartedAt={Math.floor(Date.now() / 1000)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("[data-composer-status-drawer]")).toBe(drawer);
|
||||
expect(drawer).toHaveAttribute("data-state", "open");
|
||||
expect(drawer).not.toHaveAttribute("aria-hidden");
|
||||
const status = screen.getByRole("status");
|
||||
expect(status).toHaveClass("composer-status-drawer-content");
|
||||
|
||||
rerender(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
runStartedAt={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("[data-composer-status-drawer]")).toBe(drawer);
|
||||
expect(drawer).toHaveAttribute("data-state", "closed");
|
||||
expect(drawer).toHaveAttribute("aria-hidden", "true");
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
expect(drawer?.querySelector('[role="status"]')).toBe(status);
|
||||
});
|
||||
|
||||
it("opens an upward anchored goal panel with markdown content when expand is clicked", async () => {
|
||||
const longObjective =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz0123456789GoalTail";
|
||||
|
||||
@@ -7,6 +7,21 @@ import {
|
||||
} from "@/lib/thread-display-compat";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
const STARTED_AT = Date.UTC(2026, 6, 25, 12, 34, 0);
|
||||
|
||||
function message(
|
||||
role: UIMessage["role"],
|
||||
overrides: Partial<Omit<UIMessage, "role">> = {},
|
||||
): UIMessage {
|
||||
return {
|
||||
id: role,
|
||||
role,
|
||||
content: role,
|
||||
createdAt: STARTED_AT,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("normalizeLegacyLongTaskMessages", () => {
|
||||
it("maps legacy long_task rows to trace lines", () => {
|
||||
const legacy = {
|
||||
@@ -23,17 +38,19 @@ describe("normalizeLegacyLongTaskMessages", () => {
|
||||
});
|
||||
|
||||
it("removes model and silent-command turns without hiding concurrent replies", () => {
|
||||
const message = (
|
||||
id: string,
|
||||
role: UIMessage["role"],
|
||||
content: string,
|
||||
turnId?: string,
|
||||
): UIMessage => ({ id, role, content, createdAt: 1, turnId });
|
||||
const visible = projectWebuiThreadMessages([
|
||||
message("model", "user", "/model fast", "model-turn"),
|
||||
message("model-reply", "assistant", "Switched model preset to fast.", "model-turn"),
|
||||
message("silent", "user", "/restart", "webui-system:restart"),
|
||||
message("reply", "assistant", "This unrelated reply stays visible.", "other-turn"),
|
||||
message("user", { id: "model", content: "/model fast", turnId: "model-turn" }),
|
||||
message("assistant", {
|
||||
id: "model-reply",
|
||||
content: "Switched model preset to fast.",
|
||||
turnId: "model-turn",
|
||||
}),
|
||||
message("user", { id: "silent", content: "/restart", turnId: "webui-system:restart" }),
|
||||
message("assistant", {
|
||||
id: "reply",
|
||||
content: "This unrelated reply stays visible.",
|
||||
turnId: "other-turn",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(visible.map(({ content }) => content)).toEqual([
|
||||
@@ -47,3 +64,74 @@ describe("normalizeLegacyLongTaskMessages", () => {
|
||||
expect(deriveTitle("## Model\n- Current model: `gpt-5.5`", "New chat")).toBe("New chat");
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectWebuiThreadMessages", () => {
|
||||
it("derives replayed completion time from the matching user turn", () => {
|
||||
const firstOutputAt = STARTED_AT + 5_000;
|
||||
const latencyMs = 13_000;
|
||||
const visible = projectWebuiThreadMessages([
|
||||
message("user", { turnId: "turn-1" }),
|
||||
message("assistant", {
|
||||
turnId: "turn-1",
|
||||
createdAt: firstOutputAt,
|
||||
latencyMs,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(visible[1]?.completedAt).toBe(STARTED_AT + latencyMs);
|
||||
expect(visible[1]?.completedAt).not.toBe(firstOutputAt + latencyMs);
|
||||
});
|
||||
|
||||
it("preserves the exact completion time received for a live turn", () => {
|
||||
const completedAt = STARTED_AT + 13_000;
|
||||
const visible = projectWebuiThreadMessages([
|
||||
message("user", { turnId: "turn-1" }),
|
||||
message("assistant", {
|
||||
turnId: "turn-1",
|
||||
latencyMs: 13_000,
|
||||
completedAt,
|
||||
createdAt: STARTED_AT + 5_000,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(visible[1]?.completedAt).toBe(completedAt);
|
||||
});
|
||||
|
||||
it("does not borrow a timestamp from a different turn", () => {
|
||||
const visible = projectWebuiThreadMessages([
|
||||
message("user", { turnId: "turn-1" }),
|
||||
message("assistant", {
|
||||
turnId: "turn-2",
|
||||
latencyMs: 13_000,
|
||||
createdAt: STARTED_AT + 60_000,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(visible[1]?.completedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the nearest user start for legacy rows without turn metadata", () => {
|
||||
const visible = projectWebuiThreadMessages([
|
||||
message("user"),
|
||||
message("assistant", {
|
||||
latencyMs: 13_000,
|
||||
createdAt: STARTED_AT + 5_000,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(visible[1]?.completedAt).toBe(STARTED_AT + 13_000);
|
||||
});
|
||||
|
||||
it("does not attach a previous user turn to proactive messages", () => {
|
||||
const visible = projectWebuiThreadMessages([
|
||||
message("user"),
|
||||
message("assistant", {
|
||||
source: { kind: "cron" },
|
||||
latencyMs: 13_000,
|
||||
createdAt: STARTED_AT + 26 * 60_000,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(visible[1]?.completedAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ThreadMessages,
|
||||
unitKeysForDisplay,
|
||||
} from "@/components/thread/ThreadMessages";
|
||||
import { preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -15,6 +16,118 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("ThreadMessages", () => {
|
||||
it("does not move a mounted tail answer into offscreen rendering on the next turn", () => {
|
||||
const completed: UIMessage[] = [
|
||||
{ id: "u1", role: "user", content: "question", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "latest answer", createdAt: 2 },
|
||||
];
|
||||
const { rerender } = render(
|
||||
<ThreadMessages messages={completed} isStreaming={false} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("latest answer").closest(".thread-render-unit")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<ThreadMessages
|
||||
messages={[
|
||||
...completed,
|
||||
{ id: "u2", role: "user", content: "next question", createdAt: 3 },
|
||||
]}
|
||||
isStreaming
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("latest answer").closest(".thread-render-unit")).toBeNull();
|
||||
});
|
||||
|
||||
it("still defers historical non-tail answers on their initial render", () => {
|
||||
render(
|
||||
<ThreadMessages
|
||||
messages={[
|
||||
{ id: "u1", role: "user", content: "old question", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "historical answer", createdAt: 2 },
|
||||
{ id: "u2", role: "user", content: "latest question", createdAt: 3 },
|
||||
]}
|
||||
isStreaming={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("historical answer").closest(".thread-render-unit")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("preserves an answer's markdown tree across completion and the next prompt", async () => {
|
||||
await preloadMarkdownText();
|
||||
const turnId = "turn-1";
|
||||
const streaming: UIMessage[] = [
|
||||
{
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "question",
|
||||
createdAt: 1,
|
||||
turnId,
|
||||
turnPhase: "prompt",
|
||||
},
|
||||
{
|
||||
id: "live-answer",
|
||||
role: "assistant",
|
||||
content: "stable final answer",
|
||||
createdAt: 2,
|
||||
isStreaming: true,
|
||||
turnId,
|
||||
turnPhase: "answer",
|
||||
},
|
||||
];
|
||||
const { container, rerender } = render(
|
||||
<ThreadMessages messages={streaming} isStreaming />,
|
||||
);
|
||||
await waitFor(
|
||||
() => expect(container.querySelector(".markdown-content")).not.toBeNull(),
|
||||
{ timeout: 3_000 },
|
||||
);
|
||||
const paragraph = screen.getByText("stable final answer").closest("p");
|
||||
expect(paragraph).not.toBeNull();
|
||||
|
||||
rerender(
|
||||
<ThreadMessages
|
||||
messages={[
|
||||
streaming[0],
|
||||
{
|
||||
...streaming[1],
|
||||
id: "canonical-answer",
|
||||
isStreaming: false,
|
||||
},
|
||||
]}
|
||||
isStreaming={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("stable final answer").closest("p")).toBe(paragraph);
|
||||
|
||||
rerender(
|
||||
<ThreadMessages
|
||||
messages={[
|
||||
streaming[0],
|
||||
{
|
||||
...streaming[1],
|
||||
id: "canonical-answer",
|
||||
isStreaming: false,
|
||||
},
|
||||
{
|
||||
id: "u2",
|
||||
role: "user",
|
||||
content: "next question",
|
||||
createdAt: 3,
|
||||
turnId: "turn-2",
|
||||
turnPhase: "prompt",
|
||||
},
|
||||
]}
|
||||
isStreaming
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("stable final answer").closest("p")).toBe(paragraph);
|
||||
});
|
||||
|
||||
it("offers a follow-up action for text selected within one completed answer", async () => {
|
||||
const onQuoteSelection = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ThreadMotionCoordinator,
|
||||
type ThreadMotionGeometry,
|
||||
type ThreadMotionScheduler,
|
||||
} from "@/components/thread/thread-motion";
|
||||
|
||||
function motionHarness(initial?: Partial<ThreadMotionGeometry>) {
|
||||
let nextFrameId = 1;
|
||||
let cameraFollowing = false;
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
let geometry: ThreadMotionGeometry = {
|
||||
scrollTop: 400,
|
||||
scrollHeight: 1_900,
|
||||
clientHeight: 500,
|
||||
maxScrollTop: 1_400,
|
||||
composerHeight: 120,
|
||||
promptTop: 404,
|
||||
...initial,
|
||||
};
|
||||
const scheduler: ThreadMotionScheduler = {
|
||||
request: vi.fn((callback) => {
|
||||
const id = nextFrameId;
|
||||
nextFrameId += 1;
|
||||
frames.set(id, callback);
|
||||
return id;
|
||||
}),
|
||||
cancel: vi.fn((id) => {
|
||||
frames.delete(id);
|
||||
}),
|
||||
};
|
||||
const camera = {
|
||||
cancel: vi.fn(() => {
|
||||
cameraFollowing = false;
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
jumpTo: vi.fn(),
|
||||
followTo: vi.fn(() => "started" as const),
|
||||
isFollowing: vi.fn(() => cameraFollowing),
|
||||
navigateTo: vi.fn(() => {
|
||||
cameraFollowing = true;
|
||||
return "started" as const;
|
||||
}),
|
||||
};
|
||||
const onGeometry = vi.fn();
|
||||
const onAutoFollow = vi.fn();
|
||||
const measure = vi.fn(() => geometry);
|
||||
const coordinator = new ThreadMotionCoordinator({
|
||||
camera,
|
||||
measure,
|
||||
onGeometry,
|
||||
onAutoFollow,
|
||||
scheduler,
|
||||
});
|
||||
const advanceFrame = () => {
|
||||
const pending = [...frames.entries()];
|
||||
frames.clear();
|
||||
for (const [, callback] of pending) callback(16);
|
||||
};
|
||||
const setGeometry = (next: Partial<ThreadMotionGeometry>) => {
|
||||
const updated = { ...geometry, ...next };
|
||||
geometry = {
|
||||
...updated,
|
||||
maxScrollTop: next.maxScrollTop
|
||||
?? Math.max(0, updated.scrollHeight - updated.clientHeight),
|
||||
};
|
||||
};
|
||||
const setCameraFollowing = (following: boolean) => {
|
||||
cameraFollowing = following;
|
||||
};
|
||||
|
||||
return {
|
||||
camera,
|
||||
coordinator,
|
||||
frames,
|
||||
measure,
|
||||
onAutoFollow,
|
||||
onGeometry,
|
||||
scheduler,
|
||||
advanceFrame,
|
||||
setCameraFollowing,
|
||||
setGeometry,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ThreadMotionCoordinator", () => {
|
||||
it("coalesces discrete invalidations into one authoritative geometry frame", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
frames,
|
||||
measure,
|
||||
scheduler,
|
||||
advanceFrame,
|
||||
} = motionHarness();
|
||||
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
coordinator.invalidateGeometry();
|
||||
coordinator.invalidateGeometry();
|
||||
|
||||
expect(scheduler.request).toHaveBeenCalledTimes(1);
|
||||
expect(frames).toHaveLength(1);
|
||||
expect(measure).not.toHaveBeenCalled();
|
||||
|
||||
advanceFrame();
|
||||
|
||||
expect(measure).toHaveBeenCalledTimes(1);
|
||||
expect(measure).toHaveBeenCalledWith("prompt-1");
|
||||
expect(camera.jumpTo).toHaveBeenCalledWith(404);
|
||||
expect(camera.followTo).toHaveBeenCalledWith(1_400);
|
||||
expect(coordinator.snapshot()).toMatchObject({
|
||||
mode: "follow-output",
|
||||
promptPositioned: true,
|
||||
measurementPending: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("retargets repeated output growth without restarting camera ownership", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
setGeometry,
|
||||
} = motionHarness();
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
camera.cancel.mockClear();
|
||||
camera.followTo.mockClear();
|
||||
|
||||
setGeometry({ scrollTop: 1_400, scrollHeight: 1_928 });
|
||||
coordinator.invalidateGeometry();
|
||||
coordinator.invalidateGeometry();
|
||||
advanceFrame();
|
||||
setGeometry({ scrollTop: 1_410, scrollHeight: 1_944 });
|
||||
coordinator.invalidateGeometry();
|
||||
advanceFrame();
|
||||
|
||||
expect(camera.followTo.mock.calls).toEqual([[1_428], [1_444]]);
|
||||
expect(camera.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"geometry-before-completion",
|
||||
"completion-before-geometry",
|
||||
] as const)("keeps following final layout for %s ordering", (ordering) => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
setGeometry,
|
||||
} = motionHarness();
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
camera.cancel.mockClear();
|
||||
camera.followTo.mockClear();
|
||||
|
||||
const applyFinalGeometry = () => {
|
||||
setGeometry({
|
||||
scrollTop: 1_400,
|
||||
scrollHeight: 2_400,
|
||||
});
|
||||
coordinator.invalidateGeometry();
|
||||
};
|
||||
if (ordering === "geometry-before-completion") applyFinalGeometry();
|
||||
coordinator.completeTurn();
|
||||
if (ordering === "completion-before-geometry") applyFinalGeometry();
|
||||
|
||||
expect(coordinator.snapshot().mode).toBe("follow-completion");
|
||||
expect(camera.cancel).not.toHaveBeenCalled();
|
||||
advanceFrame();
|
||||
expect(camera.followTo).toHaveBeenLastCalledWith(1_900);
|
||||
|
||||
setGeometry({
|
||||
scrollTop: 1_900,
|
||||
scrollHeight: 2_450,
|
||||
});
|
||||
coordinator.invalidateGeometry();
|
||||
advanceFrame();
|
||||
expect(camera.followTo).toHaveBeenLastCalledWith(1_950);
|
||||
});
|
||||
|
||||
it("keeps turn identity stable when canonical replay replaces DOM ids", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
measure,
|
||||
advanceFrame,
|
||||
setGeometry,
|
||||
} = motionHarness();
|
||||
coordinator.updateTurn({
|
||||
id: "turn-stable",
|
||||
promptId: "optimistic-prompt",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
camera.jumpTo.mockClear();
|
||||
measure.mockClear();
|
||||
|
||||
setGeometry({ scrollHeight: 2_020, promptTop: 120 });
|
||||
coordinator.updateTurn({
|
||||
id: "turn-stable",
|
||||
promptId: "canonical-prompt",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
|
||||
expect(measure).toHaveBeenCalledWith(null);
|
||||
expect(camera.jumpTo).not.toHaveBeenCalled();
|
||||
expect(camera.followTo).toHaveBeenLastCalledWith(1_520);
|
||||
});
|
||||
|
||||
it("continues from the bottom when a running turn is restored before prompt identity", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
} = motionHarness({
|
||||
scrollTop: 1_400,
|
||||
});
|
||||
|
||||
coordinator.updateTurn({
|
||||
id: "turn-restored",
|
||||
promptId: null,
|
||||
hasOutput: true,
|
||||
entry: "restored",
|
||||
});
|
||||
advanceFrame();
|
||||
|
||||
expect(camera.jumpTo).not.toHaveBeenCalled();
|
||||
expect(camera.followTo).toHaveBeenCalledWith(1_400);
|
||||
expect(coordinator.snapshot()).toMatchObject({
|
||||
mode: "follow-output",
|
||||
promptPositioned: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("continues measuring while browsing history but does not steal the viewport", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
onGeometry,
|
||||
advanceFrame,
|
||||
setGeometry,
|
||||
} = motionHarness();
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
camera.followTo.mockClear();
|
||||
onGeometry.mockClear();
|
||||
|
||||
coordinator.takeUserControl();
|
||||
setGeometry({ scrollTop: 200, scrollHeight: 2_100 });
|
||||
coordinator.invalidateGeometry();
|
||||
advanceFrame();
|
||||
|
||||
expect(onGeometry).toHaveBeenCalledTimes(1);
|
||||
expect(camera.followTo).not.toHaveBeenCalled();
|
||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||
|
||||
coordinator.resumeAutoFollow();
|
||||
advanceFrame();
|
||||
expect(camera.followTo).toHaveBeenCalledWith(1_600);
|
||||
});
|
||||
|
||||
it("treats scroll events as observations until explicit user intent takes control", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
setGeometry,
|
||||
} = motionHarness({
|
||||
scrollTop: 700,
|
||||
scrollHeight: 1_200,
|
||||
clientHeight: 500,
|
||||
maxScrollTop: 700,
|
||||
});
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: false,
|
||||
});
|
||||
advanceFrame();
|
||||
camera.jumpTo.mockClear();
|
||||
|
||||
setGeometry({
|
||||
scrollTop: 700,
|
||||
scrollHeight: 1_280,
|
||||
});
|
||||
expect(coordinator.observeScroll(false)).toBe("automatic");
|
||||
expect(coordinator.snapshot().mode).toBe("anchor-prompt");
|
||||
advanceFrame();
|
||||
expect(camera.jumpTo).toHaveBeenCalledWith(780);
|
||||
|
||||
coordinator.takeUserControl();
|
||||
expect(coordinator.observeScroll(false)).toBe("user");
|
||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||
|
||||
expect(coordinator.observeScroll(true)).toBe("automatic");
|
||||
expect(coordinator.snapshot().mode).toBe("anchor-prompt");
|
||||
});
|
||||
|
||||
it("preserves history browsing when an active turn is cleared", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
} = motionHarness();
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
coordinator.takeUserControl();
|
||||
camera.followTo.mockClear();
|
||||
|
||||
coordinator.updateTurn({
|
||||
id: null,
|
||||
promptId: null,
|
||||
hasOutput: false,
|
||||
});
|
||||
advanceFrame();
|
||||
|
||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||
expect(camera.followTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps history navigation independent from active turn completion", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
} = motionHarness();
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
coordinator.navigateHistoryTo(900);
|
||||
camera.cancel.mockClear();
|
||||
|
||||
coordinator.completeTurn();
|
||||
|
||||
expect(camera.cancel).not.toHaveBeenCalled();
|
||||
expect(coordinator.snapshot().mode).toBe("navigating-history");
|
||||
});
|
||||
|
||||
it("moves from rail navigation to history browsing on user intent", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
setCameraFollowing,
|
||||
} = motionHarness();
|
||||
|
||||
coordinator.handleUserScrollIntent(false);
|
||||
expect(coordinator.snapshot().mode).toBe("idle");
|
||||
expect(camera.cancel).not.toHaveBeenCalled();
|
||||
|
||||
coordinator.navigateHistoryTo(900);
|
||||
expect(camera.navigateTo).toHaveBeenCalledWith(900);
|
||||
expect(coordinator.snapshot().mode).toBe("navigating-history");
|
||||
expect(coordinator.observeScroll(false)).toBe("navigation");
|
||||
|
||||
camera.cancel.mockClear();
|
||||
coordinator.handleUserScrollIntent(false);
|
||||
expect(camera.cancel).toHaveBeenCalledTimes(1);
|
||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||
expect(coordinator.observeScroll(false)).toBe("user");
|
||||
|
||||
coordinator.navigateHistoryTo(700);
|
||||
camera.cancel.mockClear();
|
||||
coordinator.takeUserControl();
|
||||
expect(camera.cancel).toHaveBeenCalledTimes(1);
|
||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||
|
||||
camera.cancel.mockClear();
|
||||
coordinator.takeUserControl();
|
||||
expect(camera.cancel).not.toHaveBeenCalled();
|
||||
|
||||
coordinator.navigateHistoryTo(600);
|
||||
setCameraFollowing(false);
|
||||
expect(coordinator.observeScroll(false)).toBe("navigation");
|
||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||
});
|
||||
|
||||
it("pins a waiting prompt to the exact lower boundary across all layout changes", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
setGeometry,
|
||||
} = motionHarness({
|
||||
scrollHeight: 930,
|
||||
clientHeight: 500,
|
||||
maxScrollTop: 430,
|
||||
});
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: false,
|
||||
});
|
||||
advanceFrame();
|
||||
expect(camera.jumpTo).toHaveBeenLastCalledWith(430);
|
||||
|
||||
setGeometry({
|
||||
composerHeight: 148,
|
||||
scrollHeight: 958,
|
||||
promptTop: 404,
|
||||
});
|
||||
coordinator.invalidateGeometry();
|
||||
advanceFrame();
|
||||
|
||||
expect(camera.jumpTo.mock.calls).toEqual([[430], [458]]);
|
||||
expect(camera.followTo).not.toHaveBeenCalled();
|
||||
expect(coordinator.snapshot().mode).toBe("anchor-prompt");
|
||||
|
||||
setGeometry({
|
||||
scrollTop: 458,
|
||||
scrollHeight: 990,
|
||||
});
|
||||
coordinator.invalidateGeometry();
|
||||
advanceFrame();
|
||||
|
||||
expect(camera.jumpTo.mock.calls).toEqual([[430], [458], [490]]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import { ThreadCameraController } from "@/components/thread/thread-camera";
|
||||
import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||
import { CLI_APPS_CHANGED_EVENT } from "@/lib/cli-app-events";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
@@ -18,6 +19,7 @@ function makeClient() {
|
||||
(modelName: string | null, modelPreset?: string | null) => void
|
||||
>();
|
||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||
const runStartedAtByChatId = new Map<string, number>();
|
||||
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
|
||||
return {
|
||||
status: "open" as const,
|
||||
@@ -31,7 +33,7 @@ function makeClient() {
|
||||
runtimeModelHandlers.delete(handler);
|
||||
};
|
||||
},
|
||||
getRunStartedAt: () => null,
|
||||
getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null,
|
||||
getGoalState: (chatId: string) => goalStateByChatId.get(chatId),
|
||||
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
|
||||
let handlers = chatHandlers.get(chatId);
|
||||
@@ -60,6 +62,18 @@ function makeClient() {
|
||||
for (const h of errorHandlers) h(err);
|
||||
},
|
||||
_emitChat(chatId: string, ev: import("@/lib/types").InboundEvent) {
|
||||
if (
|
||||
ev.event === "goal_status"
|
||||
&& ev.status === "running"
|
||||
&& typeof ev.started_at === "number"
|
||||
) {
|
||||
runStartedAtByChatId.set(chatId, ev.started_at);
|
||||
} else if (
|
||||
(ev.event === "goal_status" && ev.status === "idle")
|
||||
|| ev.event === "turn_end"
|
||||
) {
|
||||
runStartedAtByChatId.delete(chatId);
|
||||
}
|
||||
if (ev.event === "goal_state") {
|
||||
goalStateByChatId.set(chatId, ev.goal_state);
|
||||
}
|
||||
@@ -142,6 +156,36 @@ function httpJson(body: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
interface ThreadResizeObserverInstance {
|
||||
elements: Element[];
|
||||
callback: ResizeObserverCallback;
|
||||
}
|
||||
|
||||
function stubThreadResizeObserver() {
|
||||
const original = globalThis.ResizeObserver;
|
||||
const observers: ThreadResizeObserverInstance[] = [];
|
||||
class MockResizeObserver {
|
||||
elements: Element[] = [];
|
||||
callback: ResizeObserverCallback;
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
observers.push(this);
|
||||
}
|
||||
|
||||
observe(element: Element) {
|
||||
this.elements.push(element);
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
return {
|
||||
observers,
|
||||
restore: () => vi.stubGlobal("ResizeObserver", original),
|
||||
};
|
||||
}
|
||||
|
||||
function modelSettings(model: string, provider: string): SettingsPayload {
|
||||
return {
|
||||
agent: {
|
||||
@@ -1351,6 +1395,40 @@ describe("ThreadShell", () => {
|
||||
await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("restores the stop control when returning to a running chat", async () => {
|
||||
const client = makeClient();
|
||||
const view = (chatId: string) => wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session(chatId)}
|
||||
title={`Chat ${chatId}`}
|
||||
onToggleSidebar={() => {}}
|
||||
onNewChat={() => {}}
|
||||
/>,
|
||||
);
|
||||
const { rerender } = render(view("chat-a"));
|
||||
|
||||
await act(async () => {
|
||||
client._emitChat("chat-a", {
|
||||
event: "goal_status",
|
||||
chat_id: "chat-a",
|
||||
status: "running",
|
||||
started_at: Date.now() / 1000,
|
||||
});
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
rerender(view("chat-b"));
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "Stop response" })).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
rerender(view("chat-a"));
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps live fork replies when a canonical refresh is missing an earlier assistant answer", async () => {
|
||||
const client = makeClient();
|
||||
let historyCalls = 0;
|
||||
@@ -1526,9 +1604,7 @@ describe("ThreadShell", () => {
|
||||
|
||||
it("does not scroll again when canonical history refreshes after a session update", async () => {
|
||||
const client = makeClient();
|
||||
const scrollTo = vi.fn();
|
||||
const originalScrollTo = HTMLElement.prototype.scrollTo;
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
|
||||
let historyCalls = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -1569,13 +1645,13 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument());
|
||||
await waitFor(() => expect(scrollTo).toHaveBeenCalled());
|
||||
await waitFor(() => expect(jumpTo).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
jumpTo.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
client._emitSessionUpdate("chat-a");
|
||||
@@ -1583,9 +1659,112 @@ describe("ThreadShell", () => {
|
||||
|
||||
await waitFor(() => expect(historyCalls).toBe(2));
|
||||
await waitFor(() => expect(screen.getByText("canonical answer")).toBeInTheDocument());
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
expect(jumpTo).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
HTMLElement.prototype.scrollTo = originalScrollTo;
|
||||
jumpTo.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps an active completion follow alive while canonical history refreshes", async () => {
|
||||
const resizeObserver = stubThreadResizeObserver();
|
||||
const client = makeClient();
|
||||
let historyCalls = 0;
|
||||
const pendingRefresh = new Promise<ReturnType<typeof httpJson>>(() => {});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Achat-follow/webui-thread")) {
|
||||
historyCalls += 1;
|
||||
if (historyCalls > 1) return pendingRefresh;
|
||||
return httpJson(
|
||||
transcriptFromSimpleMessages([
|
||||
{ role: "user", content: "old question" },
|
||||
{ role: "assistant", content: "old answer" },
|
||||
]),
|
||||
);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const requestFrame = vi.spyOn(window, "requestAnimationFrame");
|
||||
const cancelFrame = vi.spyOn(window, "cancelAnimationFrame");
|
||||
try {
|
||||
const { container } = render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-follow")}
|
||||
title="Chat follow"
|
||||
onToggleSidebar={() => {}}
|
||||
onNewChat={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument());
|
||||
const scroller = container.querySelector(".thread-viewport-scrollbar") as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2_000 },
|
||||
clientHeight: { configurable: true, value: 500 },
|
||||
scrollTop: { configurable: true, writable: true, value: 1_500 },
|
||||
scrollTo: {
|
||||
configurable: true,
|
||||
value: ({ top }: ScrollToOptions) => {
|
||||
if (typeof top === "number") scroller.scrollTop = top;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "new question" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(screen.getByText("new question")).toBeInTheDocument());
|
||||
|
||||
await act(async () => {
|
||||
client._emitChat("chat-follow", {
|
||||
event: "delta",
|
||||
chat_id: "chat-follow",
|
||||
text: "new answer",
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText("new answer")).toBeInTheDocument());
|
||||
|
||||
requestFrame.mockImplementation(() => 9_001);
|
||||
cancelFrame.mockImplementation(() => undefined);
|
||||
cancelFrame.mockClear();
|
||||
Object.defineProperty(scroller, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 2_120,
|
||||
});
|
||||
const messageContent = screen.getByTestId("thread-message-region").firstElementChild;
|
||||
const contentObserver = resizeObserver.observers.find(
|
||||
(observer) => observer.elements.includes(messageContent!),
|
||||
);
|
||||
expect(contentObserver).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
contentObserver!.callback([], contentObserver as unknown as ResizeObserver);
|
||||
});
|
||||
expect(requestFrame).toHaveBeenCalled();
|
||||
cancelFrame.mockClear();
|
||||
|
||||
act(() => {
|
||||
client._emitSessionUpdate("chat-follow", "thread");
|
||||
});
|
||||
|
||||
expect(cancelFrame).not.toHaveBeenCalledWith(9_001);
|
||||
expect(historyCalls).toBe(2);
|
||||
} finally {
|
||||
requestFrame.mockRestore();
|
||||
cancelFrame.mockRestore();
|
||||
resizeObserver.restore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1649,12 +1828,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText("loaded answer")).toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 1800,
|
||||
behavior: "auto",
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(scroller.scrollTop).toBe(1800));
|
||||
});
|
||||
|
||||
it("opens slash commands on the blank welcome page", async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1569,6 +1569,33 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[0].turnPhase).toBe("user");
|
||||
});
|
||||
|
||||
it("returns the submitted turn identity used by the optimistic row and wire frame", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-submitted-turn", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
|
||||
let submitted: ReturnType<typeof result.current.send> = null;
|
||||
act(() => {
|
||||
submitted = result.current.send("bind the camera");
|
||||
});
|
||||
|
||||
expect(submitted).not.toBeNull();
|
||||
expect(submitted?.sideChannel).toBe(false);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
id: submitted?.userMessageId,
|
||||
turnId: submitted?.turnId,
|
||||
role: "user",
|
||||
});
|
||||
expect(fake.client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-submitted-turn",
|
||||
"bind the camera",
|
||||
undefined,
|
||||
expect.objectContaining({ turnId: submitted?.turnId }),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds optimistic user file attachments as media", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-file-send", EMPTY_MESSAGES), {
|
||||
@@ -2097,30 +2124,39 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("stamps latency on the last assistant bubble from turn_end", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-lat", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-lat", {
|
||||
event: "delta",
|
||||
chat_id: "chat-lat",
|
||||
text: "Hi",
|
||||
it("stamps completion time and latency on the last assistant bubble from turn_end", () => {
|
||||
const completedAt = Date.UTC(2026, 6, 25, 12, 34, 56);
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(completedAt);
|
||||
try {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-lat", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-lat", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-lat",
|
||||
latency_ms: 2400,
|
||||
act(() => {
|
||||
fake.emit("chat-lat", {
|
||||
event: "delta",
|
||||
chat_id: "chat-lat",
|
||||
text: "Hi",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const lastAssistant = [...result.current.messages].reverse().find((m) => m.role === "assistant");
|
||||
expect(lastAssistant?.latencyMs).toBe(2400);
|
||||
act(() => {
|
||||
fake.emit("chat-lat", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-lat",
|
||||
latency_ms: 2400,
|
||||
});
|
||||
});
|
||||
|
||||
const lastAssistant = [...result.current.messages]
|
||||
.reverse()
|
||||
.find((m) => m.role === "assistant");
|
||||
expect(lastAssistant?.latencyMs).toBe(2400);
|
||||
expect(lastAssistant?.completedAt).toBe(completedAt);
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("tracks goal_status running and clears on idle", () => {
|
||||
@@ -2130,6 +2166,7 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
|
||||
expect(result.current.runStartedAt).toBeNull();
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-g", {
|
||||
@@ -2140,6 +2177,7 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
expect(result.current.runStartedAt).toBe(1700);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-g", {
|
||||
@@ -2149,6 +2187,7 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
expect(result.current.runStartedAt).toBeNull();
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("clears runStartedAt on turn_end even without idle", () => {
|
||||
@@ -2166,6 +2205,7 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
expect(result.current.runStartedAt).toBe(1700);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-g", {
|
||||
@@ -2174,6 +2214,7 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
expect(result.current.runStartedAt).toBeNull();
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("restores runStartedAt after switching away and back when goal_status was recorded without a subscriber", () => {
|
||||
@@ -2195,9 +2236,11 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
expect(result.current.runStartedAt).toBe(4242);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
|
||||
rerender({ chatId: "chat-b" });
|
||||
expect(result.current.runStartedAt).toBeNull();
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-a", {
|
||||
@@ -2210,6 +2253,7 @@ describe("useNanobotStream", () => {
|
||||
|
||||
rerender({ chatId: "chat-a" });
|
||||
expect(result.current.runStartedAt).toBe(9001);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it("tracks goal_state per chat and restores after switching sessions", () => {
|
||||
|
||||
Reference in New Issue
Block a user