Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf13d49ed6
|
||
|
|
4b5b22c7d4
|
||
|
|
89c769c806
|
||
|
|
a91394f71d
|
||
|
|
3baae016e1
|
||
|
|
3ef0bbaad3
|
||
|
|
ac0d4ce623
|
||
|
|
ec32bca644
|
||
|
|
5a87eec28d
|
||
|
|
10c82a3c7b
|
||
|
|
3510582c8f
|
||
|
|
ede91887f7
|
||
|
|
6bad293b29
|
||
|
|
4058b4b018
|
||
|
|
8590792b50
|
||
|
|
5ce5a62dff
|
||
|
|
9ddde6c870
|
||
|
|
a13e437823
|
||
|
|
89806982a9
|
||
|
|
6a095cb102
|
||
|
|
ad36932c9f
|
||
|
|
af14f4a229
|
||
|
|
5b00822551
|
||
|
|
1749d2369e
|
No files matched your search
@@ -1,7 +1,8 @@
|
|||||||
# Checks on every push to main and dev and every pull request: formatting, lints, tests, the
|
# Checks on every push to main and dev and every pull request: formatting, lints, tests, the
|
||||||
# page's scripts, the pins (tokens, CodeMirror), the public-repository hygiene -- and the app
|
# page's scripts, the pins (tokens, CodeMirror, xterm), the public-repository hygiene, the page
|
||||||
# started for real, on X11 and on Wayland, to prove it gets as far as its window. The packages
|
# itself under a headless browser (tests/ui) -- and the app started for real, on X11 and on
|
||||||
# are built only by release.yml, from a tag.
|
# Wayland, to prove it gets as far as its window. The packages are built only by release.yml,
|
||||||
|
# from a tag.
|
||||||
name: ci
|
name: ci
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -40,11 +41,32 @@ jobs:
|
|||||||
run: cargo test --locked
|
run: cargo test --locked
|
||||||
- name: The page's scripts parse, and none sets HTML
|
- name: The page's scripts parse, and none sets HTML
|
||||||
run: scripts/check-ui.sh
|
run: scripts/check-ui.sh
|
||||||
- name: The design tokens and CodeMirror are the server's, as pinned
|
- name: The design tokens, CodeMirror and xterm are the server's, as pinned
|
||||||
run: |
|
run: |
|
||||||
scripts/fetch-tokens.sh
|
scripts/fetch-tokens.sh
|
||||||
scripts/fetch-vendor.sh
|
scripts/fetch-vendor.sh
|
||||||
|
|
||||||
|
# The page (ui/) under a headless Chromium, against a stand-in for the app's bridge
|
||||||
|
# (tests/ui/mock-tauri.js) with the server's answers as a table: nothing is built and no server
|
||||||
|
# is started. Playwright is the library only, installed for the run and not a dependency of
|
||||||
|
# the repository (it has no package.json), with the Chromium it was made for.
|
||||||
|
ui:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: Playwright and its Chromium
|
||||||
|
run: |
|
||||||
|
npm install --no-save --no-package-lock --prefix "$RUNNER_TEMP/pw" playwright-core@1.65.0
|
||||||
|
node "$RUNNER_TEMP/pw/node_modules/playwright-core/cli.js" install --with-deps chromium
|
||||||
|
- name: The page under a headless browser
|
||||||
|
env:
|
||||||
|
NODE_PATH: ${{ runner.temp }}/pw/node_modules
|
||||||
|
run: node tests/ui/run.mjs
|
||||||
|
|
||||||
# The app started for real: it must still be running after 25 seconds (timeout's 124), and its
|
# The app started for real: it must still be running after 25 seconds (timeout's 124), and its
|
||||||
# log must not say it stopped. Once under Xvfb (X11) and once under a headless Weston (Wayland,
|
# log must not say it stopped. Once under Xvfb (X11) and once under a headless Weston (Wayland,
|
||||||
# GTK's Wayland backend forced), each with a session bus (the tray and the notifications want
|
# GTK's Wayland backend forced), each with a session bus (the tray and the notifications want
|
||||||
|
|||||||
+147
-1
@@ -4,7 +4,153 @@ All notable changes to LLeMbas Desktop. The format follows
|
|||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
||||||
[Semantic Versioning](https://semver.org/).
|
[Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
## [Unreleased]
|
## [1.0.0-beta.6] — 2026-11-10
|
||||||
|
|
||||||
|
LLeMbas CLI 2.0.0-beta.6 inside, the look and words of LLeMbas 2.0.0-beta.8.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Approvals show again.** An agent asking to run a command, edit a file or carry out a plan
|
||||||
|
stopped with "Waiting for you to allow bash…" and no card to answer it: drawing the card's
|
||||||
|
buttons failed, and the space for it stayed empty. Stopping and asking again gave the same.
|
||||||
|
Now the card shows, with the command in the box to correct before it runs (it used to show the
|
||||||
|
words "Run a command" there instead), what the model says it is for, why it is asking, and which
|
||||||
|
subagent asks when one does. A card already on the screen is kept as it is while the reply goes
|
||||||
|
on, so a command being corrected or an option picked is no longer lost when the server sends
|
||||||
|
the list again; a card that cannot be drawn says why instead of leaving an empty space.
|
||||||
|
- **Subagents after they are switched on.** The app read which models a new chat is offered
|
||||||
|
subagents on only when it signed in, so subagents switched on (or a model added) on the server
|
||||||
|
while the app stayed open in the tray never showed on a new chat until it was restarted. A new
|
||||||
|
chat now asks the server again. (An agent chat has no Subagents setting: its subagents run on
|
||||||
|
the computer, and the server offers none for it.)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Terminal.** An agent chat has a Terminal panel: a shell on the computer the chat works on, in
|
||||||
|
its folder, beside the chat (over it in a narrow window). A new agent chat offers it as soon as
|
||||||
|
the folder is chosen, before the first message -- and the chat that message makes carries on in
|
||||||
|
the same shell. An open agent chat opens its own from the header, the same shell the web
|
||||||
|
interface shows. **Send to chat** puts the selection, or the last command and its output, into
|
||||||
|
the message box to read before sending; the panel copies them too, and steps between commands
|
||||||
|
where the shell marks them. The terminal and the activity panel take turns beside the chat;
|
||||||
|
closing either keeps the shell. Offered where the computer allows a terminal (needs the server's
|
||||||
|
2.0.0-beta.8). The app opens the connection itself, with its sign-in: the page never holds it.
|
||||||
|
"Continue in a terminal" is now in the chat's menu only.
|
||||||
|
- **What a reply is doing, the same way everywhere.** The line under a reply being written now
|
||||||
|
says it in the words every LLeMbas client uses (harness spec 3.4.0): queued, waiting for a
|
||||||
|
model and why, loading it, reading the prompt (with how far), thinking, writing (with its
|
||||||
|
speed), running a tool and what it runs, waiting for you, waiting for subagents, compacting,
|
||||||
|
retrying (counting down), stopped or failed -- each with how long it has lasted, kept moving
|
||||||
|
every second. From a server that sends no such line (before 2.0.0-beta.8), the app shows the
|
||||||
|
server's own words as before. In every language the server has.
|
||||||
|
- **Subagents, the whole tree** (harness spec 3.4.0, needs the server's 2.0.0-beta.8). The
|
||||||
|
activity panel shows the main agent and every subagent under the one it reports to: its name and
|
||||||
|
id, its position, what it is doing right now, how full its context is, its state, model and
|
||||||
|
mode (with what it may ask for), its messages in the room and the last one, how many slots are
|
||||||
|
still open, and the ones that ended -- dimmed, with how they ended. Any subagent's conversation
|
||||||
|
opens read-only, live while it works and after it ended, and so do its findings; a working one
|
||||||
|
can be stopped (it and those under it). An agent chat's tree comes from its computer; while that
|
||||||
|
computer is away the panel shows the tree as it last was and says the rest is kept there.
|
||||||
|
- **Subagents for agent chats.** A new agent chat offers the Subagents setting where the server
|
||||||
|
says this computer can run them, and an open agent chat where the server offers it for that
|
||||||
|
chat.
|
||||||
|
- **A subagent's approval names it**: "Subagent parser (s1), reviews the parser, asks:".
|
||||||
|
- `tests/ui/`: the page under a headless browser against a stand-in for the app's bridge, with
|
||||||
|
the server's answers as a table (`node tests/ui/run.mjs`, needs Playwright and a Chromium). CI
|
||||||
|
runs it on every push (the `ui` job), with Playwright and its Chromium installed for the run.
|
||||||
|
|
||||||
|
## [1.0.0-beta.5] — 2026-11-09
|
||||||
|
|
||||||
|
Carries LLeMbas CLI 2.0.0-beta.5 and the look of LLeMbas 2.0.0-beta.7, which the Subagents
|
||||||
|
setting and the panel's compaction need.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Subagents**, by that name everywhere. The chip under the message box says **Subagents** with the
|
||||||
|
least and the most agents a chat runs ("Subagents 1–4"), and opens one slider with two handles,
|
||||||
|
**Min** and **Max**, from 1 to the server's cap -- Min never passes Max, and both move with the
|
||||||
|
arrow keys as well. The numbers count the chat's own agent: **1 is the agent alone**, with no
|
||||||
|
subagents; Max 4 is the agent and up to 3 subagents. The chip shows where the
|
||||||
|
server offers subagents for the chat (for a new chat, for its model), and the most is the
|
||||||
|
server's cap. Where the chat's model serves one request at a time, it says that subagents need
|
||||||
|
another model, on another server, named in the chat. Subagents work on the chat's own model
|
||||||
|
unless you name another one in the chat -- there is no setting for a subagent's model. A
|
||||||
|
scheduled task's field is **Subagents** too. Needs LLeMbas 2.0.0-beta.7 for the server's word on
|
||||||
|
all of this; with an older one the app guesses as before.
|
||||||
|
- **The activity panel, reshaped.** A button in the chat's header opens it at any time, and the
|
||||||
|
line above the message box shows only while something runs ("3 running · 1 queued · 2
|
||||||
|
finished"). An agent chat's **Environment** (its folder, and where it is) comes first. **Context**
|
||||||
|
says how far it is to the automatic compaction ("80.6k until auto-compact") beside a **Compact**
|
||||||
|
button that summarises the earlier turns now (greyed while there is nothing to summarise). **Background tasks** lists subagents, a team's
|
||||||
|
members and background commands under **Running**, each with how long it has run and a stop
|
||||||
|
square, unfolding to what it is, its latest step, what it is writing and its conversation; the
|
||||||
|
tasks under one fold beneath it ("Tasks: 2"), and the finished ones fold away under **Finished**.
|
||||||
|
The team's **Room** and the **Plan** have sections of their own.
|
||||||
|
|
||||||
|
## [1.0.0-beta.4] — 2026-11-09
|
||||||
|
|
||||||
|
Carries LLeMbas CLI 2.0.0-beta.3 and the look of LLeMbas 2.0.0-beta.6. Scheduled tasks need LLeMbas
|
||||||
|
2.0.0-beta.6 or later.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **An agent that could not link now gets its link.** Signing in asked the server for the agent's
|
||||||
|
token only when the built-in LLeMbas CLI had no login for that server on file -- so a computer
|
||||||
|
with an old login the server no longer takes got the app's own token alone, and the agent stayed
|
||||||
|
refused. The sign-in now asks for the agent's token unless the agent's link works right now, and
|
||||||
|
the CLI's new login replaces the old one. **Link this computer** (and **Link again**) is now its
|
||||||
|
own approval for the agent's token, handed to the CLI the same way; before, it ran the CLI's own
|
||||||
|
login, which for a server it was logged in to only refreshed the login it had.
|
||||||
|
- **"Refused" means the server refused**, and says the server's reason. A service that stopped for
|
||||||
|
want of a login says *not linked*; any other reason, *stopped* with the reason.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **A new look, the shape of an AI desktop app.** A slim sidebar: **New chat**, a search line,
|
||||||
|
**Scheduled**, **Files**, then your chats of both kinds -- agent chats and chats on the server --
|
||||||
|
under *Today* and *Earlier*, an agent chat with its folder's name; **Settings** with your account
|
||||||
|
at the foot. The sidebar folds away (its button in the title bar, or Ctrl+B) and is a drawer on a
|
||||||
|
narrow window.
|
||||||
|
- **A new chat is a chat page.** A greeting, the choice of **Agent** (on this computer) or **Chat**
|
||||||
|
(on the server), and the message box ready -- no form. What the chat starts with sits under the
|
||||||
|
text as small chips, each opening a menu: the folder and the mode for an agent chat, the model
|
||||||
|
with its picture, how hard it thinks (as the model offers it), and the team's size where a team
|
||||||
|
is offered. An open chat has the same chips, and changing one changes the chat.
|
||||||
|
- **The conversation** in a centred column: your messages in bubbles on the right, with attached
|
||||||
|
pictures as thumbnails and other files as tiles; replies full width; thinking and each tool call
|
||||||
|
a compact row that unfolds. **Send** is a round button that **Stop** replaces while a reply is
|
||||||
|
written.
|
||||||
|
- **The activity panel** opens from a chip above the message box, on every open chat; while
|
||||||
|
agents work, the chip says what they are doing.
|
||||||
|
- **The agent's status** is a chip in the title bar that opens what the agent says and the one
|
||||||
|
thing to do about it. When this computer is not linked -- or the server refused its link -- a
|
||||||
|
new agent chat says so right above the message box, with **Link this computer** (or **Link
|
||||||
|
again**) there, not only in Settings; while the approval waits, the code to compare is shown
|
||||||
|
with Cancel.
|
||||||
|
- **Files** is a page of its own, with this computer's folders and your files on the server as
|
||||||
|
two tabs. **Settings** is grouped -- Account, Agent, Project folders, Notifications, About --
|
||||||
|
with the groups listed beside them.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Scheduled tasks** (with a server that has them, LLeMbas 2.0.0-beta.6 or later): the list
|
||||||
|
with each task's next run, how its last run went and a switch to pause it, kept up to date as
|
||||||
|
the server runs them; a task's page with what it does, when (and in which time zone), its
|
||||||
|
model, effort, team and how it tells you, the next runs, and its history -- each run's status,
|
||||||
|
time, whether it was late, caught up or run by hand, the model it ran on (and that it switched
|
||||||
|
from a busy one), and its result to unfold in place, with its chat to open; earlier runs on
|
||||||
|
request. **Run now**, **Edit**, pause and resume, and delete. A new task is said in your own
|
||||||
|
words: the server reads them into a rule, a title and an instruction and shows the next five
|
||||||
|
runs before you save; the title and instruction can be changed, and where the words give no
|
||||||
|
time the days and the time can be set by hand. Each task's settings: when to tell you
|
||||||
|
(*when it matters*, *always*, *never*), a new chat for each run or one that grows, its model
|
||||||
|
(or the first free one of the scheduling models), its effort and its team. **Models** on the
|
||||||
|
list chooses which models scheduled tasks may run on, each shown free or busy.
|
||||||
|
- **A scheduled task's result comes as a system notification** while the app runs, by the
|
||||||
|
task's own setting: a result, never a run with nothing to report unless the task says
|
||||||
|
*always*, nothing for *never*; a failure the first time, and on the third in a row the
|
||||||
|
question whether to pause it. The app follows the server's event stream for this rather than
|
||||||
|
asking every few seconds.
|
||||||
|
|
||||||
## [1.0.0-beta.3] — 2026-11-09
|
## [1.0.0-beta.3] — 2026-11-09
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,21 @@ an app of its own.
|
|||||||
computer's device. Agent chats work in your project folders here, without a terminal -- start
|
computer's device. Agent chats work in your project folders here, without a terminal -- start
|
||||||
them, follow them and answer them in the app.
|
them, follow them and answer them in the app.
|
||||||
- **Your server's chats** beside them: the same account, models and permissions as in the browser.
|
- **Your server's chats** beside them: the same account, models and permissions as in the browser.
|
||||||
- **Two explorers, kept apart**: the agent's working directory on this computer, and your files
|
- **A chat app's shape**: a slim sidebar with your chats of both kinds by day, a new chat that is
|
||||||
on the server.
|
a chat page, and the chat's folder, mode, model, effort and team size as chips in the message
|
||||||
|
box.
|
||||||
|
- **Scheduled tasks** (with a server that has them): the list, each task's history and *Run now*,
|
||||||
|
and a new one said in your own words, with its next runs shown before you save.
|
||||||
|
- **Files**, two trees kept apart: the agent's folders on this computer, and your files on the
|
||||||
|
server.
|
||||||
- **A viewer** for what you open and for what the agent shows you: Markdown drawn, code
|
- **A viewer** for what you open and for what the agent shows you: Markdown drawn, code
|
||||||
highlighted, text, and HTML in a sealed frame that can reach neither the app nor the network.
|
highlighted, text, and HTML in a sealed frame that can reach neither the app nor the network.
|
||||||
- **Attachments** from the file dialog, a drop on the window or a paste; **edit** a message,
|
- **Attachments** from the file dialog, a drop on the window or a paste; **edit** a message,
|
||||||
**regenerate** a reply, rename, archive or delete a chat, and open it in the web interface.
|
**regenerate** a reply, rename, archive or delete a chat, and open it in the web interface.
|
||||||
- **A command palette** (Ctrl+K) for commands and every chat by its title.
|
- **A command palette** (Ctrl+K) for commands and every chat by its title.
|
||||||
- **The agent's real status**: what the built-in CLI says of its link -- connecting, running,
|
- **The agent's real status**: what the built-in CLI says of its link -- connecting, running,
|
||||||
waiting and why -- not whether a process runs.
|
waiting and why, refused with the server's reason -- not whether a process runs; and, when this
|
||||||
|
computer is not linked, **Link this computer** right where you start an agent chat.
|
||||||
- **Notifications** when a reply or an agent's work finishes while you are elsewhere.
|
- **Notifications** when a reply or an agent's work finishes while you are elsewhere.
|
||||||
- **Its own window and tray**: close the window and the agent keeps running in the tray.
|
- **Its own window and tray**: close the window and the agent keeps running in the tray.
|
||||||
|
|
||||||
@@ -57,12 +63,13 @@ built into the app before anything is installed. Betas are not offered as update
|
|||||||
browser, where you are signed in already; approve the code shown in the app. This one approval
|
browser, where you are signed in already; approve the code shown in the app. This one approval
|
||||||
lets the app read and write your chats, as the web interface does, and links this computer as
|
lets the app read and write your chats, as the web interface does, and links this computer as
|
||||||
the device the agent runs on: the app hands the agent's token to its LLeMbas CLI, which keeps it
|
the device the agent runs on: the app hands the agent's token to its LLeMbas CLI, which keeps it
|
||||||
in its own configuration. The agent starts. (A computer linked already keeps its link; on a
|
in its own configuration. The agent starts. (A computer whose link works keeps it; on a server
|
||||||
server that cannot give both from one approval, **Link this computer** under **Settings** is a
|
that cannot give both from one approval, **Link this computer** -- above the message box of a
|
||||||
second one.)
|
new agent chat, in the agent's panel in the title bar, or in **Settings** -- is a second one.)
|
||||||
2. Choose the folders the agent may work in (Settings, *Add folder…*); a new computer allows none
|
2. Choose the folders the agent may work in (Settings, *Add folder…*); a new computer allows none
|
||||||
until you do.
|
until you do.
|
||||||
3. **New agent chat**: pick one of those folders, a mode and a model, and write.
|
3. **New chat**, **Agent**: pick one of those folders, a mode and a model in the chips under the
|
||||||
|
message box, and write.
|
||||||
|
|
||||||
The agent is the LLeMbas CLI's `lembas service`, using the CLI's own configuration
|
The agent is the LLeMbas CLI's `lembas service`, using the CLI's own configuration
|
||||||
(`~/.config/lembas`). The app's copy of the CLI lives in its own directory
|
(`~/.config/lembas`). The app's copy of the CLI lives in its own directory
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
2.0.0-beta.3
|
2.0.0-beta.6
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Copy the server's /api/v2 contract fixtures that tests/ui reads into tests/ui/fixtures/:
|
||||||
|
#
|
||||||
|
# scripts/fetch-fixtures.sh <server-ref> (a tag such as v2.0.0-beta.8, or a branch)
|
||||||
|
#
|
||||||
|
# The server is a git checkout, LLeMbas beside this repository by default (LEMBAS_SERVER names
|
||||||
|
# another); the files are read from the ref, not from its working tree. SOURCE records the ref and
|
||||||
|
# the commit, so the tests say which server they hold the app to. CI has no server checkout: the
|
||||||
|
# copies are committed.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
SERVER="${LEMBAS_SERVER:-../LLeMbas}"
|
||||||
|
FROM="tests/fixtures/api_v2"
|
||||||
|
OUT="tests/ui/fixtures"
|
||||||
|
FILES="chat_agent.json event_ask.json event_status.json event_status_idle.json event_status_person.json
|
||||||
|
event_status_stopped.json event_tree.json models_agent.json subagent_findings.json subagent_transcript.json tree.json"
|
||||||
|
|
||||||
|
[ $# -eq 1 ] || { echo "usage: scripts/fetch-fixtures.sh <server-ref>" >&2; exit 2; }
|
||||||
|
commit="$(git -C "$SERVER" rev-parse --verify --quiet "$1^{commit}")" \
|
||||||
|
|| { echo "fetch-fixtures: $1 is not a commit in $SERVER" >&2; exit 1; }
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
for f in $FILES; do
|
||||||
|
git -C "$SERVER" show "$commit:$FROM/$f" > "$OUT/$f" || { echo "fetch-fixtures: $f is not at $1" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
printf 'LLeMbas %s (%s) %s\n' "$1" "$commit" "$FROM" > "$OUT/SOURCE"
|
||||||
|
echo "fetch-fixtures: $(echo $FILES | wc -w) files from LLeMbas $1 (${commit:0:12}) -> $OUT"
|
||||||
+16
-4
@@ -1,6 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Put the server's vendored CodeMirror build where the app's own pages read it from:
|
# Put the server's vendored CodeMirror build and xterm where the app's own pages read them from:
|
||||||
# ui/vendor/*.mjs (copies of the server's static/vendor/cm-*.mjs and lezer-*.mjs)
|
# ui/vendor/*.mjs (copies of the server's static/vendor/cm-*.mjs and lezer-*.mjs)
|
||||||
|
# ui/vendor/xterm.js, xterm.css, xterm-addon-fit.js (the terminal panel's, copied unchanged:
|
||||||
|
# classic scripts the panel loads when it is first opened)
|
||||||
#
|
#
|
||||||
# scripts/fetch-vendor.sh <server-ref> copy the modules at that ref (a tag such as
|
# scripts/fetch-vendor.sh <server-ref> copy the modules at that ref (a tag such as
|
||||||
# v2.0.0-beta.1, or a branch) and write vendor.lock.json
|
# v2.0.0-beta.1, or a branch) and write vendor.lock.json
|
||||||
@@ -47,8 +49,8 @@ if [ $# -eq 0 ]; then
|
|||||||
echo "fetch-vendor: $OUT/$name does not match $LOCK (server $ref)" >&2
|
echo "fetch-vendor: $OUT/$name does not match $LOCK (server $ref)" >&2
|
||||||
bad=1
|
bad=1
|
||||||
fi
|
fi
|
||||||
done < <(sed -n -E 's/^ *"([a-z0-9-]+\.mjs)": *\{"server": *"[0-9a-f]{64}", *"copy": *"([0-9a-f]{64})"\}.*/\1\t\2/p' "$LOCK")
|
done < <(sed -n -E 's/^ *"([a-z0-9-]+\.(mjs|js|css))": *\{"server": *"[0-9a-f]{64}", *"copy": *"([0-9a-f]{64})"\}.*/\1\t\3/p' "$LOCK")
|
||||||
for f in "$OUT"/*.mjs; do
|
for f in "$OUT"/*; do
|
||||||
grep -q "\"$(basename "$f")\"" "$LOCK" || { echo "fetch-vendor: $f is not in $LOCK" >&2; bad=1; }
|
grep -q "\"$(basename "$f")\"" "$LOCK" || { echo "fetch-vendor: $f is not in $LOCK" >&2; bad=1; }
|
||||||
done
|
done
|
||||||
[ "$count" -gt 0 ] || { echo "fetch-vendor: $LOCK lists no file" >&2; exit 1; }
|
[ "$count" -gt 0 ] || { echo "fetch-vendor: $LOCK lists no file" >&2; exit 1; }
|
||||||
@@ -87,6 +89,16 @@ for name in $names; do
|
|||||||
fi
|
fi
|
||||||
entries+=("$(printf ' "%s": {"server": "%s", "copy": "%s"}' "$name" "$have" "$(sha "$work/out/$name")")")
|
entries+=("$(printf ' "%s": {"server": "%s", "copy": "%s"}' "$name" "$have" "$(sha "$work/out/$name")")")
|
||||||
done
|
done
|
||||||
|
# xterm and its fit addon, for the terminal panel: copied as they are, each checked against the
|
||||||
|
# server's pin like the modules.
|
||||||
|
for name in xterm.js xterm.css xterm-addon-fit.js; do
|
||||||
|
git -C "$SERVER" show "$commit:$SOURCE/$name" > "$work/out/$name" \
|
||||||
|
|| { echo "fetch-vendor: $name is not in $SOURCE at $REF" >&2; exit 1; }
|
||||||
|
pinned="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], {}).get("sha256", ""))' "$work/server-lock.json" "$name")"
|
||||||
|
have="$(sha "$work/out/$name")"
|
||||||
|
[ "$pinned" = "$have" ] || { echo "fetch-vendor: $name does not match the server's pin ($have, pinned $pinned)" >&2; exit 1; }
|
||||||
|
entries+=("$(printf ' "%s": {"server": "%s", "copy": "%s"}' "$name" "$have" "$have")")
|
||||||
|
done
|
||||||
# The polyfill stub, from the server's own scripts (it is not a download, so it has no pin there).
|
# The polyfill stub, from the server's own scripts (it is not a download, so it has no pin there).
|
||||||
stub="src/lembas/web/static/js/node-process.mjs"
|
stub="src/lembas/web/static/js/node-process.mjs"
|
||||||
git -C "$SERVER" show "$commit:$stub" > "$work/out/node-process.mjs" \
|
git -C "$SERVER" show "$commit:$stub" > "$work/out/node-process.mjs" \
|
||||||
@@ -101,7 +113,7 @@ done
|
|||||||
|
|
||||||
rm -rf "$OUT"
|
rm -rf "$OUT"
|
||||||
mkdir -p "$OUT"
|
mkdir -p "$OUT"
|
||||||
cp "$work"/out/*.mjs "$OUT/"
|
cp "$work"/out/*.mjs "$work"/out/xterm.js "$work"/out/xterm.css "$work"/out/xterm-addon-fit.js "$OUT/"
|
||||||
{
|
{
|
||||||
printf '{\n "tag": "%s",\n "commit": "%s",\n "files": {\n' "$REF" "$commit"
|
printf '{\n "tag": "%s",\n "commit": "%s",\n "files": {\n' "$REF" "$commit"
|
||||||
last=$((${#entries[@]} - 1))
|
last=$((${#entries[@]} - 1))
|
||||||
|
|||||||
+1074
-100
File diff suppressed because it is too large.
Load diff
Generated
+85
-4
@@ -279,6 +279,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block2"
|
name = "block2"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -511,6 +520,12 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie"
|
name = "cookie"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
@@ -623,6 +638,15 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cssparser"
|
name = "cssparser"
|
||||||
version = "0.37.0"
|
version = "0.37.0"
|
||||||
@@ -686,6 +710,12 @@ dependencies = [
|
|||||||
"syn 3.0.8",
|
"syn 3.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dbus"
|
name = "dbus"
|
||||||
version = "0.9.12"
|
version = "0.9.12"
|
||||||
@@ -775,8 +805,19 @@ version = "0.10.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer 0.10.4",
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer 0.12.1",
|
||||||
|
"const-oid",
|
||||||
|
"crypto-common 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1552,6 +1593,15 @@ version = "1.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@@ -2085,7 +2135,7 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "llembas-desktop"
|
name = "llembas-desktop"
|
||||||
version = "1.0.0-beta.3"
|
version = "1.0.0-beta.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2097,6 +2147,7 @@ dependencies = [
|
|||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
|
"tungstenite",
|
||||||
"ureq",
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3488,6 +3539,17 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.1",
|
||||||
|
"digest 0.11.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -3496,7 +3558,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.2.17",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4539,6 +4601,25 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.30.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand",
|
||||||
|
"rustls",
|
||||||
|
"rustls-native-certs 0.8.5",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"sha1",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "llembas-desktop"
|
name = "llembas-desktop"
|
||||||
version = "1.0.0-beta.3"
|
version = "1.0.0-beta.6"
|
||||||
description = "LLeMbas Desktop: the agent on this computer and your LLeMbas server's chats, in an app of its own"
|
description = "LLeMbas Desktop: the agent on this computer and your LLeMbas server's chats, in an app of its own"
|
||||||
authors = ["Jaroslav Beneš"]
|
authors = ["Jaroslav Beneš"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -26,6 +26,8 @@ tauri-plugin-updater = "2"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
ureq = { version = "2", default-features = false, features = ["tls", "native-certs", "json"] }
|
ureq = { version = "2", default-features = false, features = ["tls", "native-certs", "json"] }
|
||||||
|
# The terminal panel's socket (`terminal.rs`): a blocking WebSocket on the TLS ureq already brings.
|
||||||
|
tungstenite = { version = "0.30", default-features = false, features = ["handshake", "rustls-tls-native-roots"] }
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|||||||
+60
-11
@@ -45,8 +45,9 @@ pub enum State {
|
|||||||
/// Not linked right now, and trying again: the CLI's own reason (a terminal holds the link,
|
/// Not linked right now, and trying again: the CLI's own reason (a terminal holds the link,
|
||||||
/// the server cannot be reached, the link dropped).
|
/// the server cannot be reached, the link dropped).
|
||||||
Waiting(String),
|
Waiting(String),
|
||||||
/// The CLI will not try again by itself: the server refused this computer's token, or the
|
/// The server refused this computer's link, in its words (a token it no longer takes, or a
|
||||||
/// login is missing. Linking again is the way out.
|
/// protocol it does not speak). The CLI will not try again by itself; linking again is the
|
||||||
|
/// way out. Only the server's refusal: a missing login is `NotLinked`.
|
||||||
Refused(String),
|
Refused(String),
|
||||||
/// Another `lembas service` on this computer holds the link.
|
/// Another `lembas service` on this computer holds the link.
|
||||||
Elsewhere(Elsewhere),
|
Elsewhere(Elsewhere),
|
||||||
@@ -108,6 +109,32 @@ pub fn from_link(link: &cli::Link, before: &State) -> State {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a service that ended cleanly (exit 0) said: the server's refusal (`stopped` in its status
|
||||||
|
/// file, with the server's reason) is `Refused`; no login for the server is `NotLinked`; any
|
||||||
|
/// other reason it gave (a configuration that cannot work) is `Failed` -- never "refused", which
|
||||||
|
/// is the server's word only. None when it said nothing.
|
||||||
|
fn ended_cleanly(stopped: Option<&cli::Link>, said: Option<&str>) -> Option<State> {
|
||||||
|
if let Some(link) = stopped {
|
||||||
|
let why = link.detail.clone().unwrap_or_default();
|
||||||
|
// The CLI's advice to its terminal user ("sign in again: lembas login") is the app's
|
||||||
|
// Link button here.
|
||||||
|
let why = why
|
||||||
|
.split(" — sign in again")
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if !why.is_empty() && why != "stopped" {
|
||||||
|
return Some(State::Refused(why));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let said = strip_prefix(said?);
|
||||||
|
if said.starts_with("not logged in") {
|
||||||
|
return Some(State::NotLinked);
|
||||||
|
}
|
||||||
|
Some(State::Failed(said))
|
||||||
|
}
|
||||||
|
|
||||||
/// What a running process has said before its status file says anything: the one line the
|
/// What a running process has said before its status file says anything: the one line the
|
||||||
/// CLI's service writes while a terminal on this computer holds the link for `/remote`.
|
/// CLI's service writes while a terminal on this computer holds the link for `/remote`.
|
||||||
fn from_output(line: &str) -> Option<State> {
|
fn from_output(line: &str) -> Option<State> {
|
||||||
@@ -252,15 +279,12 @@ impl Agent {
|
|||||||
Some(RESTART_FOR_UPDATE) => continue,
|
Some(RESTART_FOR_UPDATE) => continue,
|
||||||
Some(0) => {
|
Some(0) => {
|
||||||
// It ended by itself without an error: the CLI's word for "starting again
|
// It ended by itself without an error: the CLI's word for "starting again
|
||||||
// will not help" -- the server refused the token, or there is no login.
|
// will not help". Waiting a long while (linking again starts it at once).
|
||||||
// Waiting a long while (linking again starts it at once).
|
let stopped = cli::link_of(pid).filter(|l| l.state == "stopped");
|
||||||
let why = cli::link_of(pid)
|
let next = ended_cleanly(stopped.as_ref(), said.as_deref());
|
||||||
.filter(|l| l.state == "stopped")
|
match next {
|
||||||
.and_then(|l| l.detail)
|
Some(state) => {
|
||||||
.or_else(|| said.map(|s| strip_prefix(&s)));
|
if !self.set(generation, state) {
|
||||||
match why {
|
|
||||||
Some(why) => {
|
|
||||||
if !self.set(generation, State::Refused(why)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.pause(generation, REFUSED_WAIT);
|
self.pause(generation, REFUSED_WAIT);
|
||||||
@@ -436,6 +460,31 @@ fn strip_prefix(line: &str) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refused_is_only_the_servers_word() {
|
||||||
|
let refused = link(
|
||||||
|
"stopped",
|
||||||
|
Some("not allowed to link — sign in again: lembas login"),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ended_cleanly(Some(&refused), None),
|
||||||
|
Some(State::Refused("not allowed to link".into()))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ended_cleanly(
|
||||||
|
None,
|
||||||
|
Some("lembas service: not logged in to any LLeMbas instance — lembas login <address>")
|
||||||
|
),
|
||||||
|
Some(State::NotLinked)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ended_cleanly(None, Some("lembas service: logged in to several instances")),
|
||||||
|
Some(State::Failed("logged in to several instances".into()))
|
||||||
|
);
|
||||||
|
assert_eq!(ended_cleanly(None, None), None);
|
||||||
|
}
|
||||||
|
|
||||||
fn link(state: &str, detail: Option<&str>, attempts: u32) -> cli::Link {
|
fn link(state: &str, detail: Option<&str>, attempts: u32) -> cli::Link {
|
||||||
cli::Link {
|
cli::Link {
|
||||||
state: state.into(),
|
state: state.into(),
|
||||||
|
|||||||
+219
-26
@@ -9,9 +9,10 @@
|
|||||||
//! URL; the window refuses every navigation away from the app's page, and HTML the viewer shows
|
//! URL; the window refuses every navigation away from the app's page, and HTML the viewer shows
|
||||||
//! is a sealed frame on a scheme of its own with no IPC and no network (`sealed.rs`).
|
//! is a sealed frame on a scheme of its own with no IPC and no network (`sealed.rs`).
|
||||||
//! - **The agent is the CLI's**: the app's own sign-in also gives the agent's token, in the same
|
//! - **The agent is the CLI's**: the app's own sign-in also gives the agent's token, in the same
|
||||||
//! approval, and `lembas login --token-stdin` takes it (a server without that: `lembas login
|
//! approval, and `lembas login --token-stdin` takes it; *Link this computer* is a login for the
|
||||||
//! --json`, a second approval); `lembas service run` is the device (`agent.rs`). The app reads
|
//! agent's token alone, handed over the same way. `lembas service run` is the device
|
||||||
//! the CLI's state and runs its commands; it never writes the CLI's files.
|
//! (`agent.rs`). The app reads the CLI's state and runs its commands; it never writes the
|
||||||
|
//! CLI's files.
|
||||||
//! - **A 2.0 server only.** Discovery is checked before anything else (`server::check`).
|
//! - **A 2.0 server only.** Discovery is checked before anything else (`server::check`).
|
||||||
|
|
||||||
mod agent;
|
mod agent;
|
||||||
@@ -21,6 +22,7 @@ mod remote;
|
|||||||
mod sealed;
|
mod sealed;
|
||||||
mod server;
|
mod server;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
mod terminal;
|
||||||
mod watch;
|
mod watch;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -97,6 +99,8 @@ struct App {
|
|||||||
agent: Agent,
|
agent: Agent,
|
||||||
tray: Mutex<Option<TrayItems>>,
|
tray: Mutex<Option<TrayItems>>,
|
||||||
linking: Mutex<bool>,
|
linking: Mutex<bool>,
|
||||||
|
/// Set to stop waiting for the approval of a link in progress.
|
||||||
|
link_cancel: Mutex<Option<Arc<AtomicBool>>>,
|
||||||
/// The newer release the last check found, if any.
|
/// The newer release the last check found, if any.
|
||||||
update: Mutex<Option<String>>,
|
update: Mutex<Option<String>>,
|
||||||
/// The agent's folders, as `lembas remote` last answered.
|
/// The agent's folders, as `lembas remote` last answered.
|
||||||
@@ -112,6 +116,8 @@ struct App {
|
|||||||
signing_in: Mutex<Option<Arc<AtomicBool>>>,
|
signing_in: Mutex<Option<Arc<AtomicBool>>>,
|
||||||
streams: Mutex<HashMap<u64, Arc<AtomicBool>>>,
|
streams: Mutex<HashMap<u64, Arc<AtomicBool>>>,
|
||||||
next_stream: AtomicU64,
|
next_stream: AtomicU64,
|
||||||
|
/// The terminal panel's sockets (`terminal.rs`), by the page's id for each.
|
||||||
|
terminals: terminal::Sessions,
|
||||||
sealed: sealed::Pages,
|
sealed: sealed::Pages,
|
||||||
watch: watch::Watch,
|
watch: watch::Watch,
|
||||||
/// The files last dropped on the window: what `attach_dropped` may send, and nothing else.
|
/// The files last dropped on the window: what `attach_dropped` may send, and nothing else.
|
||||||
@@ -268,6 +274,7 @@ async fn set_server(handle: AppHandle, address: String) -> Result<Status, String
|
|||||||
if before.as_deref() != Some(d.base.as_str()) {
|
if before.as_deref() != Some(d.base.as_str()) {
|
||||||
app.agent.stop();
|
app.agent.stop();
|
||||||
app.watch.reset();
|
app.watch.reset();
|
||||||
|
app.terminals.close_all();
|
||||||
}
|
}
|
||||||
*app.account.lock().unwrap() = server::load_account(&app.config_dir, &d.base);
|
*app.account.lock().unwrap() = server::load_account(&app.config_dir, &d.base);
|
||||||
*app.server.lock().unwrap() = Some(d);
|
*app.server.lock().unwrap() = Some(d);
|
||||||
@@ -286,9 +293,12 @@ enum SignIn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sign the app in to the server: a device login for the app's scopes, approved in the browser.
|
/// Sign the app in to the server: a device login for the app's scopes, approved in the browser.
|
||||||
/// Where the server can (`one_approval`) and this computer is not linked to it yet, the same
|
/// Where the server can (`one_approval`), the same approval asks for the agent's token too, unless
|
||||||
/// approval links it: the agent's token goes to the CLI. A computer linked already keeps its
|
/// the agent's link works right now (the CLI says it is linked). Whether the CLI has a login for
|
||||||
/// link; "Link again" is the way to replace it.
|
/// this server on file is not asked: a login the server refuses is a file all the same, and
|
||||||
|
/// asking that left a computer with a refused link and no way to a new one from the sign-in.
|
||||||
|
/// After the approval the agent's token goes to the CLI -- whose `--token-stdin` login replaces
|
||||||
|
/// the one it had for this server -- or, when the link came up meanwhile, is given up.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn sign_in(handle: AppHandle) -> Result<(), String> {
|
fn sign_in(handle: AppHandle) -> Result<(), String> {
|
||||||
let app = handle.state::<App>();
|
let app = handle.state::<App>();
|
||||||
@@ -306,7 +316,7 @@ fn sign_in(handle: AppHandle) -> Result<(), String> {
|
|||||||
announce(&handle);
|
announce(&handle);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let host = cli::hostname().unwrap_or_default();
|
let host = cli::hostname().unwrap_or_default();
|
||||||
let with_agent = d.one_approval && !cli::linked_to(&d.base);
|
let with_agent = d.one_approval && !link_works(&handle.state::<App>());
|
||||||
let result = server::start_sign_in(&d, &host, with_agent).and_then(|code| {
|
let result = server::start_sign_in(&d, &host, with_agent).and_then(|code| {
|
||||||
let _ = handle
|
let _ = handle
|
||||||
.opener()
|
.opener()
|
||||||
@@ -346,7 +356,10 @@ fn sign_in(handle: AppHandle) -> Result<(), String> {
|
|||||||
let _ = handle.emit("signin", outcome);
|
let _ = handle.emit("signin", outcome);
|
||||||
announce(&handle);
|
announce(&handle);
|
||||||
if let Some(token) = agent_token {
|
if let Some(token) = agent_token {
|
||||||
if let Err(message) = hand_to_cli(&handle, &d.base, token) {
|
if link_works(&app) {
|
||||||
|
// Linked meanwhile (a service started by hand, say): this token is nobody's.
|
||||||
|
server::sign_out(&d.base, &token);
|
||||||
|
} else if let Err(message) = hand_to_cli(&handle, &d.base, token) {
|
||||||
let _ = handle.emit("linking", Linking::Failed { message });
|
let _ = handle.emit("linking", Linking::Failed { message });
|
||||||
announce(&handle);
|
announce(&handle);
|
||||||
}
|
}
|
||||||
@@ -371,6 +384,7 @@ fn sign_out(handle: AppHandle) {
|
|||||||
let account = app.account.lock().unwrap().take();
|
let account = app.account.lock().unwrap().take();
|
||||||
server::forget_account(&app.config_dir);
|
server::forget_account(&app.config_dir);
|
||||||
app.watch.reset();
|
app.watch.reset();
|
||||||
|
app.terminals.close_all();
|
||||||
if let Some(a) = account {
|
if let Some(a) = account {
|
||||||
std::thread::spawn(move || server::sign_out(&a.server, &a.token));
|
std::thread::spawn(move || server::sign_out(&a.server, &a.token));
|
||||||
}
|
}
|
||||||
@@ -382,6 +396,7 @@ fn token_refused(handle: &AppHandle) {
|
|||||||
let app = handle.state::<App>();
|
let app = handle.state::<App>();
|
||||||
if app.account.lock().unwrap().take().is_some() {
|
if app.account.lock().unwrap().take().is_some() {
|
||||||
server::forget_account(&app.config_dir);
|
server::forget_account(&app.config_dir);
|
||||||
|
app.terminals.close_all();
|
||||||
announce(handle);
|
announce(handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,6 +517,71 @@ fn stream_close(app: tauri::State<'_, App>, id: u64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- The terminal panel ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Open a shell on a linked device, as the terminal panel asks: an agent chat's (`chat`), or the
|
||||||
|
/// one before the chat exists (`device` and `directory`). The socket is opened here, with the
|
||||||
|
/// app's token in its handshake; the page gets its frames as `terminal` events with this id --
|
||||||
|
/// `output` (base64), `control` (the server's JSON), and a last `end`.
|
||||||
|
#[tauri::command]
|
||||||
|
fn terminal_open(
|
||||||
|
handle: AppHandle,
|
||||||
|
chat: Option<String>,
|
||||||
|
device: Option<String>,
|
||||||
|
directory: Option<String>,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
) -> Result<u64, ApiError> {
|
||||||
|
let app = handle.state::<App>();
|
||||||
|
let Some((app_url, token)) = app.door() else {
|
||||||
|
return Err(ApiError::new(401, "Not signed in."));
|
||||||
|
};
|
||||||
|
let target = match (chat, device) {
|
||||||
|
(Some(chat), None) => terminal::Target::Chat(chat),
|
||||||
|
(None, Some(device)) => terminal::Target::Device {
|
||||||
|
device,
|
||||||
|
directory: directory.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
_ => return Err(ApiError::new(400, "A chat or a device, not both.")),
|
||||||
|
};
|
||||||
|
let url = terminal::url(&app_url, &target, cols, rows)?;
|
||||||
|
let h = handle.clone();
|
||||||
|
let on_end = handle.clone();
|
||||||
|
Ok(app.terminals.open(
|
||||||
|
url,
|
||||||
|
token,
|
||||||
|
move |down| {
|
||||||
|
let _ = h.emit("terminal", down);
|
||||||
|
},
|
||||||
|
move |id, end| {
|
||||||
|
on_end.state::<App>().terminals.forget(id);
|
||||||
|
if end.unauthorized() {
|
||||||
|
token_refused(&on_end);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keys typed (or pasted) into a terminal, as text: sent to the shell as its bytes.
|
||||||
|
#[tauri::command]
|
||||||
|
fn terminal_input(app: tauri::State<'_, App>, id: u64, data: String) -> bool {
|
||||||
|
app.terminals
|
||||||
|
.send(id, terminal::Up::Keys(data.into_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The terminal's size changed.
|
||||||
|
#[tauri::command]
|
||||||
|
fn terminal_resize(app: tauri::State<'_, App>, id: u64, cols: u16, rows: u16) -> bool {
|
||||||
|
app.terminals.send(id, terminal::Up::Resize(cols, rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page let go of a terminal: its socket is closed (the shell on the device lives on, as
|
||||||
|
/// the server keeps it).
|
||||||
|
#[tauri::command]
|
||||||
|
fn terminal_close(app: tauri::State<'_, App>, id: u64) {
|
||||||
|
app.terminals.close(id);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Attachments ----------------------------------------------------------------------------------
|
// --- Attachments ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
/// What became of one file sent as an attachment: the server's attachment, or why not. `reason`
|
/// What became of one file sent as an attachment: the server's attachment, or why not. `reason`
|
||||||
@@ -779,6 +859,24 @@ fn link_computer(handle: AppHandle) -> Result<(), String> {
|
|||||||
start_link(&handle)
|
start_link(&handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop waiting for the approval of a link in progress.
|
||||||
|
#[tauri::command]
|
||||||
|
fn link_cancel(handle: AppHandle) {
|
||||||
|
if let Some(c) = handle.state::<App>().link_cancel.lock().unwrap().take() {
|
||||||
|
c.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the agent's link works right now: the CLI says it is linked -- the app's own service,
|
||||||
|
/// or one started by hand. A login on file is not a link that works.
|
||||||
|
fn link_works(app: &App) -> bool {
|
||||||
|
match app.agent.state() {
|
||||||
|
AgentState::Running => true,
|
||||||
|
AgentState::Elsewhere(e) => e.linked,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn set_agent_wanted(handle: &AppHandle, on: bool) -> Result<(), String> {
|
fn set_agent_wanted(handle: &AppHandle, on: bool) -> Result<(), String> {
|
||||||
let app = handle.state::<App>();
|
let app = handle.state::<App>();
|
||||||
let s = app.update(|s| s.agent = on)?;
|
let s = app.update(|s| s.agent = on)?;
|
||||||
@@ -885,31 +983,49 @@ enum Linking {
|
|||||||
Failed { message: String },
|
Failed { message: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Link this computer: `lembas login <server> --json`, the approval opened in the system's
|
/// Link this computer (again): a device login for the agent's scopes alone, the approval opened
|
||||||
/// browser (where the person is signed in to the server), then the agent started. The way to
|
/// in the system's browser (where the person is signed in to the server), the token handed to
|
||||||
/// link again, and the only way on a server that gives no agent token with the app's sign-in.
|
/// the CLI (`lembas login --token-stdin`, which replaces a login it had for this server), then the
|
||||||
|
/// agent started. Not `lembas login <server>`: for a server the CLI is logged in to already, that
|
||||||
|
/// only refreshes the login it has, so a token the server refused would stay refused.
|
||||||
fn start_link(handle: &AppHandle) -> Result<(), String> {
|
fn start_link(handle: &AppHandle) -> Result<(), String> {
|
||||||
let app = handle.state::<App>();
|
let app = handle.state::<App>();
|
||||||
let Some(server) = app.settings().server else {
|
let Some(d) = app.server.lock().unwrap().clone() else {
|
||||||
return Err("Choose a server first.".into());
|
return Err("Choose a LLeMbas 2.0 server first.".into());
|
||||||
};
|
};
|
||||||
claim_linking(&app)?;
|
claim_linking(&app)?;
|
||||||
let child = match Command::new(cli::binary())
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
.arg("login")
|
*app.link_cancel.lock().unwrap() = Some(cancel.clone());
|
||||||
.arg(&server)
|
let handle = handle.clone();
|
||||||
.arg("--json")
|
std::thread::spawn(move || {
|
||||||
.stdin(Stdio::null())
|
let host = cli::hostname().unwrap_or_default();
|
||||||
.stdout(Stdio::piped())
|
let result = server::start_link(&d, &host).and_then(|code| {
|
||||||
.stderr(Stdio::piped())
|
let _ = handle
|
||||||
.spawn()
|
.opener()
|
||||||
{
|
.open_url(&code.verification_uri_complete, None::<&str>);
|
||||||
Ok(c) => c,
|
let _ = handle.emit(
|
||||||
Err(e) => {
|
"linking",
|
||||||
|
Linking::Code {
|
||||||
|
user_code: code.user_code.clone(),
|
||||||
|
uri: code.verification_uri_complete.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
server::await_sign_in(&d, &code, &cancel)
|
||||||
|
});
|
||||||
|
let app = handle.state::<App>();
|
||||||
|
app.link_cancel.lock().unwrap().take();
|
||||||
|
let given = match result {
|
||||||
|
Ok(granted) => give_to_cli(&handle, &d.base, granted.account.token),
|
||||||
|
Err(message) => {
|
||||||
*app.linking.lock().unwrap() = false;
|
*app.linking.lock().unwrap() = false;
|
||||||
return Err(format!("Could not start {}: {e}", cli::binary().display()));
|
Err(message)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
follow_login(handle, child, None);
|
if let Err(message) = given {
|
||||||
|
let _ = handle.emit("linking", Linking::Failed { message });
|
||||||
|
}
|
||||||
|
announce(&handle);
|
||||||
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -924,6 +1040,12 @@ fn hand_to_cli(handle: &AppHandle, base: &str, token: String) -> Result<(), Stri
|
|||||||
server::sign_out(base, &token);
|
server::sign_out(base, &token);
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
give_to_cli(handle, base, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `hand_to_cli` once linking is claimed: the CLI takes the token, or it is given up.
|
||||||
|
fn give_to_cli(handle: &AppHandle, base: &str, token: String) -> Result<(), String> {
|
||||||
|
let app = handle.state::<App>();
|
||||||
let spawned = Command::new(cli::binary())
|
let spawned = Command::new(cli::binary())
|
||||||
.args(["login", "--token-stdin", "--server", base, "--json"])
|
.args(["login", "--token-stdin", "--server", base, "--json"])
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
@@ -1361,6 +1483,69 @@ fn watch_chats(handle: AppHandle) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long to wait before following the event stream again: after it closed or broke off, and
|
||||||
|
/// (longer) on a server that has none.
|
||||||
|
const EVENTS_AGAIN: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
const EVENTS_NONE: std::time::Duration = std::time::Duration::from_secs(300);
|
||||||
|
|
||||||
|
/// Follow the server's event stream for the signed-in person (`GET /events`): each `schedules`
|
||||||
|
/// frame goes to the page as it is (the Scheduled page redraws from it), and a scheduled task's
|
||||||
|
/// newly finished run is told by its notify decision (`watch::decide`). Said even with the window
|
||||||
|
/// in front: it ran on its own. Signing out or another server ends the stream (`Watch::reset`).
|
||||||
|
fn watch_events(handle: AppHandle) {
|
||||||
|
std::thread::spawn(move || loop {
|
||||||
|
let app = handle.state::<App>();
|
||||||
|
let Some((app_url, token)) = app.door() else {
|
||||||
|
std::thread::sleep(EVENTS_AGAIN);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let stop = app.watch.stop_flag();
|
||||||
|
let result = server::stream(&app_url, &token, "/events", &stop, |event, data| {
|
||||||
|
if event != "schedules" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Ok(list) = serde_json::from_str::<Value>(data) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let app = handle.state::<App>();
|
||||||
|
let ran = app.watch.look_schedules(&list);
|
||||||
|
let _ = handle.emit("schedules", &list);
|
||||||
|
if ran.is_empty() || !app.settings().notify {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let words = app.watch.words.lock().unwrap().clone();
|
||||||
|
for r in ran {
|
||||||
|
let title = if r.title.is_empty() {
|
||||||
|
"LLeMbas".to_string()
|
||||||
|
} else {
|
||||||
|
r.title
|
||||||
|
};
|
||||||
|
let body = match r.told {
|
||||||
|
watch::Told::Result => &words.scheduled,
|
||||||
|
watch::Told::Failed => &words.scheduled_failed,
|
||||||
|
watch::Told::Pause => &words.scheduled_pause,
|
||||||
|
};
|
||||||
|
let _ = handle
|
||||||
|
.notification()
|
||||||
|
.builder()
|
||||||
|
.title(title)
|
||||||
|
.body(body)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
});
|
||||||
|
match result {
|
||||||
|
Err(e) if e.status == 401 => {
|
||||||
|
token_refused(&handle);
|
||||||
|
std::thread::sleep(EVENTS_AGAIN);
|
||||||
|
}
|
||||||
|
// A server without the stream: look again now and then (it may be updated).
|
||||||
|
Err(e) if e.status == 404 || e.status == 405 => std::thread::sleep(EVENTS_NONE),
|
||||||
|
_ => std::thread::sleep(EVENTS_AGAIN),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Where the app writes what it could not show: a start that fails before the window is up
|
/// Where the app writes what it could not show: a start that fails before the window is up
|
||||||
/// leaves its reason here, not only on a terminal nobody sees.
|
/// leaves its reason here, not only on a terminal nobody sees.
|
||||||
mod log {
|
mod log {
|
||||||
@@ -1446,6 +1631,10 @@ pub fn run() {
|
|||||||
terminal_word,
|
terminal_word,
|
||||||
stream_open,
|
stream_open,
|
||||||
stream_close,
|
stream_close,
|
||||||
|
terminal_open,
|
||||||
|
terminal_input,
|
||||||
|
terminal_resize,
|
||||||
|
terminal_close,
|
||||||
local_list,
|
local_list,
|
||||||
local_read,
|
local_read,
|
||||||
sealed_open,
|
sealed_open,
|
||||||
@@ -1460,6 +1649,7 @@ pub fn run() {
|
|||||||
open_external,
|
open_external,
|
||||||
set_agent,
|
set_agent,
|
||||||
link_computer,
|
link_computer,
|
||||||
|
link_cancel,
|
||||||
titlebar_menu,
|
titlebar_menu,
|
||||||
update_app,
|
update_app,
|
||||||
remote_refresh,
|
remote_refresh,
|
||||||
@@ -1496,6 +1686,7 @@ pub fn run() {
|
|||||||
agent,
|
agent,
|
||||||
tray: Mutex::new(None),
|
tray: Mutex::new(None),
|
||||||
linking: Mutex::new(false),
|
linking: Mutex::new(false),
|
||||||
|
link_cancel: Mutex::new(None),
|
||||||
update: Mutex::new(None),
|
update: Mutex::new(None),
|
||||||
remote: Mutex::new(None),
|
remote: Mutex::new(None),
|
||||||
theme: Mutex::new(theme),
|
theme: Mutex::new(theme),
|
||||||
@@ -1505,6 +1696,7 @@ pub fn run() {
|
|||||||
signing_in: Mutex::new(None),
|
signing_in: Mutex::new(None),
|
||||||
streams: Mutex::new(HashMap::new()),
|
streams: Mutex::new(HashMap::new()),
|
||||||
next_stream: AtomicU64::new(0),
|
next_stream: AtomicU64::new(0),
|
||||||
|
terminals: terminal::Sessions::default(),
|
||||||
sealed: sealed::Pages::default(),
|
sealed: sealed::Pages::default(),
|
||||||
watch: watch::Watch::default(),
|
watch: watch::Watch::default(),
|
||||||
dropped: Mutex::new(Vec::new()),
|
dropped: Mutex::new(Vec::new()),
|
||||||
@@ -1518,6 +1710,7 @@ pub fn run() {
|
|||||||
build_main(&handle)?;
|
build_main(&handle)?;
|
||||||
watch_updates(handle.clone());
|
watch_updates(handle.clone());
|
||||||
watch_chats(handle.clone());
|
watch_chats(handle.clone());
|
||||||
|
watch_events(handle.clone());
|
||||||
{
|
{
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
|
|||||||
+71
-11
@@ -271,6 +271,18 @@ pub struct Code {
|
|||||||
|
|
||||||
/// What a device login asks the server for, as the form it posts.
|
/// What a device login asks the server for, as the form it posts.
|
||||||
fn code_request(host: &str, with_agent: bool) -> Value {
|
fn code_request(host: &str, with_agent: bool) -> Value {
|
||||||
|
code_request_for(host, Ask::App { with_agent })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which login: the app's own (and the agent's beside it, in the same approval), or the agent's
|
||||||
|
/// alone -- linking this computer again, which needs no second token for the app.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub enum Ask {
|
||||||
|
App { with_agent: bool },
|
||||||
|
Agent,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn code_request_for(host: &str, ask: Ask) -> Value {
|
||||||
let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH);
|
let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH);
|
||||||
let device_name = if host.is_empty() {
|
let device_name = if host.is_empty() {
|
||||||
"LLeMbas Desktop".to_string()
|
"LLeMbas Desktop".to_string()
|
||||||
@@ -283,7 +295,13 @@ fn code_request(host: &str, with_agent: bool) -> Value {
|
|||||||
"device_platform": platform,
|
"device_platform": platform,
|
||||||
"scope": APP_SCOPE,
|
"scope": APP_SCOPE,
|
||||||
});
|
});
|
||||||
if with_agent {
|
if ask == Ask::Agent {
|
||||||
|
// The agent's token alone: the device an agent chat runs on, named by its host.
|
||||||
|
body["scope"] = Value::String(AGENT_SCOPE.to_string());
|
||||||
|
body["device_host"] = Value::String(host.to_string());
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
if matches!(ask, Ask::App { with_agent: true }) {
|
||||||
// The agent's token is the device an agent chat runs on: it carries this computer's
|
// The agent's token is the device an agent chat runs on: it carries this computer's
|
||||||
// host, as `lembas login` sends it, so the app knows which device is this one. The
|
// host, as `lembas login` sends it, so the app knows which device is this one. The
|
||||||
// app's own token alone names no host: it is not a device.
|
// app's own token alone names no host: it is not a device.
|
||||||
@@ -296,10 +314,22 @@ fn code_request(host: &str, with_agent: bool) -> Value {
|
|||||||
/// Open a device login for the app's scopes, and the agent's too when `with_agent` (only where
|
/// Open a device login for the app's scopes, and the agent's too when `with_agent` (only where
|
||||||
/// discovery offered it).
|
/// discovery offered it).
|
||||||
pub fn start_sign_in(d: &Discovery, host: &str, with_agent: bool) -> Result<Code, String> {
|
pub fn start_sign_in(d: &Discovery, host: &str, with_agent: bool) -> Result<Code, String> {
|
||||||
|
start_code(d, code_request(host, with_agent && d.one_approval))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a device login for the agent's scopes alone: linking this computer (again), with the
|
||||||
|
/// token handed to the CLI as the app's own approval hands it. A login the CLI made itself
|
||||||
|
/// (`lembas login <server>`) would not do here: for a server it is logged in to already, that
|
||||||
|
/// only refreshes the login it has -- a token the server refuses stays refused.
|
||||||
|
pub fn start_link(d: &Discovery, host: &str) -> Result<Code, String> {
|
||||||
|
start_code(d, code_request_for(host, Ask::Agent))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_code(d: &Discovery, form: Value) -> Result<Code, String> {
|
||||||
let res = agent(Duration::from_secs(15))
|
let res = agent(Duration::from_secs(15))
|
||||||
.post(&d.device_code)
|
.post(&d.device_code)
|
||||||
.set("accept", "application/json")
|
.set("accept", "application/json")
|
||||||
.send_json(code_request(host, with_agent && d.one_approval))
|
.send_json(form)
|
||||||
.map_err(|e| oauth_error(e, "The server refused to start a sign-in"))?;
|
.map_err(|e| oauth_error(e, "The server refused to start a sign-in"))?;
|
||||||
let v: Value = res
|
let v: Value = res
|
||||||
.into_json()
|
.into_json()
|
||||||
@@ -509,22 +539,32 @@ pub fn request_text(app: &str, token: &str, path: &str) -> Result<String, ApiErr
|
|||||||
|
|
||||||
/// The most a model's picture may be: the server's own limit for one (2 MB), and a little room.
|
/// The most a model's picture may be: the server's own limit for one (2 MB), and a little room.
|
||||||
pub const IMAGE_LIMIT: u64 = 2 * 1024 * 1024 + 4096;
|
pub const IMAGE_LIMIT: u64 = 2 * 1024 * 1024 + 4096;
|
||||||
|
/// The most an attached picture shown as a thumbnail may be: a screenshot fits; a larger one
|
||||||
|
/// shows as a file.
|
||||||
|
pub const ATTACHMENT_IMAGE_LIMIT: u64 = 8 * 1024 * 1024;
|
||||||
|
|
||||||
/// The pictures a model may have, by the type the server serves them as: the four it accepts on
|
/// The pictures a model may have, by the type the server serves them as: the four it accepts on
|
||||||
/// upload. Nothing else is shown, whatever the server says it is.
|
/// upload. Nothing else is shown, whatever the server says it is.
|
||||||
const IMAGE_TYPES: [&str; 4] = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
const IMAGE_TYPES: [&str; 4] = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
||||||
|
|
||||||
/// A model's picture path as `/api/v2/models` names it (`/api/v2/models/images/<file>`), on the
|
/// A picture's path on the app's door, or None for anything else: a model's picture as
|
||||||
/// app's door: `/models/images/<file>`, or None for anything else. One plain file name, no more.
|
/// `/api/v2/models` names it (`/models/images/<file>`), or a message's attached file
|
||||||
|
/// (`/attachments/<id>`, shown as a thumbnail when the server serves it as a picture). One plain
|
||||||
|
/// name after the prefix, no more.
|
||||||
pub fn image_path(path: &str) -> Option<String> {
|
pub fn image_path(path: &str) -> Option<String> {
|
||||||
let rest = path.strip_prefix("/api/v2").unwrap_or(path);
|
let rest = path.strip_prefix("/api/v2").unwrap_or(path);
|
||||||
let name = rest.strip_prefix("/models/images/")?;
|
let plain = |name: &str| {
|
||||||
let plain = !name.is_empty()
|
!name.is_empty()
|
||||||
&& name
|
&& name
|
||||||
.chars()
|
.chars()
|
||||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
|
||||||
&& !name.starts_with('.');
|
&& !name.starts_with('.')
|
||||||
plain.then(|| format!("/models/images/{name}"))
|
};
|
||||||
|
if let Some(name) = rest.strip_prefix("/models/images/") {
|
||||||
|
return plain(name).then(|| format!("/models/images/{name}"));
|
||||||
|
}
|
||||||
|
let id = rest.strip_prefix("/attachments/")?;
|
||||||
|
(plain(id) && !id.contains('.')).then(|| format!("/attachments/{id}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A model's picture, as a `data:` URL the page can show without a request of its own (its
|
/// A model's picture, as a `data:` URL the page can show without a request of its own (its
|
||||||
@@ -532,7 +572,7 @@ pub fn image_path(path: &str) -> Option<String> {
|
|||||||
/// token, as the page reads nothing itself.
|
/// token, as the page reads nothing itself.
|
||||||
pub fn request_image(app: &str, token: &str, path: &str) -> Result<String, ApiError> {
|
pub fn request_image(app: &str, token: &str, path: &str) -> Result<String, ApiError> {
|
||||||
let Some(path) = image_path(path) else {
|
let Some(path) = image_path(path) else {
|
||||||
return Err(ApiError::new(400, format!("Not a model's picture: {path}")));
|
return Err(ApiError::new(400, format!("Not a picture's path: {path}")));
|
||||||
};
|
};
|
||||||
let res = agent(Duration::from_secs(30))
|
let res = agent(Duration::from_secs(30))
|
||||||
.get(&format!("{app}{path}"))
|
.get(&format!("{app}{path}"))
|
||||||
@@ -548,12 +588,17 @@ pub fn request_image(app: &str, token: &str, path: &str) -> Result<String, ApiEr
|
|||||||
if !IMAGE_TYPES.contains(&kind.as_str()) {
|
if !IMAGE_TYPES.contains(&kind.as_str()) {
|
||||||
return Err(ApiError::new(415, format!("Not a picture: {kind}")));
|
return Err(ApiError::new(415, format!("Not a picture: {kind}")));
|
||||||
}
|
}
|
||||||
|
let limit = if path.starts_with("/attachments/") {
|
||||||
|
ATTACHMENT_IMAGE_LIMIT
|
||||||
|
} else {
|
||||||
|
IMAGE_LIMIT
|
||||||
|
};
|
||||||
let mut bytes = Vec::new();
|
let mut bytes = Vec::new();
|
||||||
res.into_reader()
|
res.into_reader()
|
||||||
.take(IMAGE_LIMIT)
|
.take(limit)
|
||||||
.read_to_end(&mut bytes)
|
.read_to_end(&mut bytes)
|
||||||
.map_err(|e| ApiError::new(0, format!("The server's answer could not be read: {e}")))?;
|
.map_err(|e| ApiError::new(0, format!("The server's answer could not be read: {e}")))?;
|
||||||
if bytes.len() as u64 >= IMAGE_LIMIT {
|
if bytes.len() as u64 >= limit {
|
||||||
return Err(ApiError::new(413, "The picture is too large."));
|
return Err(ApiError::new(413, "The picture is too large."));
|
||||||
}
|
}
|
||||||
Ok(format!("data:{kind};base64,{}", base64(&bytes)))
|
Ok(format!("data:{kind};base64,{}", base64(&bytes)))
|
||||||
@@ -793,6 +838,14 @@ mod tests {
|
|||||||
assert!(app_only.get("device_host").is_none());
|
assert!(app_only.get("device_host").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linking_again_asks_for_the_agents_token_alone_and_names_the_host() {
|
||||||
|
let link = code_request_for("box", Ask::Agent);
|
||||||
|
assert_eq!(link["scope"], "models memory link");
|
||||||
|
assert_eq!(link["device_host"], "box");
|
||||||
|
assert!(link.get("agent_scope").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_approval_gives_the_app_its_token_and_the_agent_its_own() {
|
fn an_approval_gives_the_app_its_token_and_the_agent_its_own() {
|
||||||
let v = json!({
|
let v = json!({
|
||||||
@@ -874,12 +927,19 @@ mod tests {
|
|||||||
image_path("/models/images/a-b_c.webp").as_deref(),
|
image_path("/models/images/a-b_c.webp").as_deref(),
|
||||||
Some("/models/images/a-b_c.webp")
|
Some("/models/images/a-b_c.webp")
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
image_path("/api/v2/attachments/att_01x").as_deref(),
|
||||||
|
Some("/attachments/att_01x")
|
||||||
|
);
|
||||||
for bad in [
|
for bad in [
|
||||||
"/api/v2/models/images/",
|
"/api/v2/models/images/",
|
||||||
"/api/v2/models/images/../me",
|
"/api/v2/models/images/../me",
|
||||||
"/api/v2/models/images/a/b.png",
|
"/api/v2/models/images/a/b.png",
|
||||||
"/api/v2/models/images/.hidden",
|
"/api/v2/models/images/.hidden",
|
||||||
"/api/v2/chats/x",
|
"/api/v2/chats/x",
|
||||||
|
"/api/v2/attachments/",
|
||||||
|
"/api/v2/attachments/a/b",
|
||||||
|
"/api/v2/attachments/..",
|
||||||
"/uploads/models/a.png",
|
"/uploads/models/a.png",
|
||||||
"https://elsewhere/models/images/a.png",
|
"https://elsewhere/models/images/a.png",
|
||||||
] {
|
] {
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
//! The terminal panel's socket: a shell on a linked device, through the server's app door.
|
||||||
|
//!
|
||||||
|
//! The server has two WebSockets for an app (`/api/v2`, behind the app's token):
|
||||||
|
//!
|
||||||
|
//! - `/chats/{chat}/terminal/ws` -- an agent chat's shell, the one the web page's panel opens
|
||||||
|
//! too, so the two share it;
|
||||||
|
//! - `/devices/{device}/terminal/ws?directory=` -- before the chat exists, on the device and in
|
||||||
|
//! the folder a new agent chat has chosen. The chat's first message takes that shell over.
|
||||||
|
//!
|
||||||
|
//! Both take `cols` and `rows`. Down: the shell's output as binary frames, and text frames
|
||||||
|
//! `{"t": "ready" | "closed" | "behind" | "error", ...}`. Up: keystrokes as binary,
|
||||||
|
//! `{"t": "resize", "cols", "rows"}` as text. A refused token closes with 4401.
|
||||||
|
//!
|
||||||
|
//! **The token stays here.** The socket is opened in Rust with `Authorization: Bearer` in the
|
||||||
|
//! handshake, and the page only ever gets the frames (as `terminal` events) and sends keys,
|
||||||
|
//! sizes and "close" through commands -- the same rule as every other request the page makes.
|
||||||
|
//! A page cannot open the socket itself: its content security policy allows it no connection
|
||||||
|
//! but the bridge.
|
||||||
|
//!
|
||||||
|
//! One thread per terminal owns its socket: it reads with a short timeout and, between reads,
|
||||||
|
//! sends what the page queued on its channel. Blocking sockets, no async runtime of its own.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
use std::net::{TcpStream, ToSocketAddrs};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tungstenite::http::Uri;
|
||||||
|
use tungstenite::stream::MaybeTlsStream;
|
||||||
|
use tungstenite::{ClientRequestBuilder, Error as WsError, HandshakeError, Message, WebSocket};
|
||||||
|
|
||||||
|
use crate::server::{base64, ApiError};
|
||||||
|
|
||||||
|
/// The server's close code for a token it refused (`api/v2/terminal.py`).
|
||||||
|
pub const CLOSE_UNAUTHORIZED: u16 = 4401;
|
||||||
|
/// The most one keystroke message may be: the server's own limit (a paste, at most).
|
||||||
|
pub const MAX_INPUT: usize = 256 * 1024;
|
||||||
|
/// The longest directory the server takes (`api/terminal.py` `MAX_DIR_CHARS`).
|
||||||
|
const MAX_DIR: usize = 4096;
|
||||||
|
/// How long a read waits before the thread looks at what the page queued.
|
||||||
|
const POLL: Duration = Duration::from_millis(40);
|
||||||
|
/// How long a close waits for the server's answer before the socket is simply dropped.
|
||||||
|
const CLOSE_WAIT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
type Socket = WebSocket<MaybeTlsStream<TcpStream>>;
|
||||||
|
|
||||||
|
/// Where the shell is: an agent chat's, or a device's folder before the chat exists.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum Target {
|
||||||
|
Chat(String),
|
||||||
|
Device { device: String, directory: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An id as the server makes them (a chat's, a device token's): letters, digits, `-` and `_`.
|
||||||
|
fn id_ok(id: &str) -> bool {
|
||||||
|
!id.is_empty()
|
||||||
|
&& id.len() <= 128
|
||||||
|
&& id
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A query value, percent-encoded: every byte but the unreserved ones (RFC 3986 §2.3).
|
||||||
|
pub fn encode(text: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
for b in text.bytes() {
|
||||||
|
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
|
||||||
|
out.push(b as char);
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("%{b:02X}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The socket's address on the app door (`https://host/api/v2` -> `wss://host/api/v2/...`).
|
||||||
|
pub fn url(app: &str, target: &Target, cols: u16, rows: u16) -> Result<String, ApiError> {
|
||||||
|
let door = if let Some(rest) = app.strip_prefix("https://") {
|
||||||
|
format!("wss://{rest}")
|
||||||
|
} else if let Some(rest) = app.strip_prefix("http://") {
|
||||||
|
format!("ws://{rest}")
|
||||||
|
} else {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
400,
|
||||||
|
"The server's address is not http or https.",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let cols = cols.clamp(2, 1000);
|
||||||
|
let rows = rows.clamp(1, 500);
|
||||||
|
match target {
|
||||||
|
Target::Chat(chat) => {
|
||||||
|
if !id_ok(chat) {
|
||||||
|
return Err(ApiError::new(400, "Not a chat."));
|
||||||
|
}
|
||||||
|
Ok(format!(
|
||||||
|
"{door}/chats/{chat}/terminal/ws?cols={cols}&rows={rows}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Target::Device { device, directory } => {
|
||||||
|
if !id_ok(device) {
|
||||||
|
return Err(ApiError::new(400, "Not a device."));
|
||||||
|
}
|
||||||
|
let directory = directory.trim();
|
||||||
|
if directory.is_empty() || directory.len() > MAX_DIR || directory.contains('\0') {
|
||||||
|
return Err(ApiError::new(400, "Not a folder."));
|
||||||
|
}
|
||||||
|
Ok(format!(
|
||||||
|
"{door}/devices/{device}/terminal/ws?directory={}&cols={cols}&rows={rows}",
|
||||||
|
encode(directory)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the page asked the socket's thread to do.
|
||||||
|
pub enum Up {
|
||||||
|
Keys(Vec<u8>),
|
||||||
|
Resize(u16, u16),
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One frame for the page, as the `terminal` event: `output` (the bytes, base64), `control`
|
||||||
|
/// (the server's JSON), or `end` (how it ended: `{code, reason, status, error}`).
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct Down {
|
||||||
|
pub id: u64,
|
||||||
|
pub kind: &'static str,
|
||||||
|
pub data: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a terminal's socket ended. `code` is the close frame's (4401: the token was refused),
|
||||||
|
/// `status` an HTTP status the handshake was answered with, `error` why it could not connect or
|
||||||
|
/// broke off.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||||
|
pub struct End {
|
||||||
|
pub code: Option<u16>,
|
||||||
|
pub reason: String,
|
||||||
|
pub status: Option<u16>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl End {
|
||||||
|
fn failed(error: impl Into<String>) -> Self {
|
||||||
|
End {
|
||||||
|
error: Some(error.into()),
|
||||||
|
..End::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server refused the app's token: the page signs in again.
|
||||||
|
pub fn unauthorized(&self) -> bool {
|
||||||
|
self.code == Some(CLOSE_UNAUTHORIZED) || self.status == Some(401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The open terminals: what each one's thread listens to.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Sessions {
|
||||||
|
next: AtomicU64,
|
||||||
|
open: Mutex<HashMap<u64, Sender<Up>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sessions {
|
||||||
|
/// Open a terminal's socket on its own thread. `emit` gets every frame, the `end` last;
|
||||||
|
/// `ended` is told how it ended just before. The id is the page's handle on it.
|
||||||
|
pub fn open(
|
||||||
|
&self,
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
emit: impl Fn(Down) + Send + 'static,
|
||||||
|
ended: impl FnOnce(u64, &End) + Send + 'static,
|
||||||
|
) -> u64 {
|
||||||
|
let id = self.next.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
self.open.lock().unwrap().insert(id, tx);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let send = |kind: &'static str, data: Value| emit(Down { id, kind, data });
|
||||||
|
let end = run(&url, &token, &rx, &send);
|
||||||
|
ended(id, &end);
|
||||||
|
send("end", serde_json::to_value(&end).unwrap_or(Value::Null));
|
||||||
|
});
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand the page's keys, size or close to the terminal's thread. False: no such terminal.
|
||||||
|
pub fn send(&self, id: u64, up: Up) -> bool {
|
||||||
|
self.open
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&id)
|
||||||
|
.is_some_and(|tx| tx.send(up).is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close one terminal (the page's panel let go of it).
|
||||||
|
pub fn close(&self, id: u64) {
|
||||||
|
if let Some(tx) = self.open.lock().unwrap().remove(&id) {
|
||||||
|
let _ = tx.send(Up::Close);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A terminal's thread has ended: forget it.
|
||||||
|
pub fn forget(&self, id: u64) {
|
||||||
|
self.open.lock().unwrap().remove(&id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every terminal closed (signed out, or another server chosen).
|
||||||
|
pub fn close_all(&self) {
|
||||||
|
for (_, tx) in self.open.lock().unwrap().drain() {
|
||||||
|
let _ = tx.send(Up::Close);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The TCP socket under the WebSocket, to set its read timeout.
|
||||||
|
fn tcp(ws: &Socket) -> Option<&TcpStream> {
|
||||||
|
match ws.get_ref() {
|
||||||
|
MaybeTlsStream::Plain(s) => Some(s),
|
||||||
|
MaybeTlsStream::Rustls(s) => Some(s.get_ref()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connect(url: &str, token: &str) -> Result<Socket, End> {
|
||||||
|
let uri: Uri = url
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| End::failed(format!("Not an address: {e}")))?;
|
||||||
|
let host = uri
|
||||||
|
.host()
|
||||||
|
.ok_or_else(|| End::failed("The address has no host."))?
|
||||||
|
.trim_start_matches('[')
|
||||||
|
.trim_end_matches(']')
|
||||||
|
.to_string();
|
||||||
|
let port = uri
|
||||||
|
.port_u16()
|
||||||
|
.unwrap_or(if uri.scheme_str() == Some("wss") {
|
||||||
|
443
|
||||||
|
} else {
|
||||||
|
80
|
||||||
|
});
|
||||||
|
let addrs = (host.as_str(), port)
|
||||||
|
.to_socket_addrs()
|
||||||
|
.map_err(|e| End::failed(format!("Could not reach the server: {e}")))?;
|
||||||
|
let mut last = String::from("no address");
|
||||||
|
let mut stream = None;
|
||||||
|
for addr in addrs {
|
||||||
|
match TcpStream::connect_timeout(&addr, Duration::from_secs(10)) {
|
||||||
|
Ok(s) => {
|
||||||
|
stream = Some(s);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => last = e.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let stream =
|
||||||
|
stream.ok_or_else(|| End::failed(format!("Could not reach the server: {last}")))?;
|
||||||
|
// The handshake may take its time; reads only poll once it is done.
|
||||||
|
let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
|
||||||
|
let _ = stream.set_nodelay(true);
|
||||||
|
let request = ClientRequestBuilder::new(uri)
|
||||||
|
.with_header("Authorization", format!("Bearer {token}"))
|
||||||
|
.with_header(
|
||||||
|
"User-Agent",
|
||||||
|
format!("llembas-desktop/{}", env!("CARGO_PKG_VERSION")),
|
||||||
|
);
|
||||||
|
match tungstenite::client_tls(request, stream) {
|
||||||
|
Ok((ws, _)) => Ok(ws),
|
||||||
|
Err(HandshakeError::Failure(WsError::Http(res))) => Err(End {
|
||||||
|
status: Some(res.status().as_u16()),
|
||||||
|
..End::default()
|
||||||
|
}),
|
||||||
|
Err(HandshakeError::Failure(e)) => Err(End::failed(format!("Could not connect: {e}"))),
|
||||||
|
Err(HandshakeError::Interrupted(_)) => Err(End::failed("The server did not answer.")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A read that only timed out: nothing came in this poll.
|
||||||
|
fn idle(e: &WsError) -> bool {
|
||||||
|
matches!(e, WsError::Io(io) if matches!(io.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(url: &str, token: &str, rx: &Receiver<Up>, send: &dyn Fn(&'static str, Value)) -> End {
|
||||||
|
let mut ws = match connect(url, token) {
|
||||||
|
Ok(ws) => ws,
|
||||||
|
Err(end) => return end,
|
||||||
|
};
|
||||||
|
match tcp(&ws) {
|
||||||
|
Some(s) => {
|
||||||
|
let _ = s.set_read_timeout(Some(POLL));
|
||||||
|
}
|
||||||
|
None => return End::failed("An unexpected kind of connection."),
|
||||||
|
}
|
||||||
|
let mut closed_by_server: Option<(u16, String)> = None;
|
||||||
|
let mut closing: Option<Instant> = None;
|
||||||
|
loop {
|
||||||
|
// What the page queued, until the server has said goodbye.
|
||||||
|
if closing.is_none() && closed_by_server.is_none() {
|
||||||
|
loop {
|
||||||
|
let sent = match rx.try_recv() {
|
||||||
|
Ok(Up::Keys(bytes)) if bytes.len() <= MAX_INPUT => {
|
||||||
|
ws.send(Message::binary(bytes))
|
||||||
|
}
|
||||||
|
Ok(Up::Keys(_)) => Ok(()),
|
||||||
|
Ok(Up::Resize(cols, rows)) => ws.send(Message::text(
|
||||||
|
json!({ "t": "resize", "cols": cols, "rows": rows }).to_string(),
|
||||||
|
)),
|
||||||
|
Ok(Up::Close) | Err(TryRecvError::Disconnected) => {
|
||||||
|
closing = Some(Instant::now());
|
||||||
|
let _ = ws.close(None);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Empty) => break,
|
||||||
|
};
|
||||||
|
if let Err(e) = sent {
|
||||||
|
if !idle(&e) {
|
||||||
|
return End::failed(format!("The connection broke off: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match ws.read() {
|
||||||
|
Ok(Message::Binary(bytes)) => send("output", Value::String(base64(&bytes))),
|
||||||
|
Ok(Message::Text(text)) => {
|
||||||
|
if let Ok(value) = serde_json::from_str::<Value>(text.as_str()) {
|
||||||
|
send("control", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Message::Close(frame)) => {
|
||||||
|
let (code, reason) = frame
|
||||||
|
.map(|f| (u16::from(f.code), f.reason.as_str().to_string()))
|
||||||
|
.unwrap_or((1005, String::new()));
|
||||||
|
closed_by_server = Some((code, reason));
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) if idle(&e) => {
|
||||||
|
if closing.is_some_and(|at| at.elapsed() > CLOSE_WAIT) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(WsError::ConnectionClosed) | Err(WsError::AlreadyClosed) => break,
|
||||||
|
Err(e) => {
|
||||||
|
if closing.is_some() || closed_by_server.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return End::failed(format!("The connection broke off: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match closed_by_server {
|
||||||
|
Some((code, reason)) => End {
|
||||||
|
code: Some(code),
|
||||||
|
reason,
|
||||||
|
..End::default()
|
||||||
|
},
|
||||||
|
None => End::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_chats_terminal_is_on_the_app_door_with_its_size() {
|
||||||
|
let t = Target::Chat("chat_1-a".into());
|
||||||
|
assert_eq!(
|
||||||
|
url("https://ai.example/api/v2", &t, 80, 24).unwrap(),
|
||||||
|
"wss://ai.example/api/v2/chats/chat_1-a/terminal/ws?cols=80&rows=24"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
url("http://ai.example:8080/sub/api/v2", &t, 0, 9999).unwrap(),
|
||||||
|
"ws://ai.example:8080/sub/api/v2/chats/chat_1-a/terminal/ws?cols=2&rows=500"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_terminal_before_the_chat_carries_its_folder_encoded() {
|
||||||
|
let t = Target::Device {
|
||||||
|
device: "token1".into(),
|
||||||
|
directory: " /home/dev/my project&x=1 ".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
url("https://ai.example/api/v2", &t, 100, 30).unwrap(),
|
||||||
|
"wss://ai.example/api/v2/devices/token1/terminal/ws?directory=%2Fhome%2Fdev%2Fmy%20project%26x%3D1&cols=100&rows=30"
|
||||||
|
);
|
||||||
|
assert_eq!(encode("C:\\Users\\Ádám"), "C%3A%5CUsers%5C%C3%81d%C3%A1m");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_but_an_id_and_a_folder_reaches_the_address() {
|
||||||
|
let door = "https://ai.example/api/v2";
|
||||||
|
for bad in ["", "../me", "a/b", "a?b", "a b", "x#y"] {
|
||||||
|
assert!(
|
||||||
|
url(door, &Target::Chat(bad.into()), 80, 24).is_err(),
|
||||||
|
"{bad}"
|
||||||
|
);
|
||||||
|
let dev = Target::Device {
|
||||||
|
device: bad.into(),
|
||||||
|
directory: "/tmp".into(),
|
||||||
|
};
|
||||||
|
assert!(url(door, &dev, 80, 24).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
for dir in ["", " ", "a\0b"] {
|
||||||
|
let dev = Target::Device {
|
||||||
|
device: "token1".into(),
|
||||||
|
directory: dir.into(),
|
||||||
|
};
|
||||||
|
assert!(url(door, &dev, 80, 24).is_err(), "{dir:?}");
|
||||||
|
}
|
||||||
|
assert!(url("ftp://ai.example", &Target::Chat("c".into()), 80, 24).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refused_token_is_told_apart_from_a_dropped_line() {
|
||||||
|
let refused = End {
|
||||||
|
code: Some(CLOSE_UNAUTHORIZED),
|
||||||
|
..End::default()
|
||||||
|
};
|
||||||
|
assert!(refused.unauthorized());
|
||||||
|
assert!(End {
|
||||||
|
status: Some(401),
|
||||||
|
..End::default()
|
||||||
|
}
|
||||||
|
.unauthorized());
|
||||||
|
assert!(!End::failed("reset").unauthorized());
|
||||||
|
assert!(!End {
|
||||||
|
code: Some(1000),
|
||||||
|
..End::default()
|
||||||
|
}
|
||||||
|
.unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_for_a_closed_terminal_go_nowhere() {
|
||||||
|
let s = Sessions::default();
|
||||||
|
assert!(!s.send(7, Up::Keys(b"ls\n".to_vec())));
|
||||||
|
s.close(7);
|
||||||
|
s.close_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
+234
-2
@@ -1,14 +1,26 @@
|
|||||||
//! Native notifications: a reply finished, or an agent chat's work did, while the person was not
|
//! Native notifications: a reply finished, or an agent chat's work did, while the person was not
|
||||||
//! looking at it. The app's own, so the server's Web Push can stay off.
|
//! looking at it; and a scheduled task has a result the server decided to tell of. The app's own,
|
||||||
|
//! so the server's Web Push can stay off.
|
||||||
//!
|
//!
|
||||||
//! The app asks the server's chat list (`/api/v2/chats`) every `EVERY` and notes which chats are
|
//! The app asks the server's chat list (`/api/v2/chats`) every `EVERY` and notes which chats are
|
||||||
//! being answered; a chat that was and no longer is has finished. The notification is shown
|
//! being answered; a chat that was and no longer is has finished. The notification is shown
|
||||||
//! unless that chat is open in a window in front. Its words come from the page (`set_words`), in
|
//! unless that chat is open in a window in front. Its words come from the page (`set_words`), in
|
||||||
//! the page's language; the chat's title is the person's own. Nothing from the reply itself is
|
//! the page's language; the chat's title is the person's own. Nothing from the reply itself is
|
||||||
//! put on screen outside the app.
|
//! put on screen outside the app.
|
||||||
|
//!
|
||||||
|
//! Scheduled tasks: the app follows the server's own event stream (`GET /api/v2/events`), whose
|
||||||
|
//! `schedules` frame carries the whole list (`{"version", "schedules": [schedule]}`) whenever it
|
||||||
|
//! changes -- a run starting, waiting, finishing. A schedule's last run that has newly finished is
|
||||||
|
//! told by the server's notify decision for it: `notified` when the server already told a device,
|
||||||
|
//! else the schedule's `notify` policy as the server applies it (`services/schedule/notify.py`:
|
||||||
|
//! `always` -- a result, even "nothing to report"; `matters` -- a result, never a `[SILENT]` one;
|
||||||
|
//! `never` -- nothing), and a failure the first time in a row and again on the third, which asks
|
||||||
|
//! whether to pause it. Said by the schedule's title, never the result's words. A server without
|
||||||
|
//! the stream answers 404, and nothing is said.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -23,6 +35,27 @@ pub struct Words {
|
|||||||
pub reply: String,
|
pub reply: String,
|
||||||
/// An agent chat has finished its work.
|
/// An agent chat has finished its work.
|
||||||
pub agent: String,
|
pub agent: String,
|
||||||
|
/// A scheduled task has a result.
|
||||||
|
#[serde(default = "scheduled_word")]
|
||||||
|
pub scheduled: String,
|
||||||
|
/// A scheduled task's run failed.
|
||||||
|
#[serde(default = "scheduled_failed_word")]
|
||||||
|
pub scheduled_failed: String,
|
||||||
|
/// A scheduled task failed three times in a row: pause it?
|
||||||
|
#[serde(default = "scheduled_pause_word")]
|
||||||
|
pub scheduled_pause: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scheduled_word() -> String {
|
||||||
|
"A scheduled task has a result.".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scheduled_failed_word() -> String {
|
||||||
|
"A scheduled task failed.".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scheduled_pause_word() -> String {
|
||||||
|
"A scheduled task failed 3 times in a row. Pause it?".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Words {
|
impl Default for Words {
|
||||||
@@ -30,6 +63,9 @@ impl Default for Words {
|
|||||||
Words {
|
Words {
|
||||||
reply: "The reply is ready.".into(),
|
reply: "The reply is ready.".into(),
|
||||||
agent: "The agent has finished.".into(),
|
agent: "The agent has finished.".into(),
|
||||||
|
scheduled: scheduled_word(),
|
||||||
|
scheduled_failed: scheduled_failed_word(),
|
||||||
|
scheduled_pause: scheduled_pause_word(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,6 +85,56 @@ pub struct Watch {
|
|||||||
pub words: Mutex<Words>,
|
pub words: Mutex<Words>,
|
||||||
/// Which chats were being answered at the last look (None before the first look).
|
/// Which chats were being answered at the last look (None before the first look).
|
||||||
running: Mutex<Option<HashMap<String, bool>>>,
|
running: Mutex<Option<HashMap<String, bool>>>,
|
||||||
|
/// Each schedule's last run at the last look, and whether it had finished (None before the
|
||||||
|
/// first look).
|
||||||
|
runs: Mutex<Option<HashMap<String, (String, bool)>>>,
|
||||||
|
/// Set to end the event stream that is followed now; `reset` sets it and puts a new one here.
|
||||||
|
stop: Mutex<Arc<AtomicBool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a scheduled task's run is told.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub enum Told {
|
||||||
|
/// It has a result.
|
||||||
|
Result,
|
||||||
|
/// It failed.
|
||||||
|
Failed,
|
||||||
|
/// It failed for the third time in a row: pause it?
|
||||||
|
Pause,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A scheduled task's newly finished run that is worth telling of.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Ran {
|
||||||
|
pub title: String,
|
||||||
|
pub told: Told,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The run statuses that are an end (`db/models/schedule.py`): `running` and `waiting` are not.
|
||||||
|
const FINISHED: [&str; 5] = ["ok", "silent", "failed", "skipped", "missed"];
|
||||||
|
/// The failure in a row that asks whether to pause (`notify.PAUSE_AFTER`).
|
||||||
|
const PAUSE_AFTER: i64 = 3;
|
||||||
|
|
||||||
|
/// Whether a finished run is told, and how: the server's word when it already told (`notified`),
|
||||||
|
/// else its notify policy for the schedule as the server applies it.
|
||||||
|
pub fn decide(policy: &str, status: &str, notified: bool, streak: i64) -> Option<Told> {
|
||||||
|
let failed = matches!(status, "failed" | "skipped");
|
||||||
|
let kind = if failed && streak == PAUSE_AFTER {
|
||||||
|
Told::Pause
|
||||||
|
} else if failed {
|
||||||
|
Told::Failed
|
||||||
|
} else {
|
||||||
|
Told::Result
|
||||||
|
};
|
||||||
|
if notified {
|
||||||
|
return Some(kind);
|
||||||
|
}
|
||||||
|
match (policy, status) {
|
||||||
|
("never", _) => None,
|
||||||
|
(_, "ok") | ("always", "silent") => Some(Told::Result),
|
||||||
|
_ if failed && (streak <= 1 || streak == PAUSE_AFTER) => Some(kind),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Watch {
|
impl Watch {
|
||||||
@@ -89,9 +175,71 @@ impl Watch {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Note the schedules as the event stream gave them (`{"schedules": [...]}`); the runs that
|
||||||
|
/// finished since the last look and are told. The first look only notes, as with the chats; a
|
||||||
|
/// run is told once, however often the list comes again.
|
||||||
|
pub fn look_schedules(&self, list: &Value) -> Vec<Ran> {
|
||||||
|
let rows = list
|
||||||
|
.get("schedules")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut seen = self.runs.lock().unwrap();
|
||||||
|
let first = seen.is_none();
|
||||||
|
let mut now = HashMap::new();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for row in &rows {
|
||||||
|
let Some(id) = row.get("id").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(run) = row.get("last_run").filter(|r| r.is_object()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(run_id) = run.get("id").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let status = run.get("status").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let finished = FINISHED.contains(&status);
|
||||||
|
let before = seen.as_ref().and_then(|s| s.get(id));
|
||||||
|
let known = before.is_some_and(|(r, done)| r == run_id && *done);
|
||||||
|
if !first && finished && !known {
|
||||||
|
let told = decide(
|
||||||
|
row.get("notify").and_then(Value::as_str).unwrap_or(""),
|
||||||
|
status,
|
||||||
|
run.get("notified").and_then(Value::as_bool) == Some(true),
|
||||||
|
row.get("failure_streak")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.unwrap_or(0),
|
||||||
|
);
|
||||||
|
if let Some(told) = told {
|
||||||
|
out.push(Ran {
|
||||||
|
title: row
|
||||||
|
.get("title")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
told,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now.insert(id.to_string(), (run_id.to_string(), finished));
|
||||||
|
}
|
||||||
|
*seen = Some(now);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The flag that ends the event stream followed now.
|
||||||
|
pub fn stop_flag(&self) -> Arc<AtomicBool> {
|
||||||
|
self.stop.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Start again from nothing: another server, or signed out.
|
/// Start again from nothing: another server, or signed out.
|
||||||
pub fn reset(&self) {
|
pub fn reset(&self) {
|
||||||
*self.running.lock().unwrap() = None;
|
*self.running.lock().unwrap() = None;
|
||||||
|
*self.runs.lock().unwrap() = None;
|
||||||
|
let mut stop = self.stop.lock().unwrap();
|
||||||
|
stop.store(true, Ordering::SeqCst);
|
||||||
|
*stop = Arc::new(AtomicBool::new(false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +248,90 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// A schedule in a test: its id, notify policy, failure streak, and last run (id, status,
|
||||||
|
/// whether the server told of it).
|
||||||
|
type Row<'a> = (&'a str, &'a str, i64, Option<(&'a str, &'a str, bool)>);
|
||||||
|
|
||||||
|
fn schedules(rows: &[Row]) -> Value {
|
||||||
|
json!({"version": "v", "schedules": rows.iter().map(|(id, notify, streak, run)| json!({
|
||||||
|
"id": id, "title": format!("Task {id}"), "notify": notify, "failure_streak": streak,
|
||||||
|
"last_run": run.map(|(r, status, told)| json!({"id": r, "status": status, "notified": told})),
|
||||||
|
})).collect::<Vec<_>>()})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ran(id: &str, told: Told) -> Ran {
|
||||||
|
Ran {
|
||||||
|
title: format!("Task {id}"),
|
||||||
|
told,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_finished_run_is_said_once_by_its_policy_and_a_silent_one_only_when_always() {
|
||||||
|
let w = Watch::default();
|
||||||
|
assert!(w
|
||||||
|
.look_schedules(&schedules(&[
|
||||||
|
("a", "matters", 0, Some(("r1", "ok", false))),
|
||||||
|
("b", "always", 0, None)
|
||||||
|
]))
|
||||||
|
.is_empty());
|
||||||
|
// Running is not news; finishing is, even under the same run id.
|
||||||
|
assert!(w
|
||||||
|
.look_schedules(&schedules(&[(
|
||||||
|
"a",
|
||||||
|
"matters",
|
||||||
|
0,
|
||||||
|
Some(("r2", "running", false))
|
||||||
|
)]))
|
||||||
|
.is_empty());
|
||||||
|
let out = w.look_schedules(&schedules(&[
|
||||||
|
("a", "matters", 0, Some(("r2", "ok", false))),
|
||||||
|
("b", "always", 0, Some(("r3", "silent", false))),
|
||||||
|
("c", "matters", 0, Some(("r4", "silent", false))),
|
||||||
|
("d", "never", 0, Some(("r5", "ok", false))),
|
||||||
|
]));
|
||||||
|
assert_eq!(out, vec![ran("a", Told::Result), ran("b", Told::Result)]);
|
||||||
|
// The same list again (the server told a device meanwhile): not said twice.
|
||||||
|
assert!(w
|
||||||
|
.look_schedules(&schedules(&[("a", "matters", 0, Some(("r2", "ok", true)))]))
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failures_are_said_the_first_time_and_the_third_asks_to_pause() {
|
||||||
|
assert_eq!(decide("matters", "failed", false, 1), Some(Told::Failed));
|
||||||
|
assert_eq!(decide("matters", "failed", false, 2), None);
|
||||||
|
assert_eq!(decide("always", "skipped", false, 3), Some(Told::Pause));
|
||||||
|
assert_eq!(decide("never", "failed", false, 1), None);
|
||||||
|
assert_eq!(decide("matters", "missed", false, 0), None);
|
||||||
|
// The server's own word wins: it told a device.
|
||||||
|
assert_eq!(decide("matters", "failed", true, 2), Some(Told::Failed));
|
||||||
|
assert_eq!(decide("matters", "silent", true, 0), Some(Told::Result));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reset_ends_the_stream_and_starts_quiet() {
|
||||||
|
let w = Watch::default();
|
||||||
|
let old = w.stop_flag();
|
||||||
|
w.look_schedules(&schedules(&[(
|
||||||
|
"a",
|
||||||
|
"matters",
|
||||||
|
0,
|
||||||
|
Some(("r1", "running", false)),
|
||||||
|
)]));
|
||||||
|
w.reset();
|
||||||
|
assert!(old.load(Ordering::SeqCst));
|
||||||
|
assert!(!w.stop_flag().load(Ordering::SeqCst));
|
||||||
|
assert!(w
|
||||||
|
.look_schedules(&schedules(&[(
|
||||||
|
"a",
|
||||||
|
"matters",
|
||||||
|
0,
|
||||||
|
Some(("r1", "ok", false))
|
||||||
|
)]))
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
fn list(rows: &[(&str, bool, &str)]) -> Value {
|
fn list(rows: &[(&str, bool, &str)]) -> Value {
|
||||||
json!({"chats": rows.iter().map(|(id, running, kind)| json!({
|
json!({"chats": rows.iter().map(|(id, running, kind)| json!({
|
||||||
"id": id, "title": format!("Chat {id}"), "running": running, "kind": kind
|
"id": id, "title": format!("Chat {id}"), "running": running, "kind": kind
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
{
|
||||||
|
"about": "Progress (progress.md, spec 3.4.0): the text and the line every client draws for a progress object. `progress`: the object; `now`: the client's clock (Unix ms). Expect `text` — the phase's text from loop.json progress.texts, its fields filled (numbers written plainly: integers as they are, a rate with one decimal) — and `line` — `{{text}} · {{elapsed}}` with elapsed = now − since in whole seconds (5s, 1m 05s, 1h 02m), or the text alone for stopped and failed. retrying's `in` is counted down: in − floor((now − since) / 1000), never below 0. LLeMbas: services/progress.py; CLI: session/progress.ts.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"about": "Queued, nothing more known.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "queued",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790405000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Queued",
|
||||||
|
"line": "Queued · 5s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Queued behind two turns.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "queued",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"ahead": 2
|
||||||
|
},
|
||||||
|
"now": 1762790465000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Queued: 2 ahead",
|
||||||
|
"line": "Queued: 2 ahead · 1m 05s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A one-request server answering another request.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_model",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"model": "Qwen 27B",
|
||||||
|
"reason": "busy"
|
||||||
|
},
|
||||||
|
"now": 1762790412400,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for Qwen 27B: it serves one request at a time and is answering another",
|
||||||
|
"line": "Waiting for Qwen 27B: it serves one request at a time and is answering another · 12s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Every slot taken.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_model",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"model": "coder",
|
||||||
|
"reason": "slots"
|
||||||
|
},
|
||||||
|
"now": 1762790401000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for coder: all its slots are in use",
|
||||||
|
"line": "Waiting for coder: all its slots are in use · 1s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Another model on the one-model server.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_model",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"model": "coder",
|
||||||
|
"reason": "group"
|
||||||
|
},
|
||||||
|
"now": 1762790400000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for coder: its server holds another model now",
|
||||||
|
"line": "Waiting for coder: its server holds another model now · 0s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Loading.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "loading_model",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"model": "Gemma 12B"
|
||||||
|
},
|
||||||
|
"now": 1762794125000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Loading Gemma 12B",
|
||||||
|
"line": "Loading Gemma 12B · 1h 02m"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Reading the prompt: the percentage is floored.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "reading_prompt",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"done": 1999,
|
||||||
|
"total": 3000
|
||||||
|
},
|
||||||
|
"now": 1762790402000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Reading the prompt: 1999 / 3000 tokens (66%)",
|
||||||
|
"line": "Reading the prompt: 1999 / 3000 tokens (66%) · 2s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Reading the prompt with no counts from the server.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "reading_prompt",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790402000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Reading the prompt",
|
||||||
|
"line": "Reading the prompt · 2s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Thinking.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "thinking",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790459999,
|
||||||
|
"expect": {
|
||||||
|
"text": "Thinking",
|
||||||
|
"line": "Thinking · 59s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Writing, the rate with one decimal.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "writing",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"rate": 12.46
|
||||||
|
},
|
||||||
|
"now": 1762790404000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Writing: 12.5 tok/s",
|
||||||
|
"line": "Writing: 12.5 tok/s · 4s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Writing before a rate is measured.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "writing",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790400400,
|
||||||
|
"expect": {
|
||||||
|
"text": "Writing",
|
||||||
|
"line": "Writing · 0s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A tool with what it does.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "running_tool",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"tool": "bash",
|
||||||
|
"what": "bun test"
|
||||||
|
},
|
||||||
|
"now": 1762790461000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Running bash: bun test",
|
||||||
|
"line": "Running bash: bun test · 1m 01s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A tool without a title.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "running_tool",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"tool": "recall"
|
||||||
|
},
|
||||||
|
"now": 1762790401000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Running recall",
|
||||||
|
"line": "Running recall · 1s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "An approval, naming the tool.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_person",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"what": "approval",
|
||||||
|
"tool": "bash"
|
||||||
|
},
|
||||||
|
"now": 1762790430000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for you: allow bash?",
|
||||||
|
"line": "Waiting for you: allow bash? · 30s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "An approval with no tool named.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_person",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"what": "approval"
|
||||||
|
},
|
||||||
|
"now": 1762790400000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for you: an approval",
|
||||||
|
"line": "Waiting for you: an approval · 0s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A question.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_person",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"what": "question"
|
||||||
|
},
|
||||||
|
"now": 1762790403000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for you: a question",
|
||||||
|
"line": "Waiting for you: a question · 3s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The plan.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_person",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"what": "plan"
|
||||||
|
},
|
||||||
|
"now": 1762790403000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for you: the plan",
|
||||||
|
"line": "Waiting for you: the plan · 3s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Subagents.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_subagents",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"running": 3
|
||||||
|
},
|
||||||
|
"now": 1762790490000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for subagents: 3 running",
|
||||||
|
"line": "Waiting for subagents: 3 running · 1m 30s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Compacting.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "compacting",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790407000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Compacting the conversation",
|
||||||
|
"line": "Compacting the conversation · 7s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Retrying: `in` counts down from since.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "retrying",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"reason": "502 Bad Gateway",
|
||||||
|
"in": 10,
|
||||||
|
"attempt": 2
|
||||||
|
},
|
||||||
|
"now": 1762790403000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Retrying in 7 s: 502 Bad Gateway",
|
||||||
|
"line": "Retrying in 7 s: 502 Bad Gateway · 3s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Retrying: never below 0.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "retrying",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"reason": "overloaded",
|
||||||
|
"in": 2
|
||||||
|
},
|
||||||
|
"now": 1762790405000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Retrying in 0 s: overloaded",
|
||||||
|
"line": "Retrying in 0 s: overloaded · 5s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Stopped draws no elapsed time.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "stopped",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790405000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Stopped",
|
||||||
|
"line": "Stopped"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Stopped by the person.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "stopped",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"reason": "person"
|
||||||
|
},
|
||||||
|
"now": 1762790405000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Stopped: by you",
|
||||||
|
"line": "Stopped: by you"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Stopped at the step limit.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "stopped",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"reason": "steps"
|
||||||
|
},
|
||||||
|
"now": 1762790400000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Stopped: step limit",
|
||||||
|
"line": "Stopped: step limit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Failed.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "failed",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"reason": "connection refused"
|
||||||
|
},
|
||||||
|
"now": 1762790405000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Failed: connection refused",
|
||||||
|
"line": "Failed: connection refused"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
LLeMbas v2.0.0-beta.8 (d06ca353b801916302f0ce0cc9ae3e5244ff184f) tests/fixtures/api_v2
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"id": "chat2",
|
||||||
|
"kind": "agent",
|
||||||
|
"title": "Waybread",
|
||||||
|
"model": "qwen",
|
||||||
|
"folder_id": null,
|
||||||
|
"pinned": false,
|
||||||
|
"archived": false,
|
||||||
|
"temporary": false,
|
||||||
|
"unread": true,
|
||||||
|
"running": true,
|
||||||
|
"mode": "edit",
|
||||||
|
"effort": "low",
|
||||||
|
"device": {
|
||||||
|
"token_id": "token1",
|
||||||
|
"directory": "/home/dev/project"
|
||||||
|
},
|
||||||
|
"device_session_id": "ses_01waybread",
|
||||||
|
"parent_chat_id": null,
|
||||||
|
"agents": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 4
|
||||||
|
},
|
||||||
|
"subagents": {
|
||||||
|
"offered": true,
|
||||||
|
"named_only": false,
|
||||||
|
"cap": 32
|
||||||
|
},
|
||||||
|
"created_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
{
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"id": "card1",
|
||||||
|
"kind": "question",
|
||||||
|
"why": "",
|
||||||
|
"dismissable": false,
|
||||||
|
"expires_in": null,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"key": "q0",
|
||||||
|
"kind": "question",
|
||||||
|
"tool": "ask_user",
|
||||||
|
"title": "Honey or not?",
|
||||||
|
"header": "Taste",
|
||||||
|
"detail": "",
|
||||||
|
"reason": "",
|
||||||
|
"purpose": "",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"label": "Honey",
|
||||||
|
"description": "",
|
||||||
|
"recommended": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Plain",
|
||||||
|
"description": "As the elves have it",
|
||||||
|
"recommended": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"multiple": false,
|
||||||
|
"free_text": true,
|
||||||
|
"editable": false,
|
||||||
|
"plan": null,
|
||||||
|
"mode_choice": true,
|
||||||
|
"job": "",
|
||||||
|
"subagent": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card2",
|
||||||
|
"kind": "approval",
|
||||||
|
"why": "",
|
||||||
|
"dismissable": false,
|
||||||
|
"expires_in": null,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"key": "0",
|
||||||
|
"kind": "approval",
|
||||||
|
"tool": "bash",
|
||||||
|
"title": "Run a command",
|
||||||
|
"header": "",
|
||||||
|
"detail": "rm -rf build",
|
||||||
|
"reason": "Edit mode asks before anything that runs a command.",
|
||||||
|
"purpose": "Start clean",
|
||||||
|
"options": [],
|
||||||
|
"multiple": false,
|
||||||
|
"free_text": false,
|
||||||
|
"editable": true,
|
||||||
|
"plan": null,
|
||||||
|
"mode_choice": true,
|
||||||
|
"job": "explore (job1)",
|
||||||
|
"subagent": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card3",
|
||||||
|
"kind": "plan",
|
||||||
|
"why": "",
|
||||||
|
"dismissable": true,
|
||||||
|
"expires_in": null,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"key": "p0",
|
||||||
|
"kind": "plan",
|
||||||
|
"tool": "plan_submit",
|
||||||
|
"title": "Bake lembas",
|
||||||
|
"header": "",
|
||||||
|
"detail": "",
|
||||||
|
"reason": "",
|
||||||
|
"purpose": "",
|
||||||
|
"options": [],
|
||||||
|
"multiple": false,
|
||||||
|
"free_text": true,
|
||||||
|
"editable": false,
|
||||||
|
"plan": {
|
||||||
|
"version": 2,
|
||||||
|
"title": "Bake lembas",
|
||||||
|
"summary": "",
|
||||||
|
"findings": [],
|
||||||
|
"objectives": [],
|
||||||
|
"phases": [
|
||||||
|
{
|
||||||
|
"id": "p1",
|
||||||
|
"title": "Plan",
|
||||||
|
"status": "pending",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "t1",
|
||||||
|
"text": "Gather mallorn leaves",
|
||||||
|
"status": "todo",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t2",
|
||||||
|
"text": "Bake slowly",
|
||||||
|
"status": "todo",
|
||||||
|
"note": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Gather mallorn leaves",
|
||||||
|
"Bake slowly"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mode_choice": true,
|
||||||
|
"job": "",
|
||||||
|
"subagent": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card4",
|
||||||
|
"kind": "approval",
|
||||||
|
"why": "",
|
||||||
|
"dismissable": false,
|
||||||
|
"expires_in": null,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"key": "0",
|
||||||
|
"kind": "approval",
|
||||||
|
"tool": "bash",
|
||||||
|
"title": "Run a command",
|
||||||
|
"header": "",
|
||||||
|
"detail": "git push",
|
||||||
|
"reason": "Edit mode asks before anything that runs a command.",
|
||||||
|
"purpose": "",
|
||||||
|
"options": [],
|
||||||
|
"multiple": false,
|
||||||
|
"free_text": false,
|
||||||
|
"editable": true,
|
||||||
|
"plan": null,
|
||||||
|
"mode_choice": true,
|
||||||
|
"job": "",
|
||||||
|
"subagent": {
|
||||||
|
"id": "s1",
|
||||||
|
"name": "parser",
|
||||||
|
"position": "reviews the parser"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"text": "Running bake --slow",
|
||||||
|
"progress": {
|
||||||
|
"phase": "running_tool",
|
||||||
|
"since": 1767323045000,
|
||||||
|
"tool": "bash",
|
||||||
|
"what": "bake --slow"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"text": "",
|
||||||
|
"progress": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"text": "Waiting for you to allow bash…",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_person",
|
||||||
|
"since": 1767323045000,
|
||||||
|
"what": "question"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"text": "",
|
||||||
|
"progress": {
|
||||||
|
"phase": "stopped",
|
||||||
|
"since": 1767323045000,
|
||||||
|
"reason": "person"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"tree": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 4,
|
||||||
|
"size": 2,
|
||||||
|
"open_slots": 1,
|
||||||
|
"agents": [
|
||||||
|
{
|
||||||
|
"id": "lead",
|
||||||
|
"name": "lead",
|
||||||
|
"position": "the main agent",
|
||||||
|
"parent": null,
|
||||||
|
"state": "working",
|
||||||
|
"model": "qwen-27b",
|
||||||
|
"mode": "edit",
|
||||||
|
"request": [],
|
||||||
|
"manager": true,
|
||||||
|
"context": {
|
||||||
|
"used": 41200,
|
||||||
|
"window": 131072
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"phase": "thinking",
|
||||||
|
"since": 1767323045000
|
||||||
|
},
|
||||||
|
"findings": 0,
|
||||||
|
"messages": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s2",
|
||||||
|
"name": "parser",
|
||||||
|
"position": "reviews the parser",
|
||||||
|
"parent": "lead",
|
||||||
|
"state": "working",
|
||||||
|
"model": "qwen-27b",
|
||||||
|
"mode": "manual",
|
||||||
|
"task": "Review src/parse.ts.",
|
||||||
|
"request": [
|
||||||
|
"write"
|
||||||
|
],
|
||||||
|
"manager": false,
|
||||||
|
"context": {
|
||||||
|
"used": 9100,
|
||||||
|
"window": 131072
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"phase": "running_tool",
|
||||||
|
"since": 1767323045000,
|
||||||
|
"tool": "read",
|
||||||
|
"what": "src/parse.ts"
|
||||||
|
},
|
||||||
|
"findings": 2,
|
||||||
|
"messages": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ended": [
|
||||||
|
{
|
||||||
|
"id": "s1",
|
||||||
|
"name": "scout",
|
||||||
|
"position": "maps the code",
|
||||||
|
"parent": "lead",
|
||||||
|
"state": "done",
|
||||||
|
"model": "qwen-27b",
|
||||||
|
"mode": "edit",
|
||||||
|
"task": "Map it.",
|
||||||
|
"request": [],
|
||||||
|
"manager": false,
|
||||||
|
"progress": null,
|
||||||
|
"findings": 1,
|
||||||
|
"messages": 1,
|
||||||
|
"ended": 1767323045000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "qwen",
|
||||||
|
"name": "Qwen",
|
||||||
|
"vision": false,
|
||||||
|
"tools": true,
|
||||||
|
"efforts": [
|
||||||
|
"medium"
|
||||||
|
],
|
||||||
|
"thinking_switch": true,
|
||||||
|
"levels": [
|
||||||
|
"off",
|
||||||
|
"medium"
|
||||||
|
],
|
||||||
|
"effort": "medium",
|
||||||
|
"control": "switch",
|
||||||
|
"parallel": 2,
|
||||||
|
"context": 131072,
|
||||||
|
"image": "/api/v2/models/images/0a1b2c3d4e5f.png",
|
||||||
|
"default": true,
|
||||||
|
"subagents": {
|
||||||
|
"offered": true,
|
||||||
|
"named_only": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gpt-oss",
|
||||||
|
"name": "gpt-oss",
|
||||||
|
"vision": false,
|
||||||
|
"tools": true,
|
||||||
|
"efforts": [
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high"
|
||||||
|
],
|
||||||
|
"thinking_switch": false,
|
||||||
|
"levels": [
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high"
|
||||||
|
],
|
||||||
|
"effort": "high",
|
||||||
|
"control": "slider",
|
||||||
|
"parallel": 1,
|
||||||
|
"context": 0,
|
||||||
|
"image": null,
|
||||||
|
"default": false,
|
||||||
|
"subagents": {
|
||||||
|
"offered": true,
|
||||||
|
"named_only": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vision-small",
|
||||||
|
"name": "Vision",
|
||||||
|
"vision": true,
|
||||||
|
"tools": false,
|
||||||
|
"efforts": [],
|
||||||
|
"thinking_switch": false,
|
||||||
|
"levels": [
|
||||||
|
"default"
|
||||||
|
],
|
||||||
|
"effort": "default",
|
||||||
|
"control": "none",
|
||||||
|
"parallel": null,
|
||||||
|
"context": 8192,
|
||||||
|
"image": null,
|
||||||
|
"default": false,
|
||||||
|
"subagents": {
|
||||||
|
"offered": true,
|
||||||
|
"named_only": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subagents_cap": 32,
|
||||||
|
"agent_subagents": {
|
||||||
|
"offered": true,
|
||||||
|
"device_runs": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "s2",
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"n": 1,
|
||||||
|
"text": "Login is POST /session.",
|
||||||
|
"time": 1767323045000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"id": "s2",
|
||||||
|
"state": "working",
|
||||||
|
"turns": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"text": "Write the routes.",
|
||||||
|
"machine": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"text": "Reading the API first.",
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "read",
|
||||||
|
"title": "src/api.py",
|
||||||
|
"isError": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"error": "",
|
||||||
|
"running": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
{
|
||||||
|
"tree": {
|
||||||
|
"min": 2,
|
||||||
|
"max": 4,
|
||||||
|
"size": 3,
|
||||||
|
"open_slots": 0,
|
||||||
|
"agents": [
|
||||||
|
{
|
||||||
|
"id": "lead",
|
||||||
|
"name": "lead",
|
||||||
|
"position": "the main agent",
|
||||||
|
"parent": null,
|
||||||
|
"state": "working",
|
||||||
|
"model": "local/qwen",
|
||||||
|
"mode": "auto",
|
||||||
|
"request": [],
|
||||||
|
"manager": true,
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_subagents",
|
||||||
|
"since": 1767323045000,
|
||||||
|
"running": 2
|
||||||
|
},
|
||||||
|
"findings": 0,
|
||||||
|
"messages": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s1",
|
||||||
|
"name": "area",
|
||||||
|
"position": "owns the API",
|
||||||
|
"parent": "lead",
|
||||||
|
"state": "working",
|
||||||
|
"model": "local/qwen",
|
||||||
|
"mode": "auto",
|
||||||
|
"request": [],
|
||||||
|
"manager": true,
|
||||||
|
"task": "Direct it.",
|
||||||
|
"progress": null,
|
||||||
|
"findings": 0,
|
||||||
|
"messages": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s2",
|
||||||
|
"name": "routes",
|
||||||
|
"position": "writes the routes",
|
||||||
|
"parent": "area",
|
||||||
|
"state": "working",
|
||||||
|
"model": "local/qwen",
|
||||||
|
"mode": "edit",
|
||||||
|
"request": [
|
||||||
|
"execute"
|
||||||
|
],
|
||||||
|
"manager": false,
|
||||||
|
"task": "Write the routes.",
|
||||||
|
"context": {
|
||||||
|
"used": 9100,
|
||||||
|
"window": 131072
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"phase": "running_tool",
|
||||||
|
"since": 1767323045000,
|
||||||
|
"tool": "read",
|
||||||
|
"what": "src/api.py"
|
||||||
|
},
|
||||||
|
"findings": 1,
|
||||||
|
"messages": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ended": [
|
||||||
|
{
|
||||||
|
"id": "s3",
|
||||||
|
"name": "scout",
|
||||||
|
"position": "maps the code",
|
||||||
|
"parent": "lead",
|
||||||
|
"state": "done",
|
||||||
|
"model": "local/qwen",
|
||||||
|
"mode": "auto",
|
||||||
|
"request": [],
|
||||||
|
"manager": false,
|
||||||
|
"task": "Map it.",
|
||||||
|
"progress": null,
|
||||||
|
"findings": 0,
|
||||||
|
"messages": 1,
|
||||||
|
"ended": 1767323045000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"room": [
|
||||||
|
{
|
||||||
|
"n": 1,
|
||||||
|
"from": "routes",
|
||||||
|
"to": [
|
||||||
|
"area"
|
||||||
|
],
|
||||||
|
"text": "Routes are in.",
|
||||||
|
"report": false,
|
||||||
|
"kind": "message",
|
||||||
|
"time": 1767323045000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": 2,
|
||||||
|
"from": "scout",
|
||||||
|
"to": [
|
||||||
|
"lead"
|
||||||
|
],
|
||||||
|
"text": "Mapped: see map.md.",
|
||||||
|
"report": true,
|
||||||
|
"kind": "report",
|
||||||
|
"time": 1767323045000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"live": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// A stand-in for Tauri's bridge, for the page under a headless browser (tests/ui/run.mjs). Runs
|
||||||
|
// before the page's own scripts: `window.__TAURI__` answers the commands the page invokes from a
|
||||||
|
// table the test hands in, and records every call, so a test can say what the page asked for.
|
||||||
|
//
|
||||||
|
// The table (`config`):
|
||||||
|
// status what `get_status` answers (the app, the agent, the server)
|
||||||
|
// routes { "GET /me": value, "POST /chats/c1/interactions/k1": value, ... } -- the server's
|
||||||
|
// /api/v2 as the page reaches it through `api`. A path is looked up with its query,
|
||||||
|
// then without it. A value `{ __error: { status, detail } }` is a refusal. Anything
|
||||||
|
// not in the table is a 404, as an older server would answer.
|
||||||
|
// streams { "/chats/c1/messages/m1/stream": [[event, data], ...] } -- the frames a followed
|
||||||
|
// reply sends as soon as it is opened. More can be sent later with `__mock.frame()`.
|
||||||
|
// terminal [[kind, data], ...] -- what every terminal's socket sends as soon as it is opened
|
||||||
|
// (`control` frames as the server's JSON, `output` as text, sent base64 as Rust does).
|
||||||
|
// More with `__mock.shell(kind, data)`; `{ refuse: {status, detail} }` makes
|
||||||
|
// `terminal_open` fail.
|
||||||
|
//
|
||||||
|
// Nothing here talks to a network: the page's policy allows it none, and this keeps it so.
|
||||||
|
;(function () {
|
||||||
|
const config = window.__mockConfig || {}
|
||||||
|
const listeners = new Map() // event name -> [callback]
|
||||||
|
const streams = new Map() // stream id -> path
|
||||||
|
const terminals = new Map() // terminal id -> { target, open }
|
||||||
|
let nextTerminal = 0
|
||||||
|
let nextStream = 0
|
||||||
|
const calls = []
|
||||||
|
|
||||||
|
function dispatch(name, payload) {
|
||||||
|
for (const cb of listeners.get(name) || []) cb({ event: name, payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
function route(method, path) {
|
||||||
|
const routes = config.routes || {}
|
||||||
|
const bare = path.split("?")[0]
|
||||||
|
for (const key of [`${method} ${path}`, `${method} ${bare}`]) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(routes, key)) {
|
||||||
|
const value = routes[key]
|
||||||
|
if (value && value.__error) return Promise.reject(value.__error)
|
||||||
|
return Promise.resolve(JSON.parse(JSON.stringify(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject({ status: 404, detail: `Not in the test's table: ${method} ${path}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shell's bytes, base64 as Rust sends them (`terminal.rs`): UTF-8 first.
|
||||||
|
const b64 = (text) => btoa(String.fromCharCode(...new TextEncoder().encode(text)))
|
||||||
|
|
||||||
|
function shell(kind, data, id) {
|
||||||
|
const ids = id ? [id] : [...terminals.keys()].filter((k) => terminals.get(k).open)
|
||||||
|
for (const tid of ids) {
|
||||||
|
if (kind === "end") terminals.get(tid).open = false
|
||||||
|
dispatch("terminal", { id: tid, kind, data: kind === "output" ? b64(data) : data })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(path, event, data) {
|
||||||
|
for (const [id, p] of streams) if (p === path) dispatch("stream", { id, event, data })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invoke(cmd, args) {
|
||||||
|
calls.push({ cmd, args: args === undefined ? null : JSON.parse(JSON.stringify(args)) })
|
||||||
|
switch (cmd) {
|
||||||
|
case "get_status":
|
||||||
|
return JSON.parse(JSON.stringify(config.status))
|
||||||
|
case "api":
|
||||||
|
return route(args.method, args.path)
|
||||||
|
case "api_text":
|
||||||
|
return route("GET", args.path)
|
||||||
|
case "stream_open": {
|
||||||
|
const id = ++nextStream
|
||||||
|
streams.set(id, args.path)
|
||||||
|
const frames = (config.streams || {})[args.path] || []
|
||||||
|
setTimeout(() => {
|
||||||
|
for (const [event, data] of frames) dispatch("stream", { id, event, data })
|
||||||
|
}, 0)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
case "stream_close":
|
||||||
|
streams.delete(args.id)
|
||||||
|
return null
|
||||||
|
case "terminal_open": {
|
||||||
|
const t = config.terminal || []
|
||||||
|
if (t.refuse) return Promise.reject(t.refuse)
|
||||||
|
const id = ++nextTerminal
|
||||||
|
terminals.set(id, { target: { chat: args.chat, device: args.device, directory: args.directory }, open: true })
|
||||||
|
setTimeout(() => {
|
||||||
|
for (const [kind, data] of t) shell(kind, data, id)
|
||||||
|
}, 0)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
case "terminal_input":
|
||||||
|
case "terminal_resize":
|
||||||
|
return !!terminals.get(args.id)?.open
|
||||||
|
case "terminal_close": {
|
||||||
|
const t = terminals.get(args.id)
|
||||||
|
if (t?.open) setTimeout(() => shell("end", { code: 1000, reason: "", status: null, error: null }, args.id), 0)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
case "model_image":
|
||||||
|
return ""
|
||||||
|
case "terminal_word":
|
||||||
|
return "lembas"
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = {
|
||||||
|
minimize: async () => {},
|
||||||
|
toggleMaximize: async () => {},
|
||||||
|
close: async () => {},
|
||||||
|
isMaximized: async () => false,
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__TAURI__ = {
|
||||||
|
core: { invoke },
|
||||||
|
event: {
|
||||||
|
listen: async (name, cb) => {
|
||||||
|
if (!listeners.has(name)) listeners.set(name, [])
|
||||||
|
listeners.get(name).push(cb)
|
||||||
|
return () => listeners.set(name, (listeners.get(name) || []).filter((x) => x !== cb))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
window: { getCurrentWindow: () => win },
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__mock = {
|
||||||
|
calls,
|
||||||
|
frame,
|
||||||
|
emit: dispatch,
|
||||||
|
/** The calls of one command, or of `api` with one method and path prefix. */
|
||||||
|
api: (method, prefix) => calls.filter((c) => c.cmd === "api" && c.args.method === method && c.args.path.startsWith(prefix)),
|
||||||
|
streams: () => [...streams.values()],
|
||||||
|
shell,
|
||||||
|
/** The terminals opened so far: [{ id, target, open }]. */
|
||||||
|
terminals: () => [...terminals].map(([id, t]) => ({ id, ...t })),
|
||||||
|
/** What was typed into a terminal, joined. */
|
||||||
|
typed: () => calls.filter((c) => c.cmd === "terminal_input").map((c) => c.args.data).join(""),
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -0,0 +1,842 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// The page under a headless browser, against a mocked bridge (tests/ui/mock-tauri.js): what the
|
||||||
|
// person sees for a given answer of the server's /api/v2, and what the page sends back.
|
||||||
|
//
|
||||||
|
// node tests/ui/run.mjs [name-filter]
|
||||||
|
//
|
||||||
|
// Needs Playwright (`playwright` or `playwright-core`, from NODE_PATH or a node_modules beside
|
||||||
|
// the repository) and a Chromium: Playwright's own, or the one in $CHROMIUM (/usr/bin/chromium
|
||||||
|
// is tried when Playwright has none). Nothing is built and no server is started: the page's
|
||||||
|
// files are handed to the browser from ui/ as they are in the repository. With $SHOTS set to a
|
||||||
|
// directory, some tests leave a screenshot there.
|
||||||
|
//
|
||||||
|
// The shapes are the server's (`api/v2/serialise.py`, its fixtures in tests/fixtures/api_v2),
|
||||||
|
// with made-up names. Where the server's fixture differs from what its code sends, the test
|
||||||
|
// says so and follows the code.
|
||||||
|
|
||||||
|
import { createRequire } from "node:module"
|
||||||
|
import { readFileSync, existsSync } from "node:fs"
|
||||||
|
import { dirname, join, extname } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const UI = join(HERE, "..", "..", "ui")
|
||||||
|
const ORIGIN = "http://app.test"
|
||||||
|
const require = createRequire(join(process.cwd(), "noop.js"))
|
||||||
|
|
||||||
|
function playwright() {
|
||||||
|
for (const name of ["playwright", "playwright-core"]) {
|
||||||
|
try {
|
||||||
|
return require(name)
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
return createRequire(import.meta.url)(name)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
console.error("tests/ui: Playwright is not installed (npm install playwright-core, and put it on NODE_PATH)")
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launch() {
|
||||||
|
const { chromium } = playwright()
|
||||||
|
const paths = [process.env.CHROMIUM, "/usr/bin/chromium", "/usr/bin/chromium-browser"].filter(Boolean)
|
||||||
|
if (!process.env.CHROMIUM) {
|
||||||
|
try {
|
||||||
|
return await chromium.launch()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
for (const executablePath of paths) if (existsSync(executablePath)) return chromium.launch({ executablePath })
|
||||||
|
throw new Error("tests/ui: no Chromium found (set CHROMIUM)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One of the server's /api/v2 contract fixtures, as copied by scripts/fetch-fixtures.sh. */
|
||||||
|
const fixture = (name) => JSON.parse(readFileSync(join(HERE, "fixtures", `${name}.json`), "utf8"))
|
||||||
|
|
||||||
|
const TYPES = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".mjs": "text/javascript", ".json": "application/json" }
|
||||||
|
|
||||||
|
// --- The server, as the tests' table -----------------------------------------------------------
|
||||||
|
|
||||||
|
const STAMP = "2026-01-02T03:04:05+00:00"
|
||||||
|
|
||||||
|
function status(over = {}) {
|
||||||
|
return {
|
||||||
|
version: "1.0.0-test",
|
||||||
|
server: "https://ai.example",
|
||||||
|
discovery: { name: "Example", app: "https://ai.example/api/v2", version: "2.0.0" },
|
||||||
|
refused: null,
|
||||||
|
checked: true,
|
||||||
|
account: { name: "Ada Example", email: "ada@example.org" },
|
||||||
|
signing_in: false,
|
||||||
|
linked: true,
|
||||||
|
agent: { state: "running", detail: null },
|
||||||
|
agent_wanted: true,
|
||||||
|
update: null,
|
||||||
|
remote: { enabled: true, roots: ["/home/dev/project"] },
|
||||||
|
theme: "moria",
|
||||||
|
host: "workbench",
|
||||||
|
notify: false,
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUB = (offered, named_only = false) => ({ offered, named_only })
|
||||||
|
|
||||||
|
/** The models as `/api/v2/models` lists them; `subagents` per model as the server works it out. */
|
||||||
|
function models({ qwen = SUB(true), solo = SUB(true, true), plain = SUB(false) } = {}) {
|
||||||
|
const base = { vision: false, tools: true, efforts: ["low", "medium", "high"], thinking_switch: false, levels: ["low", "medium", "high"], effort: "medium", control: "slider", context: 131072, image: null }
|
||||||
|
return {
|
||||||
|
models: [
|
||||||
|
{ ...base, id: "qwen", name: "Qwen", parallel: 2, default: true, subagents: qwen },
|
||||||
|
{ ...base, id: "solo", name: "Solo", parallel: 1, default: false, subagents: solo },
|
||||||
|
{ ...base, id: "plain", name: "Plain", parallel: 1, default: false, subagents: plain },
|
||||||
|
],
|
||||||
|
subagents_cap: 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatRow(over = {}) {
|
||||||
|
return {
|
||||||
|
id: "chat1",
|
||||||
|
kind: "chat",
|
||||||
|
title: "Waybread",
|
||||||
|
model: "qwen",
|
||||||
|
folder_id: null,
|
||||||
|
pinned: false,
|
||||||
|
archived: false,
|
||||||
|
temporary: false,
|
||||||
|
unread: false,
|
||||||
|
running: false,
|
||||||
|
mode: "",
|
||||||
|
effort: "medium",
|
||||||
|
device: null,
|
||||||
|
device_session_id: null,
|
||||||
|
parent_chat_id: null,
|
||||||
|
agents: { min: 1, max: 4 },
|
||||||
|
subagents: { offered: true, named_only: false, cap: 8 },
|
||||||
|
created_at: STAMP,
|
||||||
|
updated_at: STAMP,
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentChat(over = {}) {
|
||||||
|
return chatRow({
|
||||||
|
id: "chat2",
|
||||||
|
kind: "agent",
|
||||||
|
title: "Tidy the build",
|
||||||
|
running: true,
|
||||||
|
mode: "edit",
|
||||||
|
device: { token_id: "token1", directory: "/home/dev/project" },
|
||||||
|
device_session_id: "ses_01",
|
||||||
|
// An agent chat's team runs on its device: the server offers no setting (team_run.may_have_team).
|
||||||
|
subagents: { offered: false, named_only: false, cap: 8 },
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMessage = (id, text) => ({ id, role: "user", created_at: STAMP, complete: true, stopped: false, error: null, queued: false, machine: false, origin: "", model: null, attachments: [], text })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A device's bash approval as `services/device_link.py:_permission` builds it and
|
||||||
|
* `api/v2/serialise.py:interruption` sends it: the title is the fixed "Run a command", the command
|
||||||
|
* is in `detail`, and it is `editable`. (The server's fixture `event_ask.json` puts the command in
|
||||||
|
* the title; its code does not.)
|
||||||
|
*/
|
||||||
|
function deviceBashCard(over = {}) {
|
||||||
|
return {
|
||||||
|
id: "card7",
|
||||||
|
kind: "approval",
|
||||||
|
why: "",
|
||||||
|
dismissable: false,
|
||||||
|
expires_in: 1800,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
key: "0",
|
||||||
|
kind: "approval",
|
||||||
|
tool: "bash",
|
||||||
|
title: "Run a command",
|
||||||
|
header: "",
|
||||||
|
detail: "ls -la build",
|
||||||
|
reason: "Edit mode asks before anything that runs a command.",
|
||||||
|
purpose: "See what is in the build directory",
|
||||||
|
options: [],
|
||||||
|
multiple: false,
|
||||||
|
free_text: true,
|
||||||
|
editable: true,
|
||||||
|
plan: null,
|
||||||
|
mode_choice: false,
|
||||||
|
job: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseRoutes(over = {}) {
|
||||||
|
return {
|
||||||
|
"GET /me": { id: "u1", name: "Ada Example", email: "ada@example.org", theme: "moria", language: "en", permissions: {} },
|
||||||
|
"GET /models": models(),
|
||||||
|
"GET /devices": { devices: [{ id: "token1", name: "workbench", host: "workbench", platform: "linux", terminal: true }] },
|
||||||
|
"GET /chats": { chats: [chatRow(), agentChat()], more: false },
|
||||||
|
"GET /schedules": { schedules: [] },
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The browser --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let browser
|
||||||
|
|
||||||
|
async function page(config, { width = 1280, height = 860 } = {}) {
|
||||||
|
const context = await browser.newContext({ viewport: { width, height } })
|
||||||
|
const p = await context.newPage()
|
||||||
|
const errors = []
|
||||||
|
p.on("pageerror", (e) => errors.push(String(e)))
|
||||||
|
await context.route(`${ORIGIN}/**`, (route) => {
|
||||||
|
const path = new URL(route.request().url()).pathname
|
||||||
|
const file = join(UI, path === "/" ? "index.html" : decodeURIComponent(path))
|
||||||
|
if (!file.startsWith(UI) || !existsSync(file)) return route.fulfill({ status: 404, body: "" })
|
||||||
|
return route.fulfill({ status: 200, contentType: TYPES[extname(file)] || "application/octet-stream", body: readFileSync(file) })
|
||||||
|
})
|
||||||
|
await p.addInitScript((c) => {
|
||||||
|
window.__mockConfig = c
|
||||||
|
}, config)
|
||||||
|
await p.addInitScript({ path: join(HERE, "mock-tauri.js") })
|
||||||
|
await p.goto(`${ORIGIN}/index.html`)
|
||||||
|
await p.waitForSelector("#shell:not([hidden])")
|
||||||
|
p.errors = errors
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openChatRow(p, id) {
|
||||||
|
await p.click(`.chatrow[data-id="${id}"]`)
|
||||||
|
await p.waitForSelector(`#messages .msg`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The tests ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const tests = []
|
||||||
|
const test = (name, fn) => tests.push({ name, fn })
|
||||||
|
|
||||||
|
const STREAM2 = "/chats/chat2/messages/m2/stream"
|
||||||
|
|
||||||
|
function agentChatOpen(frames, over = {}) {
|
||||||
|
return {
|
||||||
|
status: status(),
|
||||||
|
routes: baseRoutes({
|
||||||
|
"GET /chats/chat2": { chat: agentChat(), messages: [userMessage("m1", "What is in build?")], more: false, live: "m2" },
|
||||||
|
"GET /chats/chat1": { chat: chatRow(), messages: [userMessage("m5", "How is lembas made?")], more: false, live: null },
|
||||||
|
"POST /chats/chat2/interactions/card8": { answered: true, remembered: false, not_remembered: [] },
|
||||||
|
"GET /models?chat=chat2": models(),
|
||||||
|
"POST /chats/chat2/interactions/card7": { answered: true, remembered: false, not_remembered: [] },
|
||||||
|
...over,
|
||||||
|
}),
|
||||||
|
streams: { [STREAM2]: frames },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("A1: an agent chat's device bash approval shows as a card, the command editable, and Allow answers it", async () => {
|
||||||
|
const card = deviceBashCard()
|
||||||
|
const p = await page(
|
||||||
|
agentChatOpen([
|
||||||
|
["steps", { steps: [], tail: { thinking: false, text: "", label: "" } }],
|
||||||
|
["status", { text: "Waiting for you to allow bash…" }],
|
||||||
|
["ask", { cards: [card] }],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.waitForSelector("#cards:not([hidden]) .ask")
|
||||||
|
assert.equal(await p.textContent(".msg.is-live .msg__status"), "Waiting for you to allow bash…")
|
||||||
|
const cardBox = await p.locator("#cards .ask").boundingBox()
|
||||||
|
const view = p.viewportSize()
|
||||||
|
assert.ok(cardBox && cardBox.height > 40, "the card has a size")
|
||||||
|
assert.ok(cardBox.y >= 0 && cardBox.y + 20 <= view.height, `the card is on the screen (y=${cardBox.y})`)
|
||||||
|
assert.ok(await p.locator("#cards .ask").isVisible(), "the card is visible")
|
||||||
|
// The command is the item's `detail`, in the box to correct it; the title is not the command.
|
||||||
|
assert.equal(await p.inputValue("#cards .ask__edit"), "ls -la build")
|
||||||
|
const text = await p.textContent("#cards .ask")
|
||||||
|
assert.match(text, /bash/)
|
||||||
|
assert.match(text, /See what is in the build directory/, "the model's purpose is said")
|
||||||
|
assert.match(text, /Edit mode asks before anything that runs a command\./, "the server's reason is said")
|
||||||
|
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "a1-card.png") })
|
||||||
|
await p.click("#cards .ask__actions .btn--primary")
|
||||||
|
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat2/interactions/").length === 1)
|
||||||
|
const [post] = await p.evaluate(() => window.__mock.api("POST", "/chats/chat2/interactions/"))
|
||||||
|
assert.equal(post.args.path, "/chats/chat2/interactions/card7")
|
||||||
|
assert.deepEqual(post.args.body, { verdict: "allow", choices: {}, texts: {} }, "an untouched command is not sent as an edit")
|
||||||
|
await p.waitForSelector("#cards", { state: "hidden" })
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("A1: a corrected command is sent as the item's text; Deny sends deny", async () => {
|
||||||
|
const p = await page(agentChatOpen([["ask", { cards: [deviceBashCard()] }]]))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.waitForSelector("#cards .ask__edit")
|
||||||
|
await p.fill("#cards .ask__edit", "ls -la build/out")
|
||||||
|
await p.click("#cards .ask__actions .btn--primary")
|
||||||
|
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat2/interactions/").length === 1)
|
||||||
|
const [post] = await p.evaluate(() => window.__mock.api("POST", "/chats/chat2/interactions/"))
|
||||||
|
assert.deepEqual(post.args.body, { verdict: "allow", choices: {}, texts: { 0: "ls -la build/out" } })
|
||||||
|
// The next one, refused.
|
||||||
|
await p.evaluate(([path, c]) => window.__mock.frame(path, "ask", { cards: [c] }), [STREAM2, deviceBashCard({ id: "card8" })])
|
||||||
|
await p.waitForSelector("#cards .ask__edit")
|
||||||
|
await p.click("#cards .ask__actions .btn >> text=Deny")
|
||||||
|
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat2/interactions/card8").length === 1)
|
||||||
|
const [deny] = await p.evaluate(() => window.__mock.api("POST", "/chats/chat2/interactions/card8"))
|
||||||
|
assert.deepEqual(deny.args.body, { verdict: "deny", choices: {}, texts: {} })
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("A1: a card that arrives while the person is typing keeps what they typed and is not drawn again by an unchanged frame", async () => {
|
||||||
|
const card = deviceBashCard()
|
||||||
|
const p = await page(agentChatOpen([["ask", { cards: [card] }]]))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.waitForSelector("#cards .ask__edit")
|
||||||
|
await p.fill("#cards .ask__edit", "ls build")
|
||||||
|
// The server sends the frame again when anything in it changes -- here only the countdown.
|
||||||
|
await p.evaluate(([path, c]) => window.__mock.frame(path, "ask", { cards: [{ ...c, expires_in: 1790 }] }), [STREAM2, card])
|
||||||
|
await p.evaluate(([path]) => window.__mock.frame(path, "status", { text: "Waiting for you to allow bash…" }), [STREAM2])
|
||||||
|
assert.equal(await p.inputValue("#cards .ask__edit"), "ls build")
|
||||||
|
assert.equal(await p.locator("#cards .ask").count(), 1)
|
||||||
|
// A second card joins the first without wiping it; the answered one goes.
|
||||||
|
await p.evaluate(([path, c]) => window.__mock.frame(path, "ask", { cards: [c, { ...c, id: "card8", items: [{ ...c.items[0], detail: "make clean" }] }] }), [STREAM2, card])
|
||||||
|
assert.equal(await p.locator("#cards .ask").count(), 2)
|
||||||
|
assert.equal(await p.inputValue("#cards .ask >> nth=0 >> .ask__edit"), "ls build")
|
||||||
|
await p.evaluate(([path]) => window.__mock.frame(path, "ask", { cards: [] }), [STREAM2])
|
||||||
|
await p.waitForSelector("#cards", { state: "hidden" })
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("A1: reopening a chat whose reply waits shows the card from the stream's first pass", async () => {
|
||||||
|
const p = await page(agentChatOpen([["status", { text: "Waiting for you to allow bash…" }], ["ask", { cards: [deviceBashCard()] }]]))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.waitForSelector("#cards .ask")
|
||||||
|
// Away to another chat and back: the stream is opened again and its first pass carries the card.
|
||||||
|
await p.click('.chatrow[data-id="chat1"]').catch(() => {})
|
||||||
|
await p.waitForFunction(() => !document.querySelector("#cards .ask"))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.waitForSelector("#cards:not([hidden]) .ask")
|
||||||
|
assert.equal(await p.evaluate(() => window.__mock.calls.filter((c) => c.cmd === "stream_open" && c.args.path.includes("/messages/")).length), 2)
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("A1: a card at 390 px wide is on the screen and its buttons can be reached", async () => {
|
||||||
|
const p = await page(agentChatOpen([["ask", { cards: [deviceBashCard()] }]]), { width: 390, height: 844 })
|
||||||
|
await p.evaluate(() => document.querySelector('.chatrow[data-id="chat2"]').click())
|
||||||
|
await p.waitForSelector("#cards:not([hidden]) .ask")
|
||||||
|
const allow = await p.locator("#cards .ask__actions .btn--primary").boundingBox()
|
||||||
|
assert.ok(allow && allow.y + allow.height <= 844 && allow.x >= 0 && allow.x + allow.width <= 390, `Allow is on the screen: ${JSON.stringify(allow)}`)
|
||||||
|
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= 390), "nothing scrolls sideways")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- A5: the Subagents setting ----------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The server decides (`services/team_run.py:offered`, `services/subagents.py:view`): subagents
|
||||||
|
// enabled on the instance, the person's permission, a model that calls tools, and some model that
|
||||||
|
// could serve one -- the chat's own serving two requests at once, or another off its one-model
|
||||||
|
// server (`named_only`: then the setting says a subagent needs a model named in the chat). It says
|
||||||
|
// so per model in `/models` (a new chat) and per chat in `/chats/{id}` (`subagents`). The web page
|
||||||
|
// reads the same function (`api/pages.py:_team_context`). The app shows what it is told.
|
||||||
|
|
||||||
|
async function newChatOn(p, kind, modelName) {
|
||||||
|
await p.click("#new-chat-btn")
|
||||||
|
await p.click(`.kind__tab >> text=${kind}`)
|
||||||
|
if (modelName) {
|
||||||
|
await p.click(".cchip--model")
|
||||||
|
await p.locator(".menu .menu__item", { hasText: modelName }).click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subagentsChip(p) {
|
||||||
|
// The chip comes after the others (an open chat's waits for the server's word).
|
||||||
|
await p.waitForTimeout(150)
|
||||||
|
return p.locator("#composer-chips .cchip--subagents")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSlider(p) {
|
||||||
|
await p.click("#composer-chips .cchip--subagents")
|
||||||
|
await p.waitForSelector(".range2")
|
||||||
|
return {
|
||||||
|
min: await p.getAttribute(".range2__input--min", "max"),
|
||||||
|
text: await p.textContent(".pop__body"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const OWN = "This model serves one request at a time: subagents need another model, on another server, named in the chat."
|
||||||
|
|
||||||
|
function a5(routes = {}) {
|
||||||
|
return { status: status(), routes: baseRoutes(routes), streams: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("A5: a new chat on a model serving two at once offers Subagents, Min and Max to the server's cap", async () => {
|
||||||
|
const p = await page(a5())
|
||||||
|
await newChatOn(p, "Chat")
|
||||||
|
const chip = await subagentsChip(p)
|
||||||
|
assert.equal(await chip.count(), 1, "the chip is there")
|
||||||
|
const s = await openSlider(p)
|
||||||
|
assert.equal(s.min, "8", "up to the server's cap")
|
||||||
|
assert.ok(!s.text.includes(OWN), "no one-request note")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("A5: a new chat on a one-request model the server still offers: Subagents with the note", async () => {
|
||||||
|
const p = await page(a5())
|
||||||
|
await newChatOn(p, "Chat", "Solo")
|
||||||
|
const chip = await subagentsChip(p)
|
||||||
|
assert.equal(await chip.count(), 1)
|
||||||
|
const s = await openSlider(p)
|
||||||
|
assert.ok(s.text.includes(OWN), "the one-request note")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("A5: subagents off on the instance (offered false for every model): no Subagents", async () => {
|
||||||
|
const off = SUB(false)
|
||||||
|
const p = await page(a5({ "GET /models": models({ qwen: off, solo: off, plain: off }) }))
|
||||||
|
await newChatOn(p, "Chat")
|
||||||
|
assert.equal(await (await subagentsChip(p)).count(), 0)
|
||||||
|
await newChatOn(p, "Chat", "Solo")
|
||||||
|
assert.equal(await (await subagentsChip(p)).count(), 0)
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("A5: subagents switched on while the app stays open: the next new chat offers them", async () => {
|
||||||
|
const off = SUB(false)
|
||||||
|
const p = await page(a5({ "GET /models": models({ qwen: off, solo: off, plain: off }) }))
|
||||||
|
await newChatOn(p, "Chat")
|
||||||
|
assert.equal(await (await subagentsChip(p)).count(), 0, "off at sign-in")
|
||||||
|
// The administrator switches subagents on; the app read /models at sign-in.
|
||||||
|
await p.evaluate((m) => (window.__mockConfig.routes["GET /models"] = m), models())
|
||||||
|
await p.waitForTimeout(5200)
|
||||||
|
await p.click("#new-chat-btn")
|
||||||
|
await p.waitForSelector("#composer-chips .cchip--subagents")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("A5: an open chat follows its own `subagents`: offered, offered with the note, not offered", async () => {
|
||||||
|
for (const [said, chip, note] of [
|
||||||
|
[{ offered: true, named_only: false, cap: 8 }, 1, false],
|
||||||
|
[{ offered: true, named_only: true, cap: 8 }, 1, true],
|
||||||
|
[{ offered: false, named_only: false, cap: 8 }, 0, false],
|
||||||
|
]) {
|
||||||
|
const p = await page(
|
||||||
|
a5({
|
||||||
|
"GET /chats/chat1": { chat: chatRow({ subagents: said }), messages: [userMessage("m5", "How is lembas made?")], more: false, live: null },
|
||||||
|
"GET /models?chat=chat1": models(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await openChatRow(p, "chat1")
|
||||||
|
const c = await subagentsChip(p)
|
||||||
|
assert.equal(await c.count(), chip, `chip for ${JSON.stringify(said)}`)
|
||||||
|
if (chip) assert.equal((await openSlider(p)).text.includes(OWN), note)
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
await p.context().close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- B3: the Terminal panel -------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The server's two sockets for an app (`api/v2/terminal.py`): an agent chat's
|
||||||
|
// (`/chats/{chat}/terminal/ws`) and one before the chat exists, on the device and folder chosen
|
||||||
|
// (`/devices/{device}/terminal/ws?directory=`), which the chat's first message takes over.
|
||||||
|
// `GET /devices` says per device whether it opens a terminal. The socket is Rust's: the page asks
|
||||||
|
// `terminal_open` and gets the frames as `terminal` events (mock-tauri.js plays the server).
|
||||||
|
|
||||||
|
const READY = ["control", { t: "ready", label: "workbench", dir: "/home/dev/project", cols: 80, rows: 24, shared: false, osc: false }]
|
||||||
|
|
||||||
|
function b3(over = {}, terminal = [READY, ["output", "dev@workbench:~/project$ "]]) {
|
||||||
|
return {
|
||||||
|
status: status(),
|
||||||
|
routes: baseRoutes({
|
||||||
|
"GET /chats/chat2": { chat: agentChat({ running: false }), messages: [userMessage("m1", "What is in build?")], more: false, live: null },
|
||||||
|
"GET /models?chat=chat2": models(),
|
||||||
|
"POST /chats": { chat: agentChat({ running: false }), message: userMessage("m9", "Build it"), reply: null },
|
||||||
|
...over,
|
||||||
|
}),
|
||||||
|
streams: {},
|
||||||
|
terminal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function screenText(p) {
|
||||||
|
return p.evaluate(() => document.querySelector("#terminal .xterm-rows")?.textContent || "")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("B3: a new agent chat offers the Terminal once its folder is chosen, and opens a shell there before the first message", async () => {
|
||||||
|
const p = await page(b3())
|
||||||
|
await p.click("#new-chat-btn")
|
||||||
|
await p.click(".kind__tab >> text=Agent")
|
||||||
|
await p.waitForSelector("#composer-chips .cchip--terminal")
|
||||||
|
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 0, "nothing is opened until asked")
|
||||||
|
await p.click("#composer-chips .cchip--terminal")
|
||||||
|
await p.waitForSelector("#terminal:not([hidden]) .xterm")
|
||||||
|
await p.waitForFunction(() => window.__mock.terminals().length === 1)
|
||||||
|
const [shell] = await p.evaluate(() => window.__mock.terminals())
|
||||||
|
assert.deepEqual(shell.target, { chat: null, device: "token1", directory: "/home/dev/project" })
|
||||||
|
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("dev@workbench"))
|
||||||
|
assert.equal(await p.textContent("#terminal-message"), "Connected.")
|
||||||
|
assert.equal(await p.textContent("#terminal-title"), "workbench")
|
||||||
|
assert.equal(await p.getAttribute("#composer-chips .cchip--terminal", "aria-pressed"), "true")
|
||||||
|
// Keys go up through the bridge, never a socket of the page's own.
|
||||||
|
await p.click("#terminal .xterm")
|
||||||
|
await p.keyboard.type("ls")
|
||||||
|
await p.keyboard.press("Enter")
|
||||||
|
await p.waitForFunction(() => window.__mock.typed().includes("ls\r"))
|
||||||
|
assert.equal(await p.evaluate(() => window.__mock.calls.filter((c) => c.cmd === "terminal_open").length), 1)
|
||||||
|
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "b3-new-1280.png") })
|
||||||
|
// The first message makes the chat, which takes the shell over: the same socket goes on.
|
||||||
|
await p.click("#composer-text")
|
||||||
|
await p.fill("#composer-text", "Build it")
|
||||||
|
await p.click("#send")
|
||||||
|
await p.waitForFunction(() => window.__mock.api("POST", "/chats").length === 1)
|
||||||
|
const [made] = await p.evaluate(() => window.__mock.api("POST", "/chats"))
|
||||||
|
assert.equal(made.args.body.device, "token1")
|
||||||
|
assert.equal(made.args.body.directory, "/home/dev/project")
|
||||||
|
await p.waitForSelector('#chat-tools [data-terminal-toggle][aria-pressed="true"]')
|
||||||
|
assert.ok(await p.isVisible("#terminal"), "the panel stays open")
|
||||||
|
const shells = await p.evaluate(() => window.__mock.terminals())
|
||||||
|
assert.equal(shells.length, 1, "no second shell: the chat took the first one over")
|
||||||
|
assert.ok(shells[0].open, "and its socket is still open")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("B3: an open agent chat opens its own shell from the header; Send to chat fills the composer and sends nothing", async () => {
|
||||||
|
const p = await page(b3())
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.click('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.waitForFunction(() => window.__mock.terminals().length === 1)
|
||||||
|
assert.deepEqual((await p.evaluate(() => window.__mock.terminals()))[0].target, { chat: "chat2", device: null, directory: null })
|
||||||
|
await p.evaluate(() => window.__mock.shell("output", "\r\ntotal 4\r\n-rw-r--r-- 1 dev dev 12 build.log\r\n"))
|
||||||
|
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("build.log"))
|
||||||
|
await p.fill("#composer-text", "Look at this:")
|
||||||
|
await p.click("#terminal-send")
|
||||||
|
const text = await p.inputValue("#composer-text")
|
||||||
|
assert.ok(text.startsWith("Look at this:\n\n```\n"), `after what was typed, as a block: ${JSON.stringify(text.slice(0, 40))}`)
|
||||||
|
assert.match(text, /build\.log/)
|
||||||
|
assert.equal(await p.evaluate(() => window.__mock.api("POST", "/chats/chat2/messages").length), 0, "nothing is sent")
|
||||||
|
// Closing the panel keeps the shell; opening it again does not open another.
|
||||||
|
await p.click("#terminal-close")
|
||||||
|
assert.ok(await p.isHidden("#terminal"))
|
||||||
|
await p.click('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.waitForSelector("#terminal:not([hidden])")
|
||||||
|
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 1)
|
||||||
|
// The server ends it: said, and no new shell in its place.
|
||||||
|
await p.evaluate(() => window.__mock.shell("control", { t: "closed", message: "The shell exited." }))
|
||||||
|
await p.waitForFunction(() => document.querySelector("#terminal-message").textContent === "The shell exited.")
|
||||||
|
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 1)
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("B3: the terminal and the activity panel take turns in the panel column", async () => {
|
||||||
|
const p = await page(b3({ "GET /chats/chat2/activity": { activity: {} } }))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.click('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.waitForSelector("#terminal:not([hidden])")
|
||||||
|
assert.equal(await p.getAttribute("#shell", "data-terminal"), "true")
|
||||||
|
await p.click("#activity-open")
|
||||||
|
await p.waitForSelector("#activity:not([hidden])")
|
||||||
|
assert.ok(await p.isHidden("#terminal"), "the terminal gave way")
|
||||||
|
assert.equal(await p.getAttribute("#shell", "data-terminal"), "false")
|
||||||
|
await p.click('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.waitForSelector("#terminal:not([hidden])")
|
||||||
|
assert.ok(await p.isHidden("#activity"), "and the activity panel to it")
|
||||||
|
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 1, "the shell lived on behind it")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("B3: no Terminal where the device opens none, for a chat on the server, or before a folder", async () => {
|
||||||
|
const off = { "GET /devices": { devices: [{ id: "token1", name: "workbench", host: "workbench", platform: "linux", terminal: false }] } }
|
||||||
|
let p = await page(b3(off))
|
||||||
|
await p.click("#new-chat-btn")
|
||||||
|
await p.click(".kind__tab >> text=Agent")
|
||||||
|
await p.waitForSelector("#composer-chips .cchip")
|
||||||
|
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "the device opens no terminal")
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
assert.equal(await p.locator("#chat-tools [data-terminal-toggle]").count(), 0)
|
||||||
|
await p.context().close()
|
||||||
|
p = await page(b3({}))
|
||||||
|
await p.click("#new-chat-btn")
|
||||||
|
await p.click(".kind__tab >> text=Chat")
|
||||||
|
await p.waitForSelector("#composer-chips .cchip")
|
||||||
|
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "a chat on the server has no shell")
|
||||||
|
await p.context().close()
|
||||||
|
p = await page({ ...b3({}), status: status({ remote: { enabled: true, roots: [] } }) })
|
||||||
|
await p.click("#new-chat-btn")
|
||||||
|
await p.click(".kind__tab >> text=Agent")
|
||||||
|
await p.waitForSelector("#composer-chips .cchip")
|
||||||
|
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "no folder yet")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("B3: a refused sign-in and an older server are said in words", async () => {
|
||||||
|
let p = await page(b3({}, [["end", { code: 4401, reason: "not allowed", status: null, error: null }]]))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.click('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.waitForFunction(() => /Sign in again/.test(document.querySelector("#terminal-message").textContent))
|
||||||
|
assert.ok(await p.evaluate(() => document.querySelector("#terminal-message").classList.contains("is-error")))
|
||||||
|
await p.context().close()
|
||||||
|
p = await page(b3({}, [["end", { code: null, reason: "", status: 404, error: null }]]))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.click('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.waitForFunction(() => /needs a newer LLeMbas server/.test(document.querySelector("#terminal-message").textContent))
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("B3: at 390 px the terminal is a sheet over the chat, inside the window", async () => {
|
||||||
|
const p = await page(b3(), { width: 390, height: 844 })
|
||||||
|
await p.evaluate(() => document.querySelector('.chatrow[data-id="chat2"]').click())
|
||||||
|
await p.waitForSelector('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.click('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("dev@workbench"))
|
||||||
|
const box = await p.locator("#terminal").boundingBox()
|
||||||
|
assert.ok(box.x >= 0 && box.x + box.width <= 390 && box.y + box.height <= 844, `inside: ${JSON.stringify(box)}`)
|
||||||
|
const send = await p.locator("#terminal-send").boundingBox()
|
||||||
|
assert.ok(send.x + send.width <= 390 && send.y + send.height <= 844, `Send is reachable: ${JSON.stringify(send)}`)
|
||||||
|
const close = await p.locator("#terminal-close").boundingBox()
|
||||||
|
assert.ok(close.x + close.width <= 390, "Close is reachable")
|
||||||
|
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= 390), "nothing scrolls sideways")
|
||||||
|
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "b3-chat-390.png") })
|
||||||
|
// Send to chat: the sheet steps aside, and the message box has the block.
|
||||||
|
await p.click("#terminal-send")
|
||||||
|
assert.ok(await p.isHidden("#terminal"))
|
||||||
|
assert.match(await p.inputValue("#composer-text"), /dev@workbench/)
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("B3: at 1280 px the terminal sits beside the chat, which keeps its column", async () => {
|
||||||
|
const p = await page(b3())
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.click('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("dev@workbench"))
|
||||||
|
const term = await p.locator("#terminal").boundingBox()
|
||||||
|
const main = await p.locator("#main").boundingBox()
|
||||||
|
assert.ok(term.x >= main.x + main.width - 1, "beside, not over")
|
||||||
|
assert.ok(main.width >= 400, `the chat keeps room: ${main.width}`)
|
||||||
|
assert.ok(term.width >= 380, `the terminal has its width: ${term.width}`)
|
||||||
|
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "b3-chat-1280.png") })
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- B1: the progress line ---------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Harness spec 3.4.0 (harness/progress.md in the LLeMbas CLI): one `progress` object, drawn by
|
||||||
|
// every client as `{{text}} · {{elapsed}}`. tests/ui/conformance/progress.json is a copy of the
|
||||||
|
// spec's conformance/progress.json (3.4.0); every case must come out word for word.
|
||||||
|
|
||||||
|
test("B1: every conformance case of the spec draws the same text and line", async () => {
|
||||||
|
const { cases } = JSON.parse(readFileSync(join(HERE, "conformance", "progress.json"), "utf8"))
|
||||||
|
const p = await page(b3())
|
||||||
|
const got = await p.evaluate(async (cs) => {
|
||||||
|
const m = await import("/js/progress.js")
|
||||||
|
return cs.map((c) => ({ text: m.progressText(c.progress, c.now), line: m.progressLine(c.progress, c.now) }))
|
||||||
|
}, cases)
|
||||||
|
cases.forEach((c, i) => assert.deepEqual(got[i], c.expect, c.about))
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("B1: a reply being written draws the stream's progress, its clock moving, and the status text without one", async () => {
|
||||||
|
const p = await page(agentChatOpen([["status", { text: "Writing…" }]]))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.waitForSelector(".msg.is-live .msg__status")
|
||||||
|
// An older server sends no progress at all: its status text, as before.
|
||||||
|
await p.evaluate((path) => window.__mock.frame(path, "status", { text: "Running bake --slow" }), STREAM2)
|
||||||
|
assert.equal(await p.textContent(".msg.is-live .msg__status"), "Running bake --slow")
|
||||||
|
// The server's status frame (event_status.json), its clock set 65 s back from now.
|
||||||
|
const status = fixture("event_status")
|
||||||
|
await p.evaluate(([path, f]) => {
|
||||||
|
window.__mock.frame(path, "status", { ...f, progress: { ...f.progress, since: Date.now() - 65000 } })
|
||||||
|
}, [STREAM2, status])
|
||||||
|
const line = () => p.textContent(".msg.is-live .msg__status")
|
||||||
|
assert.equal(await line(), "Running bash: bake --slow · 1m 05s")
|
||||||
|
assert.equal(await p.getAttribute(".msg.is-live .msg__status", "data-phase"), "running_tool")
|
||||||
|
await p.waitForFunction(() => document.querySelector(".msg.is-live .msg__status").textContent.endsWith("1m 06s"), null, { timeout: 2500 })
|
||||||
|
// A data-only frame with progress (the ask frame carries it too): the line follows.
|
||||||
|
await p.evaluate((path) => window.__mock.frame(path, "ask", { cards: [], progress: { phase: "waiting_for_person", since: Date.now(), what: "approval", tool: "bash" } }), STREAM2)
|
||||||
|
assert.equal(await line(), "Waiting for you: allow bash? · 0s")
|
||||||
|
// Nothing running (event_status_idle.json: no text, `progress: null`): the plain words again.
|
||||||
|
await p.evaluate(([path, f]) => window.__mock.frame(path, "status", f), [STREAM2, fixture("event_status_idle")])
|
||||||
|
assert.equal(await line(), "Writing…")
|
||||||
|
// Waiting for a person's answer (event_status_person.json): the phase, not the text.
|
||||||
|
await p.evaluate(([path, f]) => window.__mock.frame(path, "status", { ...f, progress: { ...f.progress, since: Date.now() } }), [STREAM2, fixture("event_status_person")])
|
||||||
|
assert.equal(await line(), "Waiting for you: a question · 0s")
|
||||||
|
// Stopped and failed have no clock.
|
||||||
|
await p.evaluate(([path, f]) => window.__mock.frame(path, "status", f), [STREAM2, fixture("event_status_stopped")])
|
||||||
|
assert.equal(await line(), "Stopped: by you")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- C4: subagents (spec 3.4.0) ---------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The server's /api/v2 (its fixtures, copied into tests/ui/fixtures): GET /chats/{chat}/tree ->
|
||||||
|
// {tree, room, live}, the stream's `tree` frame, a subagent's transcript and findings, POST
|
||||||
|
// .../stop; an agent chat's device away -> the kept tree (`live` false) and 409 for the rest;
|
||||||
|
// /models?kind=agent&device= for a new agent chat's Subagents; `subagent` on an approval item.
|
||||||
|
|
||||||
|
const ACTIVITY = { activity: { version: "v1", summary: { active: 0 }, agents: [], context: null, outputs: [], team: null, plan: null } }
|
||||||
|
|
||||||
|
function c4(over = {}) {
|
||||||
|
return b3({
|
||||||
|
"GET /chats/chat2/activity": ACTIVITY,
|
||||||
|
"GET /chats/chat2/tree": fixture("tree"),
|
||||||
|
"GET /chats/chat2/subagents/s2/transcript": fixture("subagent_transcript"),
|
||||||
|
"GET /chats/chat2/subagents/s2/findings": fixture("subagent_findings"),
|
||||||
|
"POST /chats/chat2/subagents/s2/stop": { stopped: ["s2"] },
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTree(p) {
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.click("#activity-open")
|
||||||
|
await p.waitForSelector("#activity .subagents .subagent")
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (p) => p.evaluate(() => [...document.querySelectorAll("#activity .subagents__list > .subagent")].map((r) => [r.dataset.id, r.style.getPropertyValue("--depth")]))
|
||||||
|
|
||||||
|
test("C4: the activity panel draws the server's tree: nested by parent, slots, the ended ones apart", async () => {
|
||||||
|
const p = await page(c4())
|
||||||
|
await openTree(p)
|
||||||
|
assert.deepEqual(await rows(p), [["lead", "0"], ["s1", "1"], ["s2", "2"]])
|
||||||
|
const routes = await p.textContent('#activity .subagent[data-id="s2"]')
|
||||||
|
assert.match(routes, /routes/)
|
||||||
|
assert.match(routes, /writes the routes/)
|
||||||
|
assert.match(routes, /Running read: src\/api\.py/)
|
||||||
|
assert.match(routes, /6%/, "context: floor(9100 × 100 / 131072)")
|
||||||
|
assert.match(routes, /edit \+ execute/)
|
||||||
|
assert.match(routes, /messages: 1/)
|
||||||
|
assert.match(routes, /routes → area: Routes are in\./, "the last room message to or from it")
|
||||||
|
assert.match(await p.textContent('#activity .subagent[data-id="lead"]'), /the main agent/)
|
||||||
|
assert.match(await p.textContent("#activity .subagents"), /3 of 4/)
|
||||||
|
assert.match(await p.textContent("#activity .subagents"), /They run on the computer this chat runs on\./)
|
||||||
|
// The ended one, folded apart, with how it ended, and no stop square.
|
||||||
|
await p.click("#activity .subagents__ended > summary")
|
||||||
|
const scout = await p.textContent('#activity .subagents__ended .subagent[data-id="s3"]')
|
||||||
|
assert.match(scout, /done/)
|
||||||
|
assert.equal(await p.locator('#activity .subagent[data-id="s3"] button.agent__stop').count(), 0)
|
||||||
|
assert.equal(await p.locator('#activity .subagent[data-id="lead"] button.agent__stop').count(), 0, "the main agent is not stopped from here")
|
||||||
|
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "c4-tree-1280.png") })
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("C4: a subagent's conversation and findings open read-only; Stop asks, then stops it", async () => {
|
||||||
|
const p = await page(c4())
|
||||||
|
await openTree(p)
|
||||||
|
await p.click('#activity .subagent[data-id="s2"] > summary')
|
||||||
|
await p.click('#activity .subagent[data-id="s2"] .subagent__buttons .btn >> text=Open its conversation')
|
||||||
|
await p.waitForSelector("#activity .convo .convo__turn")
|
||||||
|
const convo = await p.textContent("#activity-body")
|
||||||
|
assert.match(convo, /Write the routes\./)
|
||||||
|
assert.match(convo, /Reading the API first\./)
|
||||||
|
assert.match(convo, /src\/api\.py/)
|
||||||
|
assert.equal(await p.locator("#activity-body textarea, #activity-body input").count(), 0, "nothing to type into")
|
||||||
|
await p.click("#activity-back")
|
||||||
|
await p.click('#activity .subagent[data-id="s2"] .subagent__buttons .btn >> text=Findings')
|
||||||
|
await p.waitForSelector("#activity .findings")
|
||||||
|
assert.match(await p.textContent("#activity .findings"), /Login is POST \/session\./)
|
||||||
|
await p.click("#activity-back")
|
||||||
|
await p.click('#activity .subagent[data-id="s2"] button.agent__stop')
|
||||||
|
await p.waitForSelector("dialog[open]")
|
||||||
|
assert.match(await p.textContent("dialog[open]"), /Stop routes and the subagents under it\?/)
|
||||||
|
await p.click("dialog[open] .btn--danger")
|
||||||
|
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat2/subagents/s2/stop").length === 1)
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("C4: the stream's tree frame redraws the panel; with the device away the kept tree shows and the rest says why", async () => {
|
||||||
|
const away = { ...fixture("tree"), live: false }
|
||||||
|
const p = await page(c4({ "GET /chats/chat2/tree": away, "GET /chats/chat2/subagents/s2/transcript": { __error: { status: 409, detail: "That is kept on the computer this chat runs on, which is not linked right now." } } }))
|
||||||
|
await openTree(p)
|
||||||
|
assert.match(await p.textContent("#activity .subagents"), /This device is not linked right now\./)
|
||||||
|
await p.click('#activity .subagent[data-id="s2"] > summary')
|
||||||
|
await p.click('#activity .subagent[data-id="s2"] .subagent__buttons .btn >> text=Open its conversation')
|
||||||
|
await p.waitForSelector("#activity-body .is-error")
|
||||||
|
assert.match(await p.textContent("#activity-body"), /not linked right now/)
|
||||||
|
await p.click("#activity-back")
|
||||||
|
// A reply streams; its `tree` frame (event_tree.json) is the tree now.
|
||||||
|
await p.evaluate(() => window.__mock.frame("/chats/chat2/messages/m2/stream", "tree", null))
|
||||||
|
await p.context().close()
|
||||||
|
const live = await page({ ...c4(), routes: { ...c4().routes, "GET /chats/chat2": { chat: agentChat(), messages: [userMessage("m1", "Go")], more: false, live: "m2" } }, streams: { [STREAM2]: [] } })
|
||||||
|
await openTree(live)
|
||||||
|
await live.evaluate(([path, f]) => window.__mock.frame(path, "tree", f), [STREAM2, fixture("event_tree")])
|
||||||
|
await live.waitForFunction(() => !!document.querySelector('#activity .subagent[data-id="s2"]') && /parser/.test(document.querySelector('#activity .subagent[data-id="s2"]').textContent))
|
||||||
|
assert.match(await live.textContent('#activity .subagent[data-id="s2"]'), /reviews the parser/)
|
||||||
|
assert.match(await live.textContent('#activity .subagent[data-id="s2"]'), /manual \+ write/)
|
||||||
|
assert.deepEqual(live.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("C4: an approval a subagent asks for names it", async () => {
|
||||||
|
const card = fixture("event_ask").cards.find((c) => c.id === "card4")
|
||||||
|
const p = await page(agentChatOpen([["ask", { cards: [card] }]], { "POST /chats/chat2/interactions/card4": { answered: true } }))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.waitForSelector("#cards .ask__subagent")
|
||||||
|
assert.equal(await p.textContent("#cards .ask__subagent"), "Subagent parser (s1), reviews the parser, asks:")
|
||||||
|
assert.equal(await p.inputValue("#cards .ask__edit"), "git push", "the command, correctable, as for the main agent's")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("C4: Subagents for a new agent chat as /models?kind=agent says for this device; for an open one as the chat says", async () => {
|
||||||
|
const facts = fixture("models_agent")
|
||||||
|
const newAgent = async (models) => {
|
||||||
|
const p = await page(c4({ "GET /models?kind=agent&device=token1": models }))
|
||||||
|
await p.click("#new-chat-btn")
|
||||||
|
await p.click(".kind__tab >> text=Agent")
|
||||||
|
await p.waitForSelector("#composer-chips .cchip")
|
||||||
|
await p.waitForTimeout(200)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
let p = await newAgent(facts)
|
||||||
|
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 1, "offered, and the device runs them")
|
||||||
|
await p.click("#composer-chips .cchip--subagents")
|
||||||
|
await p.waitForSelector(".range2")
|
||||||
|
assert.equal(await p.getAttribute(".range2__input--min", "max"), "32", "the server's cap")
|
||||||
|
await p.context().close()
|
||||||
|
p = await newAgent({ ...facts, agent_subagents: { offered: true, device_runs: false } })
|
||||||
|
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "this computer's CLI cannot run them")
|
||||||
|
await p.context().close()
|
||||||
|
p = await newAgent({ ...facts, agent_subagents: { offered: false, device_runs: true } })
|
||||||
|
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "not offered")
|
||||||
|
await p.context().close()
|
||||||
|
p = await newAgent({ __error: { status: 400, detail: "older server" } })
|
||||||
|
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "a server before 3.4.0: none, as before")
|
||||||
|
await p.context().close()
|
||||||
|
// An open agent chat: its own `subagents` (chat_agent.json offers them).
|
||||||
|
const chat = fixture("chat_agent")
|
||||||
|
p = await page(c4({ "GET /chats/chat2": { chat: { ...agentChat({ running: false }), subagents: chat.subagents, agents: chat.agents }, messages: [userMessage("m1", "Go")], more: false, live: null } }))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.waitForSelector("#composer-chips .cchip--subagents")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("C4: the tree at 390 px stays inside the window", async () => {
|
||||||
|
const p = await page(c4(), { width: 390, height: 844 })
|
||||||
|
await p.evaluate(() => document.querySelector('.chatrow[data-id="chat2"]').click())
|
||||||
|
await p.waitForSelector("#activity-open")
|
||||||
|
await p.click("#activity-open")
|
||||||
|
await p.waitForSelector("#activity .subagents .subagent")
|
||||||
|
await p.click('#activity .subagent[data-id="s2"] > summary')
|
||||||
|
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= 390), "nothing scrolls sideways")
|
||||||
|
const panel = await p.evaluate(() => { const b = document.querySelector("#activity-body"); return b.scrollWidth <= b.clientWidth })
|
||||||
|
assert.ok(panel, "the panel does not scroll sideways")
|
||||||
|
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "c4-tree-390.png") })
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Run ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const only = process.argv[2] || ""
|
||||||
|
browser = await launch()
|
||||||
|
let failed = 0
|
||||||
|
for (const { name, fn } of tests) {
|
||||||
|
if (only && !name.includes(only)) continue
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
console.log(`ok ${name}`)
|
||||||
|
} catch (err) {
|
||||||
|
failed++
|
||||||
|
console.log(`FAIL ${name}\n ${String(err?.stack || err).split("\n").slice(0, 6).join("\n ")}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await browser.close()
|
||||||
|
console.log(failed ? `tests/ui: ${failed} failed` : "tests/ui: all passed")
|
||||||
|
process.exit(failed ? 1 : 0)
|
||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"tag": "v2.0.0-beta.5",
|
"tag": "v2.0.0-beta.8",
|
||||||
"commit": "02d0c874434e3b726193bf94fb9741b4f8d0a22c",
|
"commit": "d06ca353b801916302f0ce0cc9ae3e5244ff184f",
|
||||||
"sha256": "9c40356cb6f89bf978b9034ef1ec0fb9b46c18790be263752024c1b1552d4ac4"
|
"sha256": "6fd6d9d530c005dc52ad7dfcd61b3515b29955aa6d55365d35df3936fb7f29da"
|
||||||
}
|
}
|
||||||
+1033
-446
File diff suppressed because it is too large.
Load diff
+1568
-152
File diff suppressed because it is too large.
Load diff
+90
-44
@@ -13,13 +13,13 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<header class="bar" data-tauri-drag-region>
|
<header class="bar" data-tauri-drag-region>
|
||||||
<button class="bar__btn bar__side" id="side-toggle" type="button" aria-label="Chats" title="Chats" aria-expanded="false" aria-controls="side" data-i18n-aria-label data-i18n-title></button>
|
<button class="bar__btn" id="side-toggle" type="button" aria-label="Sidebar" title="Sidebar" aria-expanded="true" aria-controls="side" data-i18n-aria-label data-i18n-title></button>
|
||||||
<img class="bar__leaf" src="logo-mark.svg" alt="" width="18" height="18" data-tauri-drag-region>
|
<img class="bar__leaf" src="logo-mark.svg" alt="" width="18" height="18" data-tauri-drag-region>
|
||||||
<span class="bar__name" data-tauri-drag-region><b>LL</b>e<b>M</b>bas</span>
|
<span class="bar__name" data-tauri-drag-region><b>LL</b>e<b>M</b>bas</span>
|
||||||
<span class="bar__host" id="host" data-tauri-drag-region></span>
|
<span class="bar__host" id="host" data-tauri-drag-region></span>
|
||||||
<button class="bar__update" id="update" type="button" hidden>update</button>
|
<button class="bar__update" id="update" type="button" hidden>update</button>
|
||||||
<button class="bar__agent" id="agent" type="button" title="Agent">
|
<button class="bar__agent" id="agent" type="button" aria-haspopup="dialog" aria-expanded="false">
|
||||||
<span class="dot" aria-hidden="true"></span><span id="agent-text">agent off</span>
|
<span class="dot" aria-hidden="true"></span><span class="bar__agent-text" id="agent-text">agent off</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="bar__btn" id="menu" type="button" aria-label="Menu" title="Menu" data-i18n-aria-label data-i18n-title></button>
|
<button class="bar__btn" id="menu" type="button" aria-label="Menu" title="Menu" data-i18n-aria-label data-i18n-title></button>
|
||||||
<span class="bar__sep" aria-hidden="true"></span>
|
<span class="bar__sep" aria-hidden="true"></span>
|
||||||
@@ -41,79 +41,125 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="shell" id="shell" hidden data-side="false" data-viewer="false">
|
<div class="shell" id="shell" hidden data-side="true" data-viewer="false" data-view="chat">
|
||||||
<nav class="side" id="side" aria-label="Chats" data-i18n-aria-label data-local="true" data-server="false">
|
<nav class="side" id="side" aria-label="Chats" data-i18n-aria-label>
|
||||||
<div class="switch" role="tablist">
|
<div class="side__top">
|
||||||
<button type="button" role="tab" data-kind="agent" aria-selected="true" data-i18n>Agent</button>
|
<button type="button" class="side__new" id="new-chat-btn"><span class="side__new-label" data-i18n>New chat</span><kbd class="kbd">Ctrl N</kbd></button>
|
||||||
<button type="button" role="tab" data-kind="chat" aria-selected="false" data-i18n>Chats</button>
|
<label class="search">
|
||||||
|
<span class="search__icon" id="side-search-icon"></span>
|
||||||
|
<input type="search" id="side-search" placeholder="Search chats" aria-label="Search chats" autocomplete="off" spellcheck="false" data-i18n-placeholder data-i18n-aria-label>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn--primary side__new" id="new-chat-btn"><span data-i18n>New agent chat</span></button>
|
<div class="side__nav">
|
||||||
<ul class="list" id="chat-list"></ul>
|
<button type="button" class="navrow" id="nav-scheduled" data-view="scheduled"><span class="navrow__icon"></span><span data-i18n>Scheduled</span></button>
|
||||||
|
<button type="button" class="navrow" id="nav-files" data-view="files"><span class="navrow__icon"></span><span data-i18n>Files</span></button>
|
||||||
<section class="pane" id="pane-local" data-open="true" aria-labelledby="pane-local-h">
|
|
||||||
<div class="pane__head">
|
|
||||||
<button type="button" class="pane__toggle" aria-expanded="true" id="pane-local-h"><span data-i18n>This computer</span></button>
|
|
||||||
<button type="button" class="pane__btn" id="local-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="pane__body">
|
<ul class="list" id="chat-list" aria-label="Chats" data-i18n-aria-label></ul>
|
||||||
<div class="pane__where" id="local-where"></div>
|
<div class="side__foot">
|
||||||
<div class="pane__tree" id="local-tree"></div>
|
<button type="button" class="navrow navrow--account" id="settings-btn" data-view="settings"></button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="pane" id="pane-server" data-open="false" aria-labelledby="pane-server-h">
|
|
||||||
<div class="pane__head">
|
|
||||||
<button type="button" class="pane__toggle" aria-expanded="false" id="pane-server-h"><span data-i18n>Server files</span></button>
|
|
||||||
<button type="button" class="pane__btn" id="server-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
|
||||||
</div>
|
|
||||||
<div class="pane__body">
|
|
||||||
<div class="pane__tree" id="server-tree"></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<button type="button" class="side__settings" id="settings-btn"></button>
|
|
||||||
</nav>
|
</nav>
|
||||||
<div class="scrim" id="scrim" aria-hidden="true"></div>
|
<div class="scrim" id="scrim" aria-hidden="true"></div>
|
||||||
|
|
||||||
<main class="main" id="main" aria-labelledby="chat-title">
|
<main class="main" id="main" aria-labelledby="chat-title" data-new="false">
|
||||||
<header class="head">
|
<header class="head">
|
||||||
<h1 class="head__title" id="chat-title"></h1>
|
<h1 class="head__title" id="chat-title"></h1>
|
||||||
<div class="head__place" id="chat-place"></div>
|
<div class="head__place" id="chat-place"></div>
|
||||||
<div class="head__tools" id="chat-tools"></div>
|
<div class="head__tools" id="chat-tools"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="thread" id="thread">
|
<div class="thread" id="thread">
|
||||||
<p class="thread__note is-error" id="thread-note" hidden></p>
|
<div class="hello" id="new-chat" hidden></div>
|
||||||
<div class="new" id="new-chat" hidden></div>
|
|
||||||
<div class="messages" id="messages"></div>
|
<div class="messages" id="messages"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dock" id="dock">
|
||||||
|
<p class="thread__note is-error" id="thread-note" hidden></p>
|
||||||
<div class="cards" id="cards" hidden></div>
|
<div class="cards" id="cards" hidden></div>
|
||||||
|
<div class="notice" id="link-notice" hidden></div>
|
||||||
<div class="strip" id="activity-strip" hidden aria-live="polite"></div>
|
<div class="strip" id="activity-strip" hidden aria-live="polite"></div>
|
||||||
<form class="composer" id="composer" hidden data-running="false" data-drag="false">
|
<form class="composer" id="composer" hidden data-running="false" data-drag="false">
|
||||||
<ul class="composer__files" id="composer-files" hidden></ul>
|
<ul class="composer__files" id="composer-files" hidden></ul>
|
||||||
<button type="button" class="btn composer__attach" id="attach" aria-label="Attach files" title="Attach files" data-i18n-aria-label data-i18n-title></button>
|
|
||||||
<textarea id="composer-text" rows="1" placeholder="Message" aria-label="Message" data-i18n-placeholder data-i18n-aria-label></textarea>
|
<textarea id="composer-text" rows="1" placeholder="Message" aria-label="Message" data-i18n-placeholder data-i18n-aria-label></textarea>
|
||||||
<p class="composer__drop" aria-hidden="true" data-i18n>Drop the files to attach them</p>
|
<div class="composer__bar">
|
||||||
<div class="composer__actions">
|
<button type="button" class="tool composer__attach" id="attach" aria-label="Attach files" title="Attach files" data-i18n-aria-label data-i18n-title></button>
|
||||||
<button type="button" class="btn" id="stop" hidden data-i18n>Stop</button>
|
<div class="composer__chips" id="composer-chips"></div>
|
||||||
<button type="submit" class="btn btn--primary" id="send" data-i18n>Send</button>
|
<button type="button" class="round round--stop" id="stop" hidden aria-label="Stop" title="Stop" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
<button type="submit" class="round" id="send" aria-label="Send" title="Send" data-i18n-aria-label data-i18n-title></button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="composer__drop" aria-hidden="true" data-i18n>Drop the files to attach them</p>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<section class="settings" id="settings" hidden aria-labelledby="settings-title">
|
<section class="page" id="page-files" hidden aria-labelledby="files-title">
|
||||||
|
<header class="head">
|
||||||
|
<h1 class="head__title" id="files-title" data-i18n>Files</h1>
|
||||||
|
<div class="head__place"></div>
|
||||||
|
<div class="head__tools"><div class="tabs" role="tablist" id="files-tabs"></div></div>
|
||||||
|
</header>
|
||||||
|
<div class="page__body files">
|
||||||
|
<section class="files__pane" id="pane-local" aria-label="This computer" data-i18n-aria-label>
|
||||||
|
<div class="files__head">
|
||||||
|
<div class="files__where" id="local-where"></div>
|
||||||
|
<button type="button" class="tool" id="local-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
</div>
|
||||||
|
<div class="files__tree" id="local-tree"></div>
|
||||||
|
</section>
|
||||||
|
<section class="files__pane" id="pane-server" hidden aria-label="Server" data-i18n-aria-label>
|
||||||
|
<div class="files__head">
|
||||||
|
<div class="files__where muted" id="server-where"></div>
|
||||||
|
<button type="button" class="tool" id="server-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
</div>
|
||||||
|
<div class="files__tree" id="server-tree"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="page" id="page-scheduled" hidden aria-labelledby="scheduled-title">
|
||||||
|
<header class="head">
|
||||||
|
<h1 class="head__title" id="scheduled-title" data-i18n>Scheduled</h1>
|
||||||
|
<div class="head__place" id="scheduled-place"></div>
|
||||||
|
<div class="head__tools" id="scheduled-tools"></div>
|
||||||
|
</header>
|
||||||
|
<div class="page__body" id="scheduled-body"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="page settings" id="settings" hidden aria-labelledby="settings-title">
|
||||||
<header class="head">
|
<header class="head">
|
||||||
<h1 class="head__title" id="settings-title" data-i18n>Settings</h1>
|
<h1 class="head__title" id="settings-title" data-i18n>Settings</h1>
|
||||||
<div class="head__place"></div>
|
<div class="head__place"></div>
|
||||||
<div class="head__tools"><button type="button" class="pane__btn" id="settings-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button></div>
|
<div class="head__tools"><button type="button" class="tool" id="settings-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button></div>
|
||||||
</header>
|
</header>
|
||||||
|
<div class="settings__layout">
|
||||||
|
<nav class="settings__nav" id="settings-nav" aria-label="Settings" data-i18n-aria-label></nav>
|
||||||
<div class="settings__body" id="settings-body"></div>
|
<div class="settings__body" id="settings-body"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<aside class="termpanel" id="terminal" hidden aria-labelledby="terminal-title">
|
||||||
|
<header class="viewer__head termpanel__head">
|
||||||
|
<span class="termpanel__icon" id="terminal-icon" aria-hidden="true"></span>
|
||||||
|
<div class="viewer__id">
|
||||||
|
<h2 class="viewer__name" id="terminal-title" data-i18n>Terminal</h2>
|
||||||
|
<p class="viewer__where termpanel__where" id="terminal-where"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="tool" id="terminal-prev" hidden aria-label="The previous command" title="The previous command (Ctrl+Shift+↑)" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
<button type="button" class="tool" id="terminal-next" hidden aria-label="The next command" title="The next command (Ctrl+Shift+↓)" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
<button type="button" class="tool" id="terminal-copy" aria-label="Copy the last command and its output" title="Copy the last command and its output" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
<button type="button" class="tool" id="terminal-close" aria-label="Close terminal" title="Close terminal" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
</header>
|
||||||
|
<div class="termpanel__screen" id="terminal-screen"></div>
|
||||||
|
<footer class="termpanel__foot">
|
||||||
|
<p class="termpanel__message" id="terminal-message" aria-live="polite"></p>
|
||||||
|
<span class="termpanel__last" id="terminal-last"></span>
|
||||||
|
<button type="button" class="btn btn--sm termpanel__send" id="terminal-send" title="Put the last command and its output into the message box" data-i18n-title><span data-i18n>Send to chat</span></button>
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<aside class="activity" id="activity" hidden aria-labelledby="activity-title">
|
<aside class="activity" id="activity" hidden aria-labelledby="activity-title">
|
||||||
<header class="viewer__head activity__head">
|
<header class="viewer__head activity__head">
|
||||||
<button type="button" class="pane__btn" id="activity-back" hidden aria-label="Back" title="Back" data-i18n-aria-label data-i18n-title></button>
|
<button type="button" class="tool" id="activity-back" hidden aria-label="Back" title="Back" data-i18n-aria-label data-i18n-title></button>
|
||||||
<h2 class="viewer__name" id="activity-title"></h2>
|
<h2 class="viewer__name" id="activity-title"></h2>
|
||||||
<button type="button" class="pane__btn" id="activity-close" aria-label="Close the activity panel" title="Close the activity panel" data-i18n-aria-label data-i18n-title></button>
|
<button type="button" class="tool" id="activity-close" aria-label="Close the activity panel" title="Close the activity panel" data-i18n-aria-label data-i18n-title></button>
|
||||||
</header>
|
</header>
|
||||||
<div class="activity__body" id="activity-body"></div>
|
<div class="activity__body" id="activity-body"></div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -125,7 +171,7 @@
|
|||||||
<p class="viewer__where" id="viewer-where"></p>
|
<p class="viewer__where" id="viewer-where"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="viewer__modes" id="viewer-modes"></div>
|
<div class="viewer__modes" id="viewer-modes"></div>
|
||||||
<button type="button" class="pane__btn" id="viewer-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button>
|
<button type="button" class="tool" id="viewer-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button>
|
||||||
</header>
|
</header>
|
||||||
<div class="viewer__body" id="viewer-body"></div>
|
<div class="viewer__body" id="viewer-body"></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
+341
-62
@@ -1,14 +1,26 @@
|
|||||||
// The chat's activity panel, beside it: what is in its context, by category, against the model's
|
// The chat's activity panel, beside it, opened from the header's button at any time: where an
|
||||||
// window; the files the chat and its agents wrote; every background agent -- a team's members as
|
// agent chat works; what is in its context, by category, against the model's window, how far it
|
||||||
// a tree, helpers and commands -- with its state, how long it has run, its model, its latest step
|
// is to the automatic compaction and a button to compact now; the files the chat and its
|
||||||
// and what it is writing, its whole conversation read-only and a button to stop it; the team's
|
// subagents wrote; every background task -- subagents, a team's members as a tree under the one
|
||||||
// room; and the plan. The strip above the message box sums the agents up and opens it. The same
|
// that leads them, background commands -- with how long it has run and a stop square, unfolding
|
||||||
// facts as the server's own panel (`services/activity.py`), from /api/v2:
|
// to its latest step, what it is writing and its whole conversation, read-only; the finished ones
|
||||||
|
// folded; the team's room; and the plan. The line above the message box sums up what runs, while
|
||||||
|
// anything does, and opens it. The same facts as the server's own panel (`services/activity.py`),
|
||||||
|
// from /api/v2:
|
||||||
//
|
//
|
||||||
// GET /chats/{chat}/activity?since=&context= everything, with a `version`
|
// GET /chats/{chat}/activity?since=&context= everything, with a `version`
|
||||||
// GET /chats/{chat}/activity/events `activity` {version, summary} on each change
|
// GET /chats/{chat}/activity/events `activity` {version, summary} on each change
|
||||||
// GET /chats/{chat}/agents/{kind}/{id} one agent and its conversation
|
// GET /chats/{chat}/agents/{kind}/{id} one task and its conversation
|
||||||
// POST /chats/{chat}/agents/{kind}/{id}/stop
|
// POST /chats/{chat}/agents/{kind}/{id}/stop
|
||||||
|
// POST /chats/{chat}/compact summarise the earlier turns now
|
||||||
|
// GET /chats/{chat}/tree?since= the subagent tree (spec 3.4.0) and the room
|
||||||
|
// GET /chats/{chat}/subagents/{id}/transcript a subagent's conversation, read-only
|
||||||
|
// GET /chats/{chat}/subagents/{id}/findings what it kept
|
||||||
|
// POST /chats/{chat}/subagents/{id}/stop it and those under it
|
||||||
|
//
|
||||||
|
// The tree comes too as the reply stream's `tree` frame (chat.js hands it on). An agent chat's
|
||||||
|
// subagents run on its device: while the device is away the server sends the tree it kept
|
||||||
|
// (`live` false) and answers 409 for a conversation or findings, in its own words.
|
||||||
//
|
//
|
||||||
// The events stream is followed for as long as a chat is open: it keeps the strip current, and
|
// The events stream is followed for as long as a chat is open: it keeps the strip current, and
|
||||||
// while the panel is open a new `version` reads the panel again. The context is the costly part
|
// while the panel is open a new `version` reads the panel again. The context is the costly part
|
||||||
@@ -23,6 +35,7 @@ import { get, post, follow, why } from "./bridge.js"
|
|||||||
import { markdown } from "./markdown.js"
|
import { markdown } from "./markdown.js"
|
||||||
import { ask } from "./popup.js"
|
import { ask } from "./popup.js"
|
||||||
import { openServerFile } from "./explorer.js"
|
import { openServerFile } from "./explorer.js"
|
||||||
|
import { treePart, tickTree, treeState, TREE_LIVE } from "./tree.js"
|
||||||
|
|
||||||
const ACTIVE = ["queued", "working", "waiting"]
|
const ACTIVE = ["queued", "working", "waiting"]
|
||||||
// How many live agents show before "Show N more", as on the server's panel.
|
// How many live agents show before "Show N more", as on the server's panel.
|
||||||
@@ -31,6 +44,8 @@ const SHOWN = 6
|
|||||||
const CONTEXT_EVERY = 15000
|
const CONTEXT_EVERY = 15000
|
||||||
|
|
||||||
let layout = () => {} // (on) -> the shell makes room for the panel
|
let layout = () => {} // (on) -> the shell makes room for the panel
|
||||||
|
let onThisComputer = () => false // (chat) -> whether an agent chat works on this computer
|
||||||
|
let onCompacted = () => {} // (chat id) -> the conversation is read again
|
||||||
let chat = null // the open chat: { id, kind, … }
|
let chat = null // the open chat: { id, kind, … }
|
||||||
let stream = null // the events stream's handle
|
let stream = null // the events stream's handle
|
||||||
let retry = 0 // the reconnect timer
|
let retry = 0 // the reconnect timer
|
||||||
@@ -44,7 +59,12 @@ let readAt = 0 // when `data` was read: the elapsed times count on from there
|
|||||||
let contextAt = 0
|
let contextAt = 0
|
||||||
let reading = null // the read in flight
|
let reading = null // the read in flight
|
||||||
let readAgain = "" // "", "plain" or "context": what to read again once the read in flight is done
|
let readAgain = "" // "", "plain" or "context": what to read again once the read in flight is done
|
||||||
let view = null // null: the panel; { kind, id, name }: one agent's conversation
|
let view = null // null: the panel; { kind, id, name }: one agent's conversation ({ kind: "sub", what } a subagent's)
|
||||||
|
let tree = null // the subagent tree (spec 3.4.0), null for none or an older server
|
||||||
|
let treeRoom = [] // the room as the tree's answer gives it (an agent chat's comes only there)
|
||||||
|
let treeLive = true // false: an agent chat's device is away, and this is the tree the server kept
|
||||||
|
let treeNote = "" // why the last stop did not go through
|
||||||
|
let subTimer = 0 // a live subagent's conversation, read again while it works
|
||||||
const openRows = new Set() // agents unfolded, by kind:id; sections folded, by name
|
const openRows = new Set() // agents unfolded, by kind:id; sections folded, by name
|
||||||
let ticker = 0
|
let ticker = 0
|
||||||
|
|
||||||
@@ -69,12 +89,14 @@ export function compact(value) {
|
|||||||
return `${trim(n / 1000000, 2)}M`
|
return `${trim(n / 1000000, 2)}M`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "3 agents working · 1 queued · 2 finished", or "" for none (the server's `summary_text`). */
|
/**
|
||||||
|
* "3 running · 1 queued · 2 finished", or "" for none: every background task -- a subagent, a
|
||||||
|
* team's member, a command -- counts the same, as the panel lists them together.
|
||||||
|
*/
|
||||||
export function summaryText(counts) {
|
export function summaryText(counts) {
|
||||||
if (!counts) return ""
|
if (!counts) return ""
|
||||||
const parts = []
|
const parts = []
|
||||||
if (counts.working === 1) parts.push(t("1 agent working"))
|
if (counts.working) parts.push(t("%(n)s running", { n: counts.working }))
|
||||||
else if (counts.working) parts.push(t("%(n)s agents working", { n: counts.working }))
|
|
||||||
if (counts.queued) parts.push(t("%(n)s queued", { n: counts.queued }))
|
if (counts.queued) parts.push(t("%(n)s queued", { n: counts.queued }))
|
||||||
if (counts.waiting) parts.push(t("%(n)s waiting", { n: counts.waiting }))
|
if (counts.waiting) parts.push(t("%(n)s waiting", { n: counts.waiting }))
|
||||||
if (counts.finished) parts.push(t("%(n)s finished", { n: counts.finished }))
|
if (counts.finished) parts.push(t("%(n)s finished", { n: counts.finished }))
|
||||||
@@ -139,11 +161,16 @@ export function watchActivity(next) {
|
|||||||
data = null
|
data = null
|
||||||
room = []
|
room = []
|
||||||
cursor = 0
|
cursor = 0
|
||||||
|
tree = null
|
||||||
|
treeRoom = []
|
||||||
|
treeLive = true
|
||||||
|
treeNote = ""
|
||||||
readAt = contextAt = 0
|
readAt = contextAt = 0
|
||||||
view = null
|
view = null
|
||||||
|
compactError = ""
|
||||||
openRows.clear()
|
openRows.clear()
|
||||||
renderStrip()
|
renderStrip()
|
||||||
$("activity-open")?.setAttribute("aria-expanded", String(panelOpen()))
|
markOpen()
|
||||||
if (!chat) {
|
if (!chat) {
|
||||||
if (panelOpen()) showPanel(false)
|
if (panelOpen()) showPanel(false)
|
||||||
return
|
return
|
||||||
@@ -155,6 +182,15 @@ export function watchActivity(next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The reply stream's `tree` frame: the tree as it is now. */
|
||||||
|
export function treeChanged(chatId, next) {
|
||||||
|
if (!chat || chat.id !== chatId) return
|
||||||
|
tree = next || null
|
||||||
|
if (!panelOpen()) return
|
||||||
|
if (!view) render()
|
||||||
|
else if (view.kind === "sub" && view.what === "transcript") readAgent()
|
||||||
|
}
|
||||||
|
|
||||||
/** A reply has ended, or the chat's settings changed: the context and the outputs move with it. */
|
/** A reply has ended, or the chat's settings changed: the context and the outputs move with it. */
|
||||||
export function activityChanged() {
|
export function activityChanged() {
|
||||||
if (chat && panelOpen()) read(true)
|
if (chat && panelOpen()) read(true)
|
||||||
@@ -232,8 +268,16 @@ async function read(withContext) {
|
|||||||
const id = chat.id
|
const id = chat.id
|
||||||
reading = (async () => {
|
reading = (async () => {
|
||||||
try {
|
try {
|
||||||
const a = (await get(`/chats/${enc(id)}/activity?since=${cursor}&context=${withContext ? "true" : "false"}`)).activity
|
const [r, tr] = await Promise.all([
|
||||||
|
get(`/chats/${enc(id)}/activity?since=${cursor}&context=${withContext ? "true" : "false"}`),
|
||||||
|
// A server before 3.4.0 has no tree: 404, and the panel is as it was.
|
||||||
|
get(`/chats/${enc(id)}/tree`).catch(() => null),
|
||||||
|
])
|
||||||
|
const a = r.activity
|
||||||
if (chat?.id !== id || !a) return
|
if (chat?.id !== id || !a) return
|
||||||
|
tree = tr?.tree || null
|
||||||
|
treeRoom = tr?.room || []
|
||||||
|
treeLive = tr ? tr.live !== false : true
|
||||||
if (!withContext) a.context = data?.context ?? null
|
if (!withContext) a.context = data?.context ?? null
|
||||||
else contextAt = Date.now()
|
else contextAt = Date.now()
|
||||||
if (a.team) {
|
if (a.team) {
|
||||||
@@ -269,26 +313,58 @@ async function read(withContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- The strip above the message box ---------------------------------------------------------------
|
// --- The line above the message box, and the header's button ---------------------------------------
|
||||||
|
|
||||||
|
/** Both ways into the panel say whether it is open. */
|
||||||
|
function markOpen() {
|
||||||
|
for (const id of ["activity-open", "activity-strip-btn"]) $(id)?.setAttribute("aria-expanded", String(panelOpen()))
|
||||||
|
}
|
||||||
|
|
||||||
function renderStrip() {
|
function renderStrip() {
|
||||||
const strip = $("activity-strip")
|
const strip = $("activity-strip")
|
||||||
// While something runs, as the server's strip: a chat whose helpers all finished long ago does
|
// Only while something runs, as the server's own line: the header's button is the way into the
|
||||||
// not carry "12 finished" above its message box for ever (the panel still lists them).
|
// panel at any other time (and a chat whose tasks all finished long ago does not carry
|
||||||
const text = chat && summary?.active ? summaryText(summary) : ""
|
// "12 finished" for ever: the panel still lists them).
|
||||||
strip.hidden = !text
|
const active = chat && summary?.active ? summaryText(summary) : ""
|
||||||
if (!text) return clear(strip)
|
strip.hidden = !active
|
||||||
|
if (!active) return clear(strip)
|
||||||
strip.replaceChildren(
|
strip.replaceChildren(
|
||||||
h(
|
h(
|
||||||
"button",
|
"button",
|
||||||
{ type: "button", class: "strip__btn", "aria-label": `${text} — ${t("Open the activity panel")}`, onclick: () => showPanel(true) },
|
{
|
||||||
h("span", { class: "dot is-busy", "aria-hidden": "true" }),
|
type: "button",
|
||||||
h("span", { class: "strip__text", text }),
|
id: "activity-strip-btn",
|
||||||
|
class: "strip__btn is-active",
|
||||||
|
"aria-label": `${active} — ${panelOpen() ? t("Close the activity panel") : t("Open the activity panel")}`,
|
||||||
|
"aria-controls": "activity",
|
||||||
|
"aria-expanded": String(panelOpen()),
|
||||||
|
onclick: togglePanel,
|
||||||
|
},
|
||||||
|
h("span", { class: "dot", "aria-hidden": "true" }),
|
||||||
|
h("span", { class: "strip__text", text: active }),
|
||||||
icon(ICONS.chevron),
|
icon(ICONS.chevron),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The header's button: the panel, open or closed, for every open chat. */
|
||||||
|
export function activityButton() {
|
||||||
|
return h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
class: "tool",
|
||||||
|
id: "activity-open",
|
||||||
|
"aria-label": t("Context and background tasks"),
|
||||||
|
title: t("Context and background tasks"),
|
||||||
|
"aria-controls": "activity",
|
||||||
|
"aria-expanded": String(panelOpen()),
|
||||||
|
onclick: togglePanel,
|
||||||
|
},
|
||||||
|
icon(ICONS.tasks),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- The panel ------------------------------------------------------------------------------------------
|
// --- The panel ------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Open or close the panel (the header's Activity button, the strip, Escape). */
|
/** Open or close the panel (the header's Activity button, the strip, Escape). */
|
||||||
@@ -296,14 +372,16 @@ export function showPanel(on) {
|
|||||||
if (on && !chat) return
|
if (on && !chat) return
|
||||||
$("activity").hidden = !on
|
$("activity").hidden = !on
|
||||||
layout(on)
|
layout(on)
|
||||||
$("activity-open")?.setAttribute("aria-expanded", String(on))
|
markOpen()
|
||||||
clearInterval(ticker)
|
clearInterval(ticker)
|
||||||
ticker = 0
|
ticker = 0
|
||||||
if (!on) {
|
if (!on) {
|
||||||
view = null
|
view = null
|
||||||
|
renderStrip()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ticker = setInterval(tick, 1000)
|
ticker = setInterval(tick, 1000)
|
||||||
|
renderStrip()
|
||||||
render()
|
render()
|
||||||
read(true)
|
read(true)
|
||||||
$("activity-close").focus()
|
$("activity-close").focus()
|
||||||
@@ -320,6 +398,7 @@ function tick() {
|
|||||||
for (const el of document.querySelectorAll("#activity .agent__time[data-active]")) {
|
for (const el of document.querySelectorAll("#activity .agent__time[data-active]")) {
|
||||||
el.textContent = duration(Number(el.dataset.seconds) + extra)
|
el.textContent = duration(Number(el.dataset.seconds) + extra)
|
||||||
}
|
}
|
||||||
|
tickTree($("activity-body"))
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderError(text) {
|
function renderError(text) {
|
||||||
@@ -349,15 +428,45 @@ function render() {
|
|||||||
}
|
}
|
||||||
// Kept where the reader was: the panel is drawn again on every change.
|
// Kept where the reader was: the panel is drawn again on every change.
|
||||||
const scroll = body.scrollTop
|
const scroll = body.scrollTop
|
||||||
body.replaceChildren(
|
// The tree draws the subagents; a team's members are not drawn again among the tasks.
|
||||||
|
const entries = (data.agents || []).filter((e) => !(tree && e.kind === "member"))
|
||||||
|
const parts = [
|
||||||
|
chat?.kind === "agent" && chat.device ? section("environment", t("Environment"), 0, environmentPart(chat)) : null,
|
||||||
section("context", t("Context"), 0, contextPart(data.context)),
|
section("context", t("Context"), 0, contextPart(data.context)),
|
||||||
section("outputs", t("Outputs"), (data.outputs || []).length, outputsPart(data.outputs || [])),
|
section("outputs", t("Outputs"), (data.outputs || []).length, outputsPart(data.outputs || [])),
|
||||||
section("tasks", t("Background tasks"), 0, tasksPart()),
|
tree ? section("subagents", t("Subagents"), 0, subagentsPart()) : null,
|
||||||
|
section("tasks", t("Background tasks"), 0, tasksPart(entries)),
|
||||||
|
data.team || treeRoom.length ? section("room", t("Room"), roomList().length, roomPart()) : null,
|
||||||
data.plan ? section("plan", t("Plan"), 0, h("div", { class: "activity__plan" }, planList(data.plan))) : null,
|
data.plan ? section("plan", t("Plan"), 0, h("div", { class: "activity__plan" }, planList(data.plan))) : null,
|
||||||
)
|
]
|
||||||
|
body.replaceChildren(...parts.filter(Boolean))
|
||||||
body.scrollTop = scroll
|
body.scrollTop = scroll
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many tasks, nested ones included: those still at work, or those finished. */
|
||||||
|
function count(list, active) {
|
||||||
|
let n = 0
|
||||||
|
for (const a of list) {
|
||||||
|
if (ACTIVE.includes(a.state) === active) n += 1
|
||||||
|
n += count(a.children || [], active)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where an agent chat works: its folder, on this computer or another device. */
|
||||||
|
function environmentPart(c) {
|
||||||
|
const dir = c.device?.directory || ""
|
||||||
|
const name = dir.split(/[\\/]/).filter(Boolean).pop() || dir
|
||||||
|
const here = onThisComputer(c)
|
||||||
|
return h(
|
||||||
|
"p",
|
||||||
|
{ class: "env", title: dir },
|
||||||
|
icon(ICONS.folder),
|
||||||
|
h("bdi", { class: "env__name", text: name }),
|
||||||
|
h("span", { class: "env__where", text: here ? t("This computer") : t("Another device") }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function contextPart(c) {
|
function contextPart(c) {
|
||||||
if (!c) return h("p", { class: "activity__empty", text: t("Not measured.") })
|
if (!c) return h("p", { class: "activity__empty", text: t("Not measured.") })
|
||||||
const known = c.percent !== null && c.percent !== undefined
|
const known = c.percent !== null && c.percent !== undefined
|
||||||
@@ -400,7 +509,67 @@ function contextPart(c) {
|
|||||||
{ class: "ctx__note" },
|
{ class: "ctx__note" },
|
||||||
`${c.measured ? t("Counted by the model at the last reply; the categories are estimated.") : t("Estimated: no reply has been counted yet.")} ${t("%(n)s tools offered.", { n: c.tools_count ?? 0 })}`,
|
`${c.measured ? t("Counted by the model at the last reply; the categories are estimated.") : t("Estimated: no reply has been counted yet.")} ${t("%(n)s tools offered.", { n: c.tools_count ?? 0 })}`,
|
||||||
)
|
)
|
||||||
return h("div", { class: "ctx" }, used, bar, rows, note)
|
return h("div", { class: "ctx" }, used, bar, rows, compactRow(c), note)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far it is to the automatic compaction, and the button that compacts now. The server says it
|
||||||
|
* (2.0.0-beta.7: `auto_compact`, `until_compact` in tokens, `compactable` whether Compact can run
|
||||||
|
* now); an older one gives only `threshold` -- the server compacts before a turn once the context
|
||||||
|
* would reach that % of the window, 0 is off -- and it is worked out from that.
|
||||||
|
*/
|
||||||
|
function compactRow(c) {
|
||||||
|
const limit = Number(c.limit) || 0
|
||||||
|
const threshold = Number(c.threshold)
|
||||||
|
const auto = "auto_compact" in c ? !!c.auto_compact : threshold > 0
|
||||||
|
let until = c.until_compact
|
||||||
|
if (until === undefined && auto && limit && threshold < 100) until = Math.floor((limit * threshold) / 100) - (Number(c.used) || 0)
|
||||||
|
let words
|
||||||
|
if (!auto) words = t("Automatic compaction is off.")
|
||||||
|
else if (until === null || until === undefined) words = t("Compacts by itself once the window is known.")
|
||||||
|
else words = until > 0 ? t("%(tokens)s until auto-compact", { tokens: compact(until) }) : t("Compacts before the next reply.")
|
||||||
|
const busy = compacting === chat?.id
|
||||||
|
// `compactable` false: nothing to summarise yet, a reply being written, compaction turned off --
|
||||||
|
// the button stays, greyed; pressing an older server's says why in its own words.
|
||||||
|
const can = !("compactable" in c) || !!c.compactable
|
||||||
|
return h(
|
||||||
|
"div",
|
||||||
|
{ class: "ctx__compact" },
|
||||||
|
h("span", { class: "ctx__until", text: words }),
|
||||||
|
h("button", {
|
||||||
|
type: "button",
|
||||||
|
class: "btn btn--sm",
|
||||||
|
text: busy ? t("Compacting…") : t("Compact"),
|
||||||
|
disabled: busy || !can,
|
||||||
|
title: t("Summarise the earlier turns now and send the summary in their place."),
|
||||||
|
onclick: compactNow,
|
||||||
|
}),
|
||||||
|
compactError ? h("p", { class: "ctx__error", role: "alert", text: compactError }) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let compacting = null // the chat being compacted
|
||||||
|
let compactError = ""
|
||||||
|
|
||||||
|
async function compactNow() {
|
||||||
|
if (!chat || compacting) return
|
||||||
|
const id = chat.id
|
||||||
|
compacting = id
|
||||||
|
compactError = ""
|
||||||
|
render()
|
||||||
|
try {
|
||||||
|
await post(`/chats/${enc(id)}/compact`)
|
||||||
|
compacting = null
|
||||||
|
if (chat?.id !== id) return
|
||||||
|
onCompacted(id)
|
||||||
|
read(true)
|
||||||
|
} catch (err) {
|
||||||
|
compacting = null
|
||||||
|
if (chat?.id !== id) return
|
||||||
|
// A server without the route in its /api/v2 says 404 or 405: said as what it means.
|
||||||
|
compactError = err?.status === 404 || err?.status === 405 ? t("This server cannot compact a chat from the app yet; the web interface can.") : why(err)
|
||||||
|
render()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A category's key as a class name: the server's words, kept to letters and underscores. */
|
/** A category's key as a class name: the server's words, kept to letters and underscores. */
|
||||||
@@ -429,31 +598,110 @@ function outputsPart(outputs) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function tasksPart() {
|
function tasksPart(entries) {
|
||||||
const entries = data.agents || []
|
const team = tree ? null : data.team
|
||||||
const team = data.team
|
|
||||||
const box = h("div", { class: "tasks", "aria-live": "polite" })
|
const box = h("div", { class: "tasks", "aria-live": "polite" })
|
||||||
if (!entries.length && !team) {
|
if (!entries.length && !team) {
|
||||||
box.append(h("p", { class: "activity__empty", text: t("Nothing runs in the background. Helpers, commands and a team's members show here while they work.") }))
|
box.append(h("p", { class: "activity__empty", text: t("Nothing runs in the background. Subagents, a team's members and background commands show here while they work.") }))
|
||||||
return box
|
return box
|
||||||
}
|
}
|
||||||
box.append(h("p", { class: "tasks__summary", text: summaryText(data.summary) || t("Nothing running.") }))
|
|
||||||
if (team) {
|
if (team) {
|
||||||
box.append(h("p", { class: "tasks__team", text: t("Team: %(size)s of %(max)s agents", { size: team.size, max: team.max }) }))
|
box.append(h("p", { class: "tasks__team", text: t("Team: %(size)s of at most %(max)s subagents", { size: team.size, max: team.max }) }))
|
||||||
if (team.goal) box.append(h("p", { class: "tasks__goal", text: team.goal }))
|
if (team.goal) box.append(h("p", { class: "tasks__goal", text: team.goal }))
|
||||||
}
|
}
|
||||||
const live = entries.filter((e) => ACTIVE.includes(e.state))
|
// At the top level, what still runs -- with its own tasks under it, finished or not -- and then
|
||||||
const ended = entries.filter((e) => !ACTIVE.includes(e.state))
|
// what finished with nothing under it still at work, folded.
|
||||||
|
const live = entries.filter((e) => ACTIVE.includes(e.state) || count(e.children || [], true))
|
||||||
|
const ended = entries.filter((e) => !live.includes(e))
|
||||||
|
box.append(h("p", { class: "tasks__group" }, h("span", { text: t("Running") }), h("span", { class: "activity__count", text: String(count(entries, true)) })))
|
||||||
if (live.length) {
|
if (live.length) {
|
||||||
box.append(h("div", { class: "tasks__list" }, live.slice(0, SHOWN).map(agentRows)))
|
box.append(h("div", { class: "tasks__list" }, live.slice(0, SHOWN).map(agentRows)))
|
||||||
if (live.length > SHOWN) box.append(fold("more", t("Show %(n)s more", { n: live.length - SHOWN }), live.slice(SHOWN)))
|
if (live.length > SHOWN) box.append(fold("more", t("Show %(n)s more", { n: live.length - SHOWN }), live.slice(SHOWN)))
|
||||||
|
} else box.append(h("p", { class: "activity__empty", text: t("Nothing running.") }))
|
||||||
|
if (ended.length) box.append(fold("finished", t("Finished %(n)s", { n: count(ended, false) }), ended, "tasks__finished"))
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The room: as the activity read it (a chat's team), else as the tree's answer gave it. */
|
||||||
|
function roomList() {
|
||||||
|
return room.length ? room : treeRoom
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentsPart() {
|
||||||
|
const box = treePart(tree, {
|
||||||
|
room: roomList(),
|
||||||
|
device: chat?.kind === "agent",
|
||||||
|
act: {
|
||||||
|
open: (a) => openSub(a, "transcript"),
|
||||||
|
findings: (a) => openSub(a, "findings"),
|
||||||
|
stop: stopSub,
|
||||||
|
isOpen: (key) => openRows.has(key),
|
||||||
|
toggled: (key, on) => (on ? openRows.add(key) : openRows.delete(key)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!treeLive) box.prepend(h("p", { class: "activity__empty", text: t("This device is not linked right now.") }))
|
||||||
|
if (treeNote) box.prepend(h("p", { class: "activity__empty is-error", text: treeNote }))
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopSub(a) {
|
||||||
|
if (!chat) return
|
||||||
|
const yes = await ask({ title: t("Stop %(name)s", { name: a.name }), text: t("Stop %(name)s and the subagents under it?", { name: a.name }), ok: t("Stop"), cancel: t("Cancel"), danger: true })
|
||||||
|
if (!yes) return
|
||||||
|
treeNote = ""
|
||||||
|
try {
|
||||||
|
await post(`/chats/${enc(chat.id)}/subagents/${enc(a.id)}/stop`)
|
||||||
|
} catch (err) {
|
||||||
|
treeNote = why(err)
|
||||||
}
|
}
|
||||||
if (ended.length) box.append(fold("finished", t("%(n)s finished", { n: ended.length }), ended))
|
read(false)
|
||||||
if (team) {
|
}
|
||||||
box.append(h("h3", { class: "activity__subheading", text: t("Room") }))
|
|
||||||
box.append(
|
function openSub(a, what) {
|
||||||
room.length
|
view = { kind: "sub", id: a.id, name: a.name || a.id, what }
|
||||||
? h(
|
$("activity").dataset.view = "agent"
|
||||||
|
$("activity-title").textContent = what === "findings" ? `${view.name} · ${t("Findings")}` : view.name
|
||||||
|
$("activity-back").hidden = false
|
||||||
|
clear($("activity-body")).append(h("p", { class: "activity__empty", text: t("Loading…") }))
|
||||||
|
readAgent()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A subagent's conversation or findings, read-only; a live one's conversation is read again while it works. */
|
||||||
|
async function readSub() {
|
||||||
|
clearTimeout(subTimer)
|
||||||
|
const { id, what } = view
|
||||||
|
let r
|
||||||
|
try {
|
||||||
|
r = await get(`/chats/${enc(chat.id)}/subagents/${enc(id)}/${what}`)
|
||||||
|
} catch (err) {
|
||||||
|
// 409: an agent chat's device is away -- the server says it is on the device.
|
||||||
|
if (view?.id === id && view.what === what) clear($("activity-body")).append(h("p", { class: "activity__empty is-error", text: why(err) }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (view?.kind !== "sub" || view.id !== id || view.what !== what) return
|
||||||
|
const body = $("activity-body")
|
||||||
|
if (what === "findings") {
|
||||||
|
const list = r.findings || []
|
||||||
|
body.replaceChildren(
|
||||||
|
list.length
|
||||||
|
? h("ol", { class: "findings" }, list.map((f) => h("li", { class: "findings__item", value: String(f.n) }, h("div", { class: "md" }, markdown(String(f.text || ""))))))
|
||||||
|
: h("p", { class: "activity__empty", text: t("Nothing yet.") }),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const atEnd = body.scrollHeight - body.scrollTop - body.clientHeight < 40
|
||||||
|
const live = TREE_LIVE.includes(r.state)
|
||||||
|
const head = h("div", { class: "convo__head" }, h("p", { class: "agent__facts" }, h("span", { class: "agent__state", text: treeState(r.state) })))
|
||||||
|
const turns = turnsOf(r.turns || [])
|
||||||
|
body.replaceChildren(head, h("div", { class: "convo" }, turns.length ? turns : h("p", { class: "activity__empty", text: t("Nothing yet.") })))
|
||||||
|
if (atEnd) body.scrollTop = body.scrollHeight
|
||||||
|
if (live) subTimer = setTimeout(() => view?.kind === "sub" && view.id === id && panelOpen() && readAgent(), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomPart() {
|
||||||
|
const room = roomList()
|
||||||
|
if (!room.length) return h("p", { class: "activity__empty", text: t("Nothing has been said in the room yet.") })
|
||||||
|
return h(
|
||||||
"ol",
|
"ol",
|
||||||
{ class: "room" },
|
{ class: "room" },
|
||||||
room.map((m) =>
|
room.map((m) =>
|
||||||
@@ -471,22 +719,27 @@ function tasksPart() {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: h("p", { class: "activity__empty", text: t("Nothing has been said in the room yet.") }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return box
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A folded list of agents ("Show 3 more", "12 finished"), remembered open across redraws. */
|
/** A folded list of tasks ("Show 3 more", "Finished 12", "Tasks: 2"), remembered across redraws. */
|
||||||
function fold(name, label, list) {
|
function fold(name, label, list, cls = "") {
|
||||||
const el = h("details", { class: "tasks__more", open: openRows.has(`fold:${name}`) }, h("summary", { text: label }), h("div", { class: "tasks__list" }, list.map(agentRows)))
|
const el = h(
|
||||||
|
"details",
|
||||||
|
{ class: `tasks__more ${cls}`, open: openRows.has(`fold:${name}`) },
|
||||||
|
h("summary", null, h("span", { class: "tasks__chev", "aria-hidden": "true" }, icon(ICONS.chevron)), h("span", { text: label })),
|
||||||
|
h("div", { class: "tasks__list" }, list.map(agentRows)),
|
||||||
|
)
|
||||||
el.addEventListener("toggle", () => (el.open ? openRows.add(`fold:${name}`) : openRows.delete(`fold:${name}`)))
|
el.addEventListener("toggle", () => (el.open ? openRows.add(`fold:${name}`) : openRows.delete(`fold:${name}`)))
|
||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An agent and, under it, the agents it leads or started. */
|
/** A task and, folded under it, the tasks it leads or started ("Tasks: 3"). */
|
||||||
function agentRows(a) {
|
function agentRows(a) {
|
||||||
return [agentRow(a), ...(a.children || []).map(agentRows)]
|
const kids = a.children || []
|
||||||
|
if (!kids.length) return agentRow(a)
|
||||||
|
const nested = fold(`under:${a.kind}:${a.id}`, t("Tasks: %(n)s", { n: kids.length }), kids, "tasks__nested")
|
||||||
|
nested.style.setProperty("--depth", String(Math.max(0, Math.min(6, (Number(a.depth) || 0) + 1))))
|
||||||
|
return [agentRow(a), nested]
|
||||||
}
|
}
|
||||||
|
|
||||||
function agentRow(a) {
|
function agentRow(a) {
|
||||||
@@ -502,7 +755,7 @@ function agentRow(a) {
|
|||||||
"button",
|
"button",
|
||||||
{
|
{
|
||||||
type: "button",
|
type: "button",
|
||||||
class: "pane__btn agent__stop",
|
class: "agent__stop",
|
||||||
"aria-label": t("Stop %(name)s", { name: a.name }),
|
"aria-label": t("Stop %(name)s", { name: a.name }),
|
||||||
title: t("Stop %(name)s", { name: a.name }),
|
title: t("Stop %(name)s", { name: a.name }),
|
||||||
onclick: (e) => {
|
onclick: (e) => {
|
||||||
@@ -511,13 +764,15 @@ function agentRow(a) {
|
|||||||
stopAgent(a)
|
stopAgent(a)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
icon(ICONS.stop),
|
h("span", { class: "agent__square", "aria-hidden": "true" }),
|
||||||
)
|
)
|
||||||
: h("span", { class: "agent__stop", "aria-hidden": "true" })
|
: h("span", { class: "agent__stop is-none", "aria-hidden": "true" })
|
||||||
const facts = h(
|
const facts = h(
|
||||||
"p",
|
"p",
|
||||||
{ class: "agent__facts" },
|
{ class: "agent__facts" },
|
||||||
h("span", { class: "agent__state", text: stateWord(a.state) }),
|
h("span", { class: "agent__state", text: stateWord(a.state) }),
|
||||||
|
h("span", { text: kindWord(a) }),
|
||||||
|
a.kind !== "member" && a.role ? h("code", { class: "agent__id", text: a.name || a.id }) : null,
|
||||||
a.agent ? h("span", { text: a.agent }) : null,
|
a.agent ? h("span", { text: a.agent }) : null,
|
||||||
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
|
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
|
||||||
a.steps ? h("span", { text: t("%(steps)s steps, %(tools)s tools", { steps: a.steps, tools: a.tools }) }) : null,
|
a.steps ? h("span", { text: t("%(steps)s steps, %(tools)s tools", { steps: a.steps, tools: a.tools }) }) : null,
|
||||||
@@ -537,8 +792,8 @@ function agentRow(a) {
|
|||||||
h(
|
h(
|
||||||
"summary",
|
"summary",
|
||||||
{ class: "agent__row" },
|
{ class: "agent__row" },
|
||||||
h("span", { class: "agent__dot", "aria-hidden": "true" }),
|
h("span", { class: "agent__chev", "aria-hidden": "true" }, icon(ICONS.chevron)),
|
||||||
h("span", { class: "agent__what" }, h("span", { class: "agent__name", text: a.name || a.id }), a.role ? h("span", { class: "agent__role", text: a.role }) : null),
|
h("span", { class: "agent__what", title: label(a) }, h("bdi", { class: "agent__name", text: label(a) })),
|
||||||
time,
|
time,
|
||||||
stopBtn,
|
stopBtn,
|
||||||
),
|
),
|
||||||
@@ -546,12 +801,11 @@ function agentRow(a) {
|
|||||||
"div",
|
"div",
|
||||||
{ class: "agent__body" },
|
{ class: "agent__body" },
|
||||||
facts,
|
facts,
|
||||||
|
a.kind === "member" && a.role ? h("p", { class: "agent__role", text: a.role }) : null,
|
||||||
a.brief ? h("p", { class: "agent__brief", text: a.brief }) : null,
|
a.brief ? h("p", { class: "agent__brief", text: a.brief }) : null,
|
||||||
step,
|
step,
|
||||||
a.text ? h("p", { class: "agent__text", text: a.text }) : null,
|
a.text ? h("p", { class: "agent__text", text: a.text }) : null,
|
||||||
a.transcript
|
a.transcript ? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => openAgent(a) }) : null,
|
||||||
? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => openAgent(a) })
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
el.style.setProperty("--depth", String(Math.max(0, Math.min(6, Number(a.depth) || 0))))
|
el.style.setProperty("--depth", String(Math.max(0, Math.min(6, Number(a.depth) || 0))))
|
||||||
@@ -559,6 +813,22 @@ function agentRow(a) {
|
|||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A task's line: a team's member by its name (it is how the room and the others call it); a
|
||||||
|
* subagent or a command by what it was given to do, its id only where there is nothing else.
|
||||||
|
*/
|
||||||
|
function label(a) {
|
||||||
|
if (a.kind === "member") return a.name || a.id
|
||||||
|
return a.role || a.brief || a.name || a.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a task is: a team's member, a subagent, or a background command. */
|
||||||
|
function kindWord(a) {
|
||||||
|
if (a.kind === "member") return t("Team member")
|
||||||
|
if (a.type === "command") return t("Background command")
|
||||||
|
return t("Subagent")
|
||||||
|
}
|
||||||
|
|
||||||
async function stopAgent(a) {
|
async function stopAgent(a) {
|
||||||
if (!chat) return
|
if (!chat) return
|
||||||
if (a.kind === "member") {
|
if (a.kind === "member") {
|
||||||
@@ -592,6 +862,7 @@ function openAgent(a) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeAgent() {
|
function closeAgent() {
|
||||||
|
clearTimeout(subTimer)
|
||||||
view = null
|
view = null
|
||||||
render()
|
render()
|
||||||
$("activity-close").focus()
|
$("activity-close").focus()
|
||||||
@@ -599,6 +870,7 @@ function closeAgent() {
|
|||||||
|
|
||||||
async function readAgent() {
|
async function readAgent() {
|
||||||
if (!chat || !view) return
|
if (!chat || !view) return
|
||||||
|
if (view.kind === "sub") return readSub()
|
||||||
const { kind, id } = view
|
const { kind, id } = view
|
||||||
let r
|
let r
|
||||||
try {
|
try {
|
||||||
@@ -624,7 +896,14 @@ async function readAgent() {
|
|||||||
a.role ? h("p", { class: "agent__role", text: a.role }) : null,
|
a.role ? h("p", { class: "agent__role", text: a.role }) : null,
|
||||||
a.stoppable ? h("button", { type: "button", class: "btn btn--sm", text: t("Stop %(name)s", { name: a.name || id }), onclick: () => stopAgent(a) }) : null,
|
a.stoppable ? h("button", { type: "button", class: "btn btn--sm", text: t("Stop %(name)s", { name: a.name || id }), onclick: () => stopAgent(a) }) : null,
|
||||||
)
|
)
|
||||||
const turns = (r.transcript || []).map((turn) => {
|
const turns = turnsOf(r.transcript || [])
|
||||||
|
body.replaceChildren(head, h("div", { class: "convo" }, turns.length ? turns : h("p", { class: "activity__empty", text: t("Nothing yet.") })))
|
||||||
|
if (atEnd) body.scrollTop = body.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A conversation's turns, read-only: the person's (or the harness's) messages and the replies. */
|
||||||
|
function turnsOf(list) {
|
||||||
|
return list.map((turn) => {
|
||||||
if (turn.role === "user") {
|
if (turn.role === "user") {
|
||||||
return h("article", { class: `msg msg--user convo__turn${turn.machine ? " is-machine" : ""}` }, h("div", { class: "msg__text", text: String(turn.text || "") }))
|
return h("article", { class: `msg msg--user convo__turn${turn.machine ? " is-machine" : ""}` }, h("div", { class: "msg__text", text: String(turn.text || "") }))
|
||||||
}
|
}
|
||||||
@@ -651,14 +930,14 @@ async function readAgent() {
|
|||||||
turn.running ? h("p", { class: "msg__status muted", text: t("Writing…") }) : null,
|
turn.running ? h("p", { class: "msg__status muted", text: t("Writing…") }) : null,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
body.replaceChildren(head, h("div", { class: "convo" }, turns.length ? turns : h("p", { class: "activity__empty", text: t("Nothing yet.") })))
|
|
||||||
if (atEnd) body.scrollTop = body.scrollHeight
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Start ------------------------------------------------------------------------------------------
|
// --- Start ------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
export function initActivity({ onLayout }) {
|
export function initActivity({ onLayout, here, compacted }) {
|
||||||
layout = onLayout || layout
|
layout = onLayout || layout
|
||||||
|
onThisComputer = here || onThisComputer
|
||||||
|
onCompacted = compacted || onCompacted
|
||||||
$("activity-close").replaceChildren(icon(ICONS.close))
|
$("activity-close").replaceChildren(icon(ICONS.close))
|
||||||
$("activity-close").addEventListener("click", () => showPanel(false))
|
$("activity-close").addEventListener("click", () => showPanel(false))
|
||||||
$("activity-back").replaceChildren(icon(ICONS.back))
|
$("activity-back").replaceChildren(icon(ICONS.back))
|
||||||
|
|||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
// The agent on this computer as the person sees it: one sentence for its state, and the one thing
|
||||||
|
// to do about it -- link this computer, link it again, add a folder, start it. The same words in
|
||||||
|
// three places: the title bar's chip (its popover), above the composer of a new agent chat, and
|
||||||
|
// Settings. The state is the CLI's word for its link (src-tauri/src/agent.rs); "refused" is the
|
||||||
|
// server's word only, with the server's reason.
|
||||||
|
|
||||||
|
import { h, icon, ICONS } from "./dom.js"
|
||||||
|
import { invoke, listen, why } from "./bridge.js"
|
||||||
|
|
||||||
|
let linking = null // { user_code, uri } while a link waits for its approval
|
||||||
|
let linkError = ""
|
||||||
|
const watchers = new Set()
|
||||||
|
|
||||||
|
/** Linked and allowed into a folder: what an agent chat needs. */
|
||||||
|
export function agentReady(s) {
|
||||||
|
const a = s?.agent || {}
|
||||||
|
const linked = a.state === "running" || (a.state === "elsewhere" && a.detail?.linked)
|
||||||
|
return linked && !!(s.remote?.enabled && s.remote.roots?.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The agent's state for the page: `tone` (ok, busy, warn, off), the sentence, and `action`: what
|
||||||
|
* the one button does ("link", "relink", "folder", "start" or null).
|
||||||
|
*/
|
||||||
|
export function agentState(s) {
|
||||||
|
const a = s?.agent || { state: "off" }
|
||||||
|
const why = typeof a.detail === "string" ? a.detail : ""
|
||||||
|
const noFolders = s?.remote && !(s.remote.enabled && s.remote.roots?.length)
|
||||||
|
switch (a.state) {
|
||||||
|
case "not_linked":
|
||||||
|
return { tone: "warn", action: "link", sentence: t("This computer is not linked yet."), more: t("Link it to your server, and agent chats can work in its folders.") }
|
||||||
|
case "refused":
|
||||||
|
return { tone: "warn", action: "relink", sentence: t("The server refused this computer's link: %(why)s", { why: why || "…" }), more: t("Link it again to give the agent a new token.") }
|
||||||
|
case "starting":
|
||||||
|
return { tone: "busy", action: null, sentence: t("The agent is starting…") }
|
||||||
|
case "connecting":
|
||||||
|
return { tone: "busy", action: null, sentence: t("The agent is connecting to the server…") }
|
||||||
|
case "waiting":
|
||||||
|
return { tone: "warn", action: null, sentence: t("The agent is not connected: %(why)s. It keeps trying.", { why: why || "…" }) }
|
||||||
|
case "failed":
|
||||||
|
return { tone: "warn", action: s.linked ? "start" : "link", sentence: t("The agent stopped: %(why)s. Trying again shortly.", { why: why || "…" }) }
|
||||||
|
case "elsewhere": {
|
||||||
|
const pid = a.detail?.pid
|
||||||
|
if (!a.detail?.linked) return { tone: "warn", action: null, sentence: t("A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s", { pid, why: a.detail?.why || "…" }) }
|
||||||
|
if (noFolders) return { tone: "warn", action: "folder", sentence: t("The agent is connected, but may open no folder yet.") }
|
||||||
|
return { tone: "ok", action: null, sentence: t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }) }
|
||||||
|
}
|
||||||
|
case "running":
|
||||||
|
if (noFolders) return { tone: "warn", action: "folder", sentence: t("The agent is connected, but may open no folder yet.") }
|
||||||
|
return { tone: "ok", action: null, sentence: t("The agent is running: agent chats can use this computer.") }
|
||||||
|
default:
|
||||||
|
return { tone: "off", action: s?.linked ? "start" : "link", sentence: s?.linked ? t("The agent is off.") : t("This computer is not linked yet.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start linking this computer: the approval opens in the browser; the page hears `linking`. */
|
||||||
|
export function link() {
|
||||||
|
linkError = ""
|
||||||
|
changed()
|
||||||
|
return invoke("link_computer").catch((err) => {
|
||||||
|
linkError = why(err)
|
||||||
|
changed()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABELS = {
|
||||||
|
link: () => t("Link this computer"),
|
||||||
|
relink: () => t("Link again"),
|
||||||
|
folder: () => t("Add folder…"),
|
||||||
|
start: () => t("Start the agent"),
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(action) {
|
||||||
|
if (action === "link" || action === "relink") return link()
|
||||||
|
if (action === "folder") return invoke("remote_add")
|
||||||
|
if (action === "start") return invoke("set_agent", { on: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The agent's state as a block: its sentence, a line more where there is one, and its button;
|
||||||
|
* while a link waits for approval, the code to compare and Cancel instead. `compact` leaves the
|
||||||
|
* second line out.
|
||||||
|
*/
|
||||||
|
export function agentBlock(s, { compact = false } = {}) {
|
||||||
|
const st = agentState(s)
|
||||||
|
const el = h("div", { class: `agentbox is-${st.tone}`, role: "status", "aria-live": "polite" })
|
||||||
|
el.append(h("span", { class: "agentbox__dot", "aria-hidden": "true" }), h("p", { class: "agentbox__text", text: st.sentence }))
|
||||||
|
if (!compact && st.more) el.append(h("p", { class: "agentbox__more", text: st.more }))
|
||||||
|
if (linking) {
|
||||||
|
el.append(
|
||||||
|
h("p", { class: "agentbox__more", text: t("Approve this code in the page that opened in your browser:") }),
|
||||||
|
h("p", { class: "code agentbox__code", text: linking.user_code }),
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "agentbox__actions" },
|
||||||
|
h("button", { type: "button", class: "btn btn--sm", text: t("Open the page again"), onclick: () => invoke("open_external", { url: linking.uri }).catch(() => {}) }),
|
||||||
|
h("button", { type: "button", class: "btn btn--sm", text: t("Cancel"), onclick: () => invoke("link_cancel") }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else if (st.action) {
|
||||||
|
el.append(
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "agentbox__actions" },
|
||||||
|
h("button", { type: "button", class: `btn btn--sm ${st.tone === "warn" || st.tone === "off" ? "btn--primary" : ""}`, onclick: () => run(st.action) }, st.action === "link" || st.action === "relink" ? icon(ICONS.link) : null, LABELS[st.action]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (linkError) el.append(h("p", { class: "agentbox__error", text: linkError }))
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Something that shows `agentBlock` and draws it again when linking moves on. */
|
||||||
|
export function onAgentChange(fn) {
|
||||||
|
watchers.add(fn)
|
||||||
|
return () => watchers.delete(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
function changed() {
|
||||||
|
for (const fn of watchers) fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linkingNow() {
|
||||||
|
return linking
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initAgent() {
|
||||||
|
listen("linking", (e) => {
|
||||||
|
const p = e.payload
|
||||||
|
linking = p.state === "code" ? { user_code: p.user_code, uri: p.uri } : null
|
||||||
|
linkError = p.state === "failed" ? p.message : ""
|
||||||
|
changed()
|
||||||
|
})
|
||||||
|
}
|
||||||
+37
-8
@@ -6,8 +6,9 @@
|
|||||||
|
|
||||||
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
||||||
import { invoke, listen, del, why } from "./bridge.js"
|
import { invoke, listen, del, why } from "./bridge.js"
|
||||||
|
import { picture } from "./models.js"
|
||||||
|
|
||||||
let pending = [] // { key, name, size, state: "sending" | "ready" | "failed", id?, error? }
|
let pending = [] // { key, name, size, state: "sending" | "ready" | "failed", id?, error?, thumb? }
|
||||||
let seq = 0
|
let seq = 0
|
||||||
let ctx = null // { chat(): the open chat's id, or "" for one not made yet; allowed(): bool }
|
let ctx = null // { chat(): the open chat's id, or "" for one not made yet; allowed(): bool }
|
||||||
|
|
||||||
@@ -45,15 +46,21 @@ function remove(p) {
|
|||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One file as the composer shows it: the same three tracks for every row. */
|
/**
|
||||||
|
* One file as the composer shows it: a tile -- a picture's thumbnail (a pasted one from its own
|
||||||
|
* bytes, a sent one through the bridge), or the file's icon -- its name, its size or state, and
|
||||||
|
* a button to take it off. The same tracks on every tile.
|
||||||
|
*/
|
||||||
function row(p) {
|
function row(p) {
|
||||||
const status =
|
const status =
|
||||||
p.state === "sending" ? t("Sending…") : p.state === "failed" ? p.error || "" : p.size ? bytes(p.size) : ""
|
p.state === "sending" ? t("Sending…") : p.state === "failed" ? p.error || "" : p.size ? bytes(p.size) : ""
|
||||||
return h(
|
const tile = h(
|
||||||
"li",
|
"li",
|
||||||
{ class: `file is-${p.state}`, title: p.state === "failed" ? `${p.name}: ${p.error || ""}` : p.name },
|
{ class: `tile file is-${p.state}${p.thumb ? " has-thumb" : ""}`, title: p.state === "failed" ? `${p.name}: ${p.error || ""}` : p.name },
|
||||||
h("span", { class: "file__name" }, h("bdi", { text: p.name })),
|
p.thumb ? h("img", { class: "tile__thumb", src: p.thumb, alt: "", draggable: "false" }) : null,
|
||||||
h("span", { class: "file__state", text: status }),
|
h("span", { class: "tile__icon" }, icon(ICONS.file)),
|
||||||
|
h("span", { class: "tile__name" }, h("bdi", { text: p.name })),
|
||||||
|
h("span", { class: "tile__size", text: status }),
|
||||||
h(
|
h(
|
||||||
"button",
|
"button",
|
||||||
{
|
{
|
||||||
@@ -67,10 +74,12 @@ function row(p) {
|
|||||||
icon(ICONS.close),
|
icon(ICONS.close),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
return tile
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const list = clear($("composer-files"))
|
const list = clear($("composer-files"))
|
||||||
|
list.className = "tiles composer__files"
|
||||||
list.hidden = !pending.length
|
list.hidden = !pending.length
|
||||||
for (const p of pending) list.append(row(p))
|
for (const p of pending) list.append(row(p))
|
||||||
}
|
}
|
||||||
@@ -90,11 +99,31 @@ async function track(names, call) {
|
|||||||
for (const r of results || []) {
|
for (const r of results || []) {
|
||||||
pending.push(
|
pending.push(
|
||||||
r.attachment
|
r.attachment
|
||||||
? { key: ++seq, name: r.attachment.name || r.name, size: r.attachment.size || r.size, state: "ready", id: r.attachment.id }
|
? { key: ++seq, name: r.attachment.name || r.name, size: r.attachment.size || r.size, state: "ready", id: r.attachment.id, image: !!r.attachment.image, thumb: r.thumb }
|
||||||
: { key: ++seq, name: r.name, size: r.size, state: "failed", error: (REASONS[r.reason] || (() => r.error))() },
|
: { key: ++seq, name: r.name, size: r.size, state: "failed", error: (REASONS[r.reason] || (() => r.error))() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
render()
|
render()
|
||||||
|
// A sent picture with no thumbnail of its own yet: the server's copy, through the bridge.
|
||||||
|
for (const p of pending) {
|
||||||
|
if (p.state !== "ready" || !p.image || p.thumb) continue
|
||||||
|
picture(`/attachments/${p.id}`).then((url) => {
|
||||||
|
if (!url || !url.startsWith("data:image/") || !pending.includes(p)) return
|
||||||
|
p.thumb = url
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pasted picture's own bytes as a `data:` URL: the page's policy allows images from there. */
|
||||||
|
function dataUrl(file) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) return resolve(undefined)
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : undefined)
|
||||||
|
reader.onerror = () => resolve(undefined)
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Choose files in the system's dialog. */
|
/** Choose files in the system's dialog. */
|
||||||
@@ -118,7 +147,7 @@ async function paste(e) {
|
|||||||
track([name], async () => {
|
track([name], async () => {
|
||||||
const data = new Uint8Array(await f.arrayBuffer())
|
const data = new Uint8Array(await f.arrayBuffer())
|
||||||
const attachment = await invoke("attach_bytes", data, { headers: { "x-name": encodeURIComponent(name), "x-chat": ctx.chat() || "" } })
|
const attachment = await invoke("attach_bytes", data, { headers: { "x-name": encodeURIComponent(name), "x-chat": ctx.chat() || "" } })
|
||||||
return [{ name, size: f.size, attachment }]
|
return [{ name, size: f.size, attachment, thumb: await dataUrl(f) }]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,61 @@ export async function follow(path, { onFrame, onEnd }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- The terminal panel's socket -----------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Opened by Rust (`terminal_open`), which holds the token and the socket; the page gets the frames
|
||||||
|
// as `terminal` events -- `output` (the shell's bytes, base64), `control` (the server's JSON), a last
|
||||||
|
// `end` -- and sends keys, sizes and "close" through commands. As with a followed reply, a frame
|
||||||
|
// can come before `terminal_open` has answered with its id: those wait.
|
||||||
|
|
||||||
|
const terminals = new Map()
|
||||||
|
const earlyTerminal = new Map()
|
||||||
|
listen("terminal", (e) => {
|
||||||
|
const { id, kind, data } = e.payload
|
||||||
|
const f = terminals.get(id)
|
||||||
|
if (f) deliverTerminal(f, id, kind, data)
|
||||||
|
else {
|
||||||
|
if (!earlyTerminal.has(id)) earlyTerminal.set(id, [])
|
||||||
|
earlyTerminal.get(id).push([kind, data])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function deliverTerminal(f, id, kind, data) {
|
||||||
|
if (kind === "end") {
|
||||||
|
terminals.delete(id)
|
||||||
|
f.onEnd(data || {})
|
||||||
|
} else if (kind === "output") f.onOutput(data)
|
||||||
|
else if (kind === "control") f.onControl(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a shell: `target` is `{ chat }` or `{ device, directory }`; `onOutput(base64)`,
|
||||||
|
* `onControl(frame)`, and `onEnd({ code, reason, status, error })` once. The handle sends keys
|
||||||
|
* (`input`), a size (`resize`), and lets go (`close`; onEnd still comes).
|
||||||
|
*/
|
||||||
|
export async function openTerminal(target, cols, rows, { onOutput, onControl, onEnd }) {
|
||||||
|
const id = await invoke("terminal_open", {
|
||||||
|
chat: target.chat ?? null,
|
||||||
|
device: target.device ?? null,
|
||||||
|
directory: target.directory ?? null,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
})
|
||||||
|
const f = { onOutput, onControl, onEnd }
|
||||||
|
terminals.set(id, f)
|
||||||
|
for (const [kind, data] of earlyTerminal.get(id) || []) {
|
||||||
|
if (!terminals.has(id)) break
|
||||||
|
deliverTerminal(f, id, kind, data)
|
||||||
|
}
|
||||||
|
earlyTerminal.delete(id)
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
input: (data) => invoke("terminal_input", { id, data }),
|
||||||
|
resize: (c, r) => invoke("terminal_resize", { id, cols: c, rows: r }),
|
||||||
|
close: () => invoke("terminal_close", { id }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A web link, in the system's browser. */
|
/** A web link, in the system's browser. */
|
||||||
export function openLink(url) {
|
export function openLink(url) {
|
||||||
return invoke("open_external", { url }).catch(() => {})
|
return invoke("open_external", { url }).catch(() => {})
|
||||||
|
|||||||
+533
-213
@@ -1,6 +1,7 @@
|
|||||||
// One chat: its transcript, the reply being written (followed as the server streams it), the
|
// One chat: its transcript, the reply being written (followed as the server streams it), the
|
||||||
// cards that wait for an answer, and the composer. And the form a new chat starts from -- an
|
// cards that wait for an answer, and the composer with the chat's settings as chips under the
|
||||||
// agent chat on this computer, in one of the agent's folders, or a chat on the server.
|
// text (folder, mode, model, effort, agents). And a new chat, which is the same page empty: a
|
||||||
|
// greeting, the choice of an agent chat on this computer or a chat on the server, and the composer.
|
||||||
//
|
//
|
||||||
// The transcript is the server's /api/v2 serialisation (tests/fixtures/api_v2 in the server's
|
// The transcript is the server's /api/v2 serialisation (tests/fixtures/api_v2 in the server's
|
||||||
// repository is the contract): a person's message is their text as typed; a reply is its steps --
|
// repository is the contract): a person's message is their text as typed; a reply is its steps --
|
||||||
@@ -11,14 +12,18 @@ import { api, get, getText, post, patch, del, follow, why, invoke, openLink } fr
|
|||||||
import { markdown } from "./markdown.js"
|
import { markdown } from "./markdown.js"
|
||||||
import { openViewer, showKind } from "./viewer.js"
|
import { openViewer, showKind } from "./viewer.js"
|
||||||
import { readLocal } from "./explorer.js"
|
import { readLocal } from "./explorer.js"
|
||||||
import { menu, ask, showLine } from "./popup.js"
|
import { menu, ask, showLine, popover, closePopover } from "./popup.js"
|
||||||
import { avatar, modelPicker, effortControl, agentsControl, teamPossible, levelsOf } from "./models.js"
|
import { avatar, agentsControl, teamPossible, namedOnly, subagentsCap, noteSubagentsCap, SUBAGENTS_HINT, SUBAGENTS_OWN, levelsOf, levelWord, picture } from "./models.js"
|
||||||
import { readyIds, sending, sent, discard } from "./attach.js"
|
import { readyIds, sending, sent, discard } from "./attach.js"
|
||||||
import { watchActivity, activityChanged, togglePanel, panelOpen, planList } from "./activity.js"
|
import { watchActivity, activityChanged, planList, activityButton, treeChanged } from "./activity.js"
|
||||||
|
import { agentBlock, agentReady, agentState, onAgentChange } from "./agent.js"
|
||||||
|
import { folderName } from "./list.js"
|
||||||
|
import { isProgress, progressLine, progressTicks } from "./progress.js"
|
||||||
|
import { aimTerminal, terminalButton, terminalOpen, toggleTerminal } from "./terminal.js"
|
||||||
|
|
||||||
const MODES = ["manual", "edit", "auto", "plan"]
|
const MODES = ["manual", "edit", "auto", "plan"]
|
||||||
|
|
||||||
let ctx = null // set by initChat: { status(), thisDevices(), models(), me(), changed(chat), opened(chat), gone(chat) }
|
let ctx = null // set by initChat: { status(), thisDevices(), devices(), models(), me(), changed(chat), opened(chat), gone(chat) }
|
||||||
let open = null // { chat, live, more, first }
|
let open = null // { chat, live, more, first }
|
||||||
|
|
||||||
export function openChatId() {
|
export function openChatId() {
|
||||||
@@ -27,10 +32,80 @@ export function openChatId() {
|
|||||||
|
|
||||||
// --- Reading -----------------------------------------------------------------------------------
|
// --- Reading -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
function onThisComputer(chat) {
|
export function onThisComputer(chat) {
|
||||||
return !!chat?.device && ctx.thisDevices().includes(chat.device.token_id)
|
return !!chat?.device && ctx.thisDevices().includes(chat.device.token_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A linked device, as `GET /devices` lists it (with `terminal`: whether it opens one). */
|
||||||
|
function deviceOf(id) {
|
||||||
|
return (ctx.devices?.() || []).find((d) => d.id === id) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The device a new agent chat goes to: this computer's, preferring the one linked right now. */
|
||||||
|
function draftDevice() {
|
||||||
|
const mine = ctx.thisDevices()
|
||||||
|
return mine.find((id) => deviceOf(id)) || mine[0] || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the Terminal panel is for, as the page stands: a new agent chat whose device and folder
|
||||||
|
* are chosen (the shell before the first message), an open agent chat on a device that opens
|
||||||
|
* terminals, or nothing.
|
||||||
|
*/
|
||||||
|
function terminalTarget() {
|
||||||
|
if (draft) {
|
||||||
|
if (draft.kind !== "agent" || !draft.dir) return null
|
||||||
|
const device = deviceOf(draftDevice())
|
||||||
|
if (!device?.terminal) return null
|
||||||
|
return { device: device.id, directory: draft.dir, name: device.name || t("This computer") }
|
||||||
|
}
|
||||||
|
const chat = open?.chat
|
||||||
|
if (chat?.kind !== "agent" || !chat.device) return null
|
||||||
|
const device = deviceOf(chat.device.token_id)
|
||||||
|
if (!device?.terminal) return null
|
||||||
|
return { chat: chat.id, device: device.id, directory: chat.device.directory, name: device.name || t("Terminal") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new agent chat's subagent facts (spec 3.4.0 §14): `/models?kind=agent&device=` -- each model's
|
||||||
|
// `subagents` for an agent chat, and `agent_subagents { offered, device_runs }` once. Read per
|
||||||
|
// device, again when a new chat starts; a server before 3.4.0 says nothing (no setting, as before).
|
||||||
|
const agentFacts = new Map() // device -> { at, models: Map(id -> subagents), offered, runs } | null
|
||||||
|
|
||||||
|
async function readAgentFacts(device) {
|
||||||
|
if (!device) return null
|
||||||
|
const known = agentFacts.get(device)
|
||||||
|
if (known && Date.now() - known.at < 5000) return known
|
||||||
|
try {
|
||||||
|
const r = await get(`/models?kind=agent&device=${encodeURIComponent(device)}`)
|
||||||
|
if (!r.agent_subagents) {
|
||||||
|
agentFacts.set(device, { at: Date.now(), none: true })
|
||||||
|
return agentFacts.get(device)
|
||||||
|
}
|
||||||
|
noteSubagentsCap(r.subagents_cap)
|
||||||
|
const facts = { at: Date.now(), models: new Map((r.models || []).map((m) => [m.id, m.subagents])), offered: !!r.agent_subagents.offered, runs: r.agent_subagents.device_runs }
|
||||||
|
agentFacts.set(device, facts)
|
||||||
|
return facts
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a new agent chat on `model` is offered subagents: `{ cap, own }`, or null. */
|
||||||
|
function agentOffer(facts, model) {
|
||||||
|
if (!facts || facts.none || !facts.offered || facts.runs === false) return null
|
||||||
|
const said = facts.models.get(model?.id)
|
||||||
|
if (said !== undefined && said !== null && !(typeof said === "object" ? said.offered : said)) return null
|
||||||
|
return { cap: subagentsCap(), own: namedOnly(said) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text into the message box, after what is there, for the person to read before sending. */
|
||||||
|
export function intoComposer(text) {
|
||||||
|
const box = $("composer-text")
|
||||||
|
box.value = box.value.trim() ? `${box.value.replace(/\s*$/, "")}\n\n${text}` : text
|
||||||
|
box.dispatchEvent(new Event("input"))
|
||||||
|
box.focus()
|
||||||
|
}
|
||||||
|
|
||||||
function nearBottom() {
|
function nearBottom() {
|
||||||
const th = $("thread")
|
const th = $("thread")
|
||||||
return th.scrollHeight - th.scrollTop - th.clientHeight < 80
|
return th.scrollHeight - th.scrollTop - th.clientHeight < 80
|
||||||
@@ -50,18 +125,34 @@ function copyText(text) {
|
|||||||
navigator.clipboard?.writeText(text).catch(() => {})
|
navigator.clipboard?.writeText(text).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file a message carries, as a tile: a picture as its thumbnail (read through the bridge, as
|
||||||
|
* a `data:` URL), anything else as its icon, name and size.
|
||||||
|
*/
|
||||||
|
export function fileTile(a) {
|
||||||
|
const tile = h(
|
||||||
|
"li",
|
||||||
|
{ class: `tile${a.image ? " tile--image" : ""}`, title: a.name },
|
||||||
|
h("span", { class: "tile__icon" }, icon(ICONS.file)),
|
||||||
|
h("span", { class: "tile__name" }, h("bdi", { text: a.name })),
|
||||||
|
h("span", { class: "tile__size", text: a.size ? bytes(a.size) : "" }),
|
||||||
|
)
|
||||||
|
if (a.image && a.id) {
|
||||||
|
picture(`/attachments/${a.id}`).then((url) => {
|
||||||
|
if (!url || !url.startsWith("data:image/")) return
|
||||||
|
tile.classList.add("has-thumb")
|
||||||
|
tile.prepend(h("img", { class: "tile__thumb", src: url, alt: "", draggable: "false" }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tile
|
||||||
|
}
|
||||||
|
|
||||||
function userMessage(m) {
|
function userMessage(m) {
|
||||||
const el = h(
|
const el = h(
|
||||||
"article",
|
"article",
|
||||||
{ class: `msg msg--user${m.queued ? " is-queued" : ""}${m.machine ? " is-machine" : ""}`, dataset: { id: m.id } },
|
{ class: `msg msg--user${m.queued ? " is-queued" : ""}${m.machine ? " is-machine" : ""}`, dataset: { id: m.id } },
|
||||||
h("div", { class: "msg__text", text: m.text || "" }),
|
m.attachments?.length ? h("ul", { class: "tiles msg__files" }, m.attachments.map(fileTile)) : null,
|
||||||
m.attachments?.length
|
m.text ? h("div", { class: "msg__bubble" }, h("div", { class: "msg__text", text: m.text })) : null,
|
||||||
? h(
|
|
||||||
"ul",
|
|
||||||
{ class: "msg__files" },
|
|
||||||
m.attachments.map((a) => h("li", { class: "chip", title: a.name }, h("bdi", { text: a.name }), a.size ? ` · ${bytes(a.size)}` : "")),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
m.queued ? h("p", { class: "msg__note", text: t("Waiting for the reply before it") }) : null,
|
m.queued ? h("p", { class: "msg__note", text: t("Waiting for the reply before it") }) : null,
|
||||||
)
|
)
|
||||||
// A background job's report is not the person's to rewrite (the server refuses it too).
|
// A background job's report is not the person's to rewrite (the server refuses it too).
|
||||||
@@ -168,7 +259,7 @@ function assistantMessage(m, chat) {
|
|||||||
// Only the last reply: CSS shows it there and nowhere else (app.css, .msg__regen).
|
// Only the last reply: CSS shows it there and nowhere else (app.css, .msg__regen).
|
||||||
action(ICONS.refresh, t("Regenerate reply"), () => regenerate(m), "msg__regen"),
|
action(ICONS.refresh, t("Regenerate reply"), () => regenerate(m), "msg__regen"),
|
||||||
),
|
),
|
||||||
model ? h("span", { class: "chip chip--model", title: model.id }, avatar(model), h("span", { text: model.name })) : null,
|
model ? h("span", { class: "msg__model", title: model.id }, avatar(model), h("span", { text: model.name })) : null,
|
||||||
meta,
|
meta,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -186,7 +277,7 @@ function step(s, chat) {
|
|||||||
return h(
|
return h(
|
||||||
"details",
|
"details",
|
||||||
{ class: "step step--thinking", open: s.open || null },
|
{ class: "step step--thinking", open: s.open || null },
|
||||||
h("summary", null, t("Thought"), s.label ? h("span", { class: "muted", text: ` ${s.label}` }) : null),
|
h("summary", { class: "step__row" }, h("span", { class: "step__icon" }, icon(ICONS.bulb)), h("span", { class: "step__label" }, t("Thought"), s.label ? h("span", { class: "step__meta", text: ` · ${s.label}` }) : null), h("span", { class: "step__chev" }, icon(ICONS.chevron))),
|
||||||
h("div", { class: "step__pre", text: s.text || "" }),
|
h("div", { class: "step__pre", text: s.text || "" }),
|
||||||
)
|
)
|
||||||
case "text":
|
case "text":
|
||||||
@@ -242,8 +333,10 @@ function toolCall(c, chat) {
|
|||||||
"summary",
|
"summary",
|
||||||
{ class: "call__head" },
|
{ class: "call__head" },
|
||||||
h("span", { class: `dot${failed ? " is-error" : c.status === "running" ? " is-busy" : " is-ok"}`, "aria-hidden": "true" }),
|
h("span", { class: `dot${failed ? " is-error" : c.status === "running" ? " is-busy" : " is-ok"}`, "aria-hidden": "true" }),
|
||||||
h("span", { class: "call__title", title: block.title || c.name, text: block.title || c.name || "" }),
|
h("span", { class: "call__name", text: c.name || "" }),
|
||||||
|
h("span", { class: "call__title", title: block.title || c.name, text: block.title && block.title !== c.name ? block.title : "" }),
|
||||||
h("span", { class: "call__chips" }, (block.chips || []).map((x) => h("span", { class: "chip", text: x }))),
|
h("span", { class: "call__chips" }, (block.chips || []).map((x) => h("span", { class: "chip", text: x }))),
|
||||||
|
h("span", { class: "step__chev" }, icon(ICONS.chevron)),
|
||||||
)
|
)
|
||||||
const el = h("details", { class: `call${failed ? " is-failed" : ""}` }, summary, body)
|
const el = h("details", { class: `call${failed ? " is-failed" : ""}` }, summary, body)
|
||||||
if (isShow) {
|
if (isShow) {
|
||||||
@@ -291,6 +384,24 @@ class Live {
|
|||||||
this.el = h("article", { class: "msg msg--assistant is-live", dataset: { id: messageId } }, this.steps, this.thinking, this.tail, this.jobs, this.status)
|
this.el = h("article", { class: "msg msg--assistant is-live", dataset: { id: messageId } }, this.steps, this.thinking, this.tail, this.jobs, this.status)
|
||||||
this.done = false
|
this.done = false
|
||||||
this.handle = null
|
this.handle = null
|
||||||
|
this.progress = null // the stream's `progress` (spec 3.4.0), drawn in place of `status` when it comes
|
||||||
|
this.said = "" // the last `status` text, for a server that sends no progress
|
||||||
|
this.clock = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The status line: the progress object's line where the server sends one, else its text. */
|
||||||
|
drawStatus() {
|
||||||
|
const p = this.progress
|
||||||
|
this.status.textContent = isProgress(p) ? progressLine(p) : this.said || t("Writing…")
|
||||||
|
this.status.dataset.phase = isProgress(p) ? p.phase : ""
|
||||||
|
const ticks = progressTicks(p)
|
||||||
|
if (ticks && !this.clock) this.clock = setInterval(() => (this.done ? this.stopClock() : this.drawStatus()), 1000)
|
||||||
|
else if (!ticks) this.stopClock()
|
||||||
|
}
|
||||||
|
|
||||||
|
stopClock() {
|
||||||
|
clearInterval(this.clock)
|
||||||
|
this.clock = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
@@ -306,11 +417,18 @@ class Live {
|
|||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
this.done = true
|
this.done = true
|
||||||
|
this.stopClock()
|
||||||
this.handle?.close()
|
this.handle?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
frame(event, data) {
|
frame(event, data) {
|
||||||
if (this.done) return
|
if (this.done) return
|
||||||
|
// Any frame that carries `progress` (the status frame does, from server 2.0.0-beta.8 on):
|
||||||
|
// the object, or null for nothing running -- then the status text again.
|
||||||
|
if (data && typeof data === "object" && Object.prototype.hasOwnProperty.call(data, "progress")) {
|
||||||
|
this.progress = isProgress(data.progress) ? data.progress : null
|
||||||
|
if (event !== "status") this.drawStatus()
|
||||||
|
}
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case "steps":
|
case "steps":
|
||||||
renderSteps(this.steps, data.steps || [], this.chat)
|
renderSteps(this.steps, data.steps || [], this.chat)
|
||||||
@@ -320,11 +438,16 @@ class Live {
|
|||||||
this.tailed(data)
|
this.tailed(data)
|
||||||
break
|
break
|
||||||
case "status":
|
case "status":
|
||||||
this.status.textContent = data.text || t("Writing…")
|
this.said = data.text || ""
|
||||||
|
this.drawStatus()
|
||||||
break
|
break
|
||||||
case "ask":
|
case "ask":
|
||||||
renderCards(this.chat, data.cards || [])
|
renderCards(this.chat, data.cards || [])
|
||||||
break
|
break
|
||||||
|
case "tree":
|
||||||
|
// The subagent tree as it changed (spec 3.4.0): the activity panel draws it.
|
||||||
|
treeChanged(this.chat.id, data.tree)
|
||||||
|
break
|
||||||
case "jobs":
|
case "jobs":
|
||||||
this.jobs.hidden = !(data.jobs || []).length
|
this.jobs.hidden = !(data.jobs || []).length
|
||||||
append(
|
append(
|
||||||
@@ -339,6 +462,7 @@ class Live {
|
|||||||
break
|
break
|
||||||
case "done":
|
case "done":
|
||||||
this.done = true
|
this.done = true
|
||||||
|
this.stopClock()
|
||||||
this.el.replaceWith(assistantMessage(data.message, this.chat))
|
this.el.replaceWith(assistantMessage(data.message, this.chat))
|
||||||
renderCards(this.chat, [])
|
renderCards(this.chat, [])
|
||||||
if (data.chat?.title) setTitle(data.chat.title)
|
if (data.chat?.title) setTitle(data.chat.title)
|
||||||
@@ -364,6 +488,7 @@ class Live {
|
|||||||
end(err) {
|
end(err) {
|
||||||
if (this.done) return
|
if (this.done) return
|
||||||
this.done = true
|
this.done = true
|
||||||
|
this.stopClock()
|
||||||
if (open?.live === this) open.live = null
|
if (open?.live === this) open.live = null
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -377,6 +502,7 @@ class Live {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** While a reply is written, Stop takes Send's place (Send comes back beside it for a message typed meanwhile). */
|
||||||
function setRunning(on) {
|
function setRunning(on) {
|
||||||
$("send").hidden = on && !$("composer-text").value.trim()
|
$("send").hidden = on && !$("composer-text").value.trim()
|
||||||
$("stop").hidden = !on
|
$("stop").hidden = !on
|
||||||
@@ -395,10 +521,32 @@ function follower(chat, messageId) {
|
|||||||
|
|
||||||
// --- Cards ----------------------------------------------------------------------------------------
|
// --- Cards ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cards waiting for an answer, as the stream's `ask` frame lists them. A card already on the
|
||||||
|
* screen is kept as it is -- the server sends the frame again whenever anything in it changes, its
|
||||||
|
* countdown included, and drawing it again would take away a command being corrected or an option
|
||||||
|
* already picked. New ones are added in the server's order; the ones no longer listed go.
|
||||||
|
*/
|
||||||
function renderCards(chat, cards) {
|
function renderCards(chat, cards) {
|
||||||
const box = clear($("cards"))
|
const box = $("cards")
|
||||||
box.hidden = !cards.length
|
const ids = cards.map((c) => String(c.id))
|
||||||
for (const card of cards) box.append(cardOf(chat, card))
|
for (const el of [...box.children]) if (el.dataset.chat !== chat.id || !ids.includes(el.dataset.card)) el.remove()
|
||||||
|
cards.forEach((card, i) => {
|
||||||
|
let el = [...box.children].find((x) => x.dataset.card === String(card.id))
|
||||||
|
if (!el) {
|
||||||
|
try {
|
||||||
|
el = cardOf(chat, card)
|
||||||
|
} catch (err) {
|
||||||
|
// Never an empty box under a status that says the reply waits: say what went wrong.
|
||||||
|
console.error("card", card, err)
|
||||||
|
el = h("section", { class: "ask" }, h("p", { class: "msg__note is-error", text: String(err) }))
|
||||||
|
}
|
||||||
|
el.dataset.card = String(card.id)
|
||||||
|
el.dataset.chat = chat.id
|
||||||
|
}
|
||||||
|
if (box.children[i] !== el) box.insertBefore(el, box.children[i] || null)
|
||||||
|
})
|
||||||
|
box.hidden = !box.children.length
|
||||||
}
|
}
|
||||||
|
|
||||||
function cardOf(chat, card) {
|
function cardOf(chat, card) {
|
||||||
@@ -408,24 +556,44 @@ function cardOf(chat, card) {
|
|||||||
if (card.why) el.append(h("p", { class: "muted", text: card.why }))
|
if (card.why) el.append(h("p", { class: "muted", text: card.why }))
|
||||||
for (const item of card.items) {
|
for (const item of card.items) {
|
||||||
const part = h("div", { class: "ask__item" })
|
const part = h("div", { class: "ask__item" })
|
||||||
|
// A background subagent asks, not the conversation's own turn: said first (`job`).
|
||||||
|
if (item.job) part.append(h("p", { class: "ask__header", text: t("%(job)s asks:", { job: item.job }) }))
|
||||||
|
// A subagent of the tree asks, for a call nobody in the tree may allow (spec 3.4.0, §7).
|
||||||
|
if (item.subagent) {
|
||||||
|
const sub = item.subagent
|
||||||
|
part.append(h("p", { class: "ask__header ask__subagent", text: t("Subagent %(name)s (%(id)s), %(position)s, asks:", { name: sub.name || sub.id, id: sub.id, position: sub.position || "—" }) }))
|
||||||
|
}
|
||||||
if (item.header) part.append(h("p", { class: "ask__header", text: item.header }))
|
if (item.header) part.append(h("p", { class: "ask__header", text: item.header }))
|
||||||
|
let detailShown = false
|
||||||
if (item.kind === "approval") {
|
if (item.kind === "approval") {
|
||||||
part.append(h("p", { class: "ask__title", text: item.tool ? t("The agent asks to run %(tool)s:", { tool: item.tool }) : t("The agent asks:") }))
|
part.append(h("p", { class: "ask__title", text: item.tool ? t("The agent asks to run %(tool)s:", { tool: item.tool }) : t("The agent asks:") }))
|
||||||
if (item.editable) {
|
if (item.editable) {
|
||||||
const edit = h("textarea", { class: "ask__edit", rows: "2", "aria-label": t("Command"), value: item.title })
|
// A command, correctable before it runs. The server says it in `detail` and puts a fixed
|
||||||
|
// "Run a command" in the title (services/device_link.py); a corrected command goes back as
|
||||||
|
// this item's text, and only when it differs -- an untouched one is not an edit.
|
||||||
|
const command = item.detail || item.title
|
||||||
|
detailShown = true
|
||||||
|
const edit = h("textarea", { class: "ask__edit", rows: "2", spellcheck: "false", "aria-label": t("Command"), value: command })
|
||||||
edit.addEventListener("input", () => {
|
edit.addEventListener("input", () => {
|
||||||
if (edit.value !== item.title) answers.texts[item.key] = edit.value
|
if (edit.value !== command) answers.texts[item.key] = edit.value
|
||||||
else delete answers.texts[item.key]
|
else delete answers.texts[item.key]
|
||||||
})
|
})
|
||||||
part.append(edit)
|
part.append(edit)
|
||||||
} else part.append(h("pre", { class: "call__pre", text: item.title }))
|
} else {
|
||||||
|
// What it wants to do (a tool's own title), then its input as the device sent it.
|
||||||
|
part.append(h("p", { class: "ask__what", text: item.title }))
|
||||||
|
if (item.detail) part.append(h("pre", { class: "call__pre", text: item.detail }))
|
||||||
|
detailShown = true
|
||||||
|
}
|
||||||
} else if (item.kind === "plan" && item.plan) {
|
} else if (item.kind === "plan" && item.plan) {
|
||||||
part.append(h("p", { class: "ask__title", text: item.plan.title || item.title }), planList(item.plan))
|
part.append(h("p", { class: "ask__title", text: item.plan.title || item.title }), planList(item.plan))
|
||||||
} else {
|
} else {
|
||||||
part.append(h("p", { class: "ask__title", text: item.title }))
|
part.append(h("p", { class: "ask__title", text: item.title }))
|
||||||
}
|
}
|
||||||
if (item.detail) part.append(h("p", { class: "muted", text: item.detail }))
|
if (item.detail && !detailShown) part.append(h("p", { class: "muted", text: item.detail }))
|
||||||
if (item.purpose) part.append(h("p", { class: "muted", text: item.purpose }))
|
// The model's account of what it is for, attributed to it; then the server's reason for asking.
|
||||||
|
if (item.purpose) part.append(h("p", { class: "muted", text: t("It says: %(purpose)s", { purpose: item.purpose }) }))
|
||||||
|
if (item.reason) part.append(h("p", { class: "muted ask__reason", text: item.reason }))
|
||||||
if (item.options?.length) {
|
if (item.options?.length) {
|
||||||
const picked = new Set()
|
const picked = new Set()
|
||||||
const opts = h("div", { class: "ask__options", role: item.multiple ? "group" : "radiogroup" })
|
const opts = h("div", { class: "ask__options", role: item.multiple ? "group" : "radiogroup" })
|
||||||
@@ -484,12 +652,11 @@ function cardOf(chat, card) {
|
|||||||
const actions = h("div", { class: "ask__actions" })
|
const actions = h("div", { class: "ask__actions" })
|
||||||
if (decides) {
|
if (decides) {
|
||||||
const isPlan = card.items.some((i) => i.kind === "plan")
|
const isPlan = card.items.some((i) => i.kind === "plan")
|
||||||
append(
|
append(actions, [
|
||||||
actions,
|
|
||||||
h("button", { type: "button", class: "btn btn--primary", text: isPlan ? t("Carry it out") : t("Allow"), onclick: () => send("allow") }),
|
h("button", { type: "button", class: "btn btn--primary", text: isPlan ? t("Carry it out") : t("Allow"), onclick: () => send("allow") }),
|
||||||
isPlan ? null : h("button", { type: "button", class: "btn", text: t("Always allow"), onclick: () => send("allow_always") }),
|
isPlan ? null : h("button", { type: "button", class: "btn", text: t("Always allow"), onclick: () => send("allow_always") }),
|
||||||
h("button", { type: "button", class: "btn", text: isPlan ? t("Not yet") : t("Deny"), onclick: () => send("deny") }),
|
h("button", { type: "button", class: "btn", text: isPlan ? t("Not yet") : t("Deny"), onclick: () => send("deny") }),
|
||||||
)
|
])
|
||||||
} else {
|
} else {
|
||||||
actions.append(h("button", { type: "button", class: "btn btn--primary", text: t("Answer"), onclick: () => send("") }))
|
actions.append(h("button", { type: "button", class: "btn btn--primary", text: t("Answer"), onclick: () => send("") }))
|
||||||
}
|
}
|
||||||
@@ -504,6 +671,7 @@ function setTitle(title) {
|
|||||||
$("chat-title").title = title || ""
|
$("chat-title").title = title || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The chat's header: its title, where it works (an agent chat's folder and device), and its tools. */
|
||||||
function renderHead(chat) {
|
function renderHead(chat) {
|
||||||
setTitle(chat.title)
|
setTitle(chat.title)
|
||||||
const place = clear($("chat-place"))
|
const place = clear($("chat-place"))
|
||||||
@@ -511,47 +679,20 @@ function renderHead(chat) {
|
|||||||
if (chat.kind === "agent" && chat.device) {
|
if (chat.kind === "agent" && chat.device) {
|
||||||
const here = onThisComputer(chat)
|
const here = onThisComputer(chat)
|
||||||
place.append(
|
place.append(
|
||||||
h("span", { class: `chip${here ? " chip--leaf" : ""}`, text: here ? t("This computer") : t("Another device") }),
|
|
||||||
h("span", { class: "head__dir", title: chat.device.directory }, h("bdi", { text: chat.device.directory })),
|
|
||||||
)
|
|
||||||
const mode = h(
|
|
||||||
"select",
|
|
||||||
{ class: "head__mode", "aria-label": t("Mode"), onchange: (e) => patch(`/chats/${encodeURIComponent(chat.id)}`, { mode: e.target.value }).catch((err) => alertIn(why(err))) },
|
|
||||||
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: chat.mode === m })),
|
|
||||||
)
|
|
||||||
tools.append(mode)
|
|
||||||
if (chat.device_session_id) {
|
|
||||||
tools.append(
|
|
||||||
h(
|
h(
|
||||||
"button",
|
"span",
|
||||||
{ type: "button", class: "pane__btn", "aria-label": t("Continue in a terminal"), title: t("Continue in a terminal"), onclick: () => continueInTerminal(open?.chat || chat) },
|
{ class: `place${here ? " is-here" : ""}`, title: chat.device.directory },
|
||||||
icon(ICONS.terminal),
|
icon(ICONS.folder),
|
||||||
|
h("span", { class: "place__name", text: folderName(chat.device.directory) }),
|
||||||
|
h("span", { class: "place__where", text: here ? t("This computer") : t("Another device") }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// The Terminal panel where the chat's device opens one (`GET /devices` says so per device);
|
||||||
|
// "Continue in a terminal" stays in the chat's menu.
|
||||||
|
if (deviceOf(chat.device.token_id)?.terminal) tools.append(terminalButton())
|
||||||
}
|
}
|
||||||
}
|
tools.append(activityButton())
|
||||||
const slot = h("span", { class: "head__model" })
|
tools.append(h("button", { type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)))
|
||||||
tools.append(slot)
|
|
||||||
chatModels(chat).then((models) => {
|
|
||||||
if (open?.chat?.id === chat.id) headModel(slot, chat, models)
|
|
||||||
})
|
|
||||||
tools.append(
|
|
||||||
h(
|
|
||||||
"button",
|
|
||||||
{
|
|
||||||
type: "button",
|
|
||||||
class: "pane__btn",
|
|
||||||
id: "activity-open",
|
|
||||||
"aria-label": t("Activity"),
|
|
||||||
title: t("Activity"),
|
|
||||||
"aria-controls": "activity",
|
|
||||||
"aria-expanded": String(panelOpen()),
|
|
||||||
onclick: togglePanel,
|
|
||||||
},
|
|
||||||
icon(ICONS.bolt),
|
|
||||||
),
|
|
||||||
h("button", { type: "button", class: "pane__btn", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The models this chat may switch to (its data group's), or the account's when that fails. */
|
/** The models this chat may switch to (its data group's), or the account's when that fails. */
|
||||||
@@ -563,80 +704,240 @@ async function chatModels(chat) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether this chat may have a team (`GET /chats/{chat}/team`): only a chat on the server. */
|
/**
|
||||||
|
* Whether this chat is offered subagents: `{ cap, own }` (the instance's most; whether only a model
|
||||||
|
* the person names could serve them), or null. The chat's own `subagents` where the server gives
|
||||||
|
* it (2.0.0-beta.7), else its team's word (`GET /chats/{chat}/team`) -- only a chat on the server.
|
||||||
|
*/
|
||||||
async function teamOffered(chat) {
|
async function teamOffered(chat) {
|
||||||
if (chat.kind !== "chat") return false
|
const said = chat.subagents
|
||||||
|
if (said !== undefined && said !== null) {
|
||||||
|
const offered = typeof said === "object" ? said.offered : said
|
||||||
|
return offered ? { cap: subagentsCap(said), own: namedOnly(said) } : null
|
||||||
|
}
|
||||||
|
if (chat.kind !== "chat") return null
|
||||||
try {
|
try {
|
||||||
return !!(await get(`/chats/${encodeURIComponent(chat.id)}/team`)).team?.offered
|
const team = (await get(`/chats/${encodeURIComponent(chat.id)}/team`)).team
|
||||||
|
return team?.offered ? { cap: subagentsCap(), own: false } : null
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// --- The chips under the text -------------------------------------------------------------------
|
||||||
* The chat's model; how hard it thinks, drawn as the model offers it (nothing for one level, the
|
//
|
||||||
* Thinking switch, or the selector); and the team's size where a team is offered.
|
// A chat's settings are small buttons under what is being written, each opening a menu: the
|
||||||
*/
|
// folder and the mode (an agent chat), the model with its picture, how hard it thinks (as the
|
||||||
async function headModel(slot, chat, models) {
|
// model offers it: nothing for one level), and the team's size (where a team is offered). For a
|
||||||
const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false
|
// new chat they say what it will start with; for an open one, changing one changes the chat.
|
||||||
const current = models.find((m) => m.id === chat.model) || { id: chat.model, name: chat.model, efforts: [] }
|
|
||||||
const parts = []
|
/** One chip: an icon or a lead (an avatar), its words, and a chevron when it opens a menu. */
|
||||||
if (mayPick && models.length > 1) {
|
function chip({ ico, lead, label, title, onclick, menuish = true, cls = "", disabled = false }) {
|
||||||
parts.push(
|
return h(
|
||||||
modelPicker({
|
"button",
|
||||||
models,
|
{ type: "button", class: `cchip ${cls}`, title: title || label, "aria-haspopup": menuish ? "menu" : null, disabled, onclick },
|
||||||
value: chat.model,
|
lead || (ico ? icon(ico) : null),
|
||||||
label: t("Model"),
|
h("span", { class: "cchip__label", text: label }),
|
||||||
onChange: async (id) => {
|
menuish ? h("span", { class: "cchip__chev" }, icon(ICONS.chevronDown)) : null,
|
||||||
try {
|
|
||||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { model: id })
|
|
||||||
if (open?.chat?.id === chat.id) {
|
|
||||||
open.chat = r.chat
|
|
||||||
headModel(slot, r.chat, models)
|
|
||||||
activityChanged()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
alertIn(why(err))
|
|
||||||
headModel(slot, chat, models)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
} else if (chat.model) {
|
}
|
||||||
parts.push(h("span", { class: "chip chip--model", title: current.id }, avatar(current), h("span", { text: current.name })))
|
|
||||||
}
|
const MODE_WORDS = {
|
||||||
const level = effortControl({
|
manual: () => t("Manual — asks first"),
|
||||||
model: current,
|
edit: () => t("Edit — asks before commands"),
|
||||||
value: chat.effort || levelsOf(current).start,
|
auto: () => t("Auto — asks nothing"),
|
||||||
compact: true,
|
plan: () => t("Plan — changes nothing"),
|
||||||
onChange: async (value) => {
|
}
|
||||||
try {
|
|
||||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { effort: value })
|
function modeChip(value, onChange, disabled) {
|
||||||
if (open?.chat?.id === chat.id) open.chat = r.chat
|
return chip({
|
||||||
} catch (err) {
|
ico: ICONS.shield,
|
||||||
alertIn(why(err))
|
label: modeName(value),
|
||||||
level.value = open?.chat?.effort || levelsOf(current).start
|
title: `${t("Mode")}: ${MODE_WORDS[value]?.() || value}`,
|
||||||
}
|
disabled,
|
||||||
|
onclick: (e) => menu(e.currentTarget, MODES.map((m) => ({ label: MODE_WORDS[m](), checked: m === value, run: () => m !== value && onChange(m) }))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelChip(models, value, onChange, mayPick) {
|
||||||
|
const m = models.find((x) => x.id === value) || (value ? { id: value, name: value } : null)
|
||||||
|
if (!m) return chip({ ico: ICONS.info, label: t("No model is available to this account."), menuish: false, disabled: true })
|
||||||
|
return chip({
|
||||||
|
lead: avatar(m),
|
||||||
|
label: m.name,
|
||||||
|
title: `${t("Model")}: ${m.name} (${m.id})`,
|
||||||
|
menuish: mayPick && models.length > 1,
|
||||||
|
cls: "cchip--model",
|
||||||
|
onclick: (e) => {
|
||||||
|
if (!mayPick || models.length < 2) return
|
||||||
|
menu(e.currentTarget, models.map((x) => ({ label: x.name, lead: avatar(x), checked: x.id === value, run: () => x.id !== value && onChange(x.id) })))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (level) parts.push(level)
|
}
|
||||||
slot.replaceChildren(...parts)
|
|
||||||
if (!(await teamOffered(chat)) || open?.chat?.id !== chat.id || !slot.isConnected) return
|
/** How hard the model thinks: nothing where it has one level; else its levels, in a menu. */
|
||||||
const team = agentsControl({
|
function effortChip(model, value, onChange) {
|
||||||
value: open.chat.agents || chat.agents,
|
const { levels, start, control } = levelsOf(model)
|
||||||
compact: true,
|
if (control === "none") return null
|
||||||
onChange: async (wanted) => {
|
const current = levels.includes(value) ? value : start
|
||||||
|
const words = (l) => (control === "switch" ? (l === "off" ? t("off") : t("Think before answering")) : levelWord(l))
|
||||||
|
return chip({
|
||||||
|
ico: ICONS.bulb,
|
||||||
|
label: control === "switch" ? (current === "off" ? t("off") : levelWord(current)) : levelWord(current),
|
||||||
|
title: `${t("Reasoning effort")}: ${levelWord(current)}`,
|
||||||
|
cls: current === "off" ? "is-dim" : "",
|
||||||
|
onclick: (e) => menu(e.currentTarget, levels.map((l) => ({ label: words(l), hint: control === "switch" && l !== "off" ? levelWord(l) : "", checked: l === current, run: () => l !== current && onChange(l) }))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subagents a chat may run: a chip saying Min–Max, opening one range with two handles.
|
||||||
|
* `value` { min, max } or empty; `cap` the instance's most, where the server says it.
|
||||||
|
*/
|
||||||
|
function agentsChip(value, onChange, { cap, own } = {}) {
|
||||||
|
const said = value?.min || value?.max ? `${value.min ?? "…"}–${value.max ?? "…"}` : t("default")
|
||||||
|
return chip({
|
||||||
|
ico: ICONS.users,
|
||||||
|
label: `${t("Subagents")} ${said}`,
|
||||||
|
title: own ? SUBAGENTS_OWN() : SUBAGENTS_HINT(),
|
||||||
|
cls: `cchip--subagents${own ? " is-warn" : ""}`,
|
||||||
|
onclick: (e) => {
|
||||||
|
const fields = agentsControl({ value, id: "agents-min", cap })
|
||||||
|
popover(
|
||||||
|
e.currentTarget,
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "pop__body" },
|
||||||
|
h("p", { class: "pop__title", text: t("Subagents") }),
|
||||||
|
fields,
|
||||||
|
own ? h("p", { class: "hint is-warn", text: SUBAGENTS_OWN() }) : null,
|
||||||
|
h("p", { class: "hint", text: SUBAGENTS_HINT() }),
|
||||||
|
h("div", { class: "pop__actions" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save"), onclick: () => (closePopover(), onChange(fields.value)) })),
|
||||||
|
),
|
||||||
|
{ up: true, label: t("Subagents") },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The folder of a new agent chat: one of the agent's folders, or Add folder… where there is none. */
|
||||||
|
function folderChip(roots, value, onChange) {
|
||||||
|
if (!roots.length) return chip({ ico: ICONS.folder, label: t("Add folder…"), menuish: false, cls: "is-warn", onclick: () => invoke("remote_add") })
|
||||||
|
return chip({
|
||||||
|
ico: ICONS.folder,
|
||||||
|
label: folderName(value),
|
||||||
|
title: `${t("Folder")}: ${value}`,
|
||||||
|
onclick: (e) =>
|
||||||
|
menu(e.currentTarget, [
|
||||||
|
...roots.map((r) => ({ label: folderName(r), hint: r, checked: r === value, run: () => r !== value && onChange(r) })),
|
||||||
|
null,
|
||||||
|
{ label: t("Add folder…"), run: () => invoke("remote_add") },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let chipsDrawn = 0 // which drawing is the latest: an older one's late answers are dropped
|
||||||
|
|
||||||
|
/** The chips for what the composer is writing to: a new chat (`draft`) or the open chat. */
|
||||||
|
async function renderChips() {
|
||||||
|
const box = $("composer-chips")
|
||||||
|
const drawing = ++chipsDrawn
|
||||||
|
const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false
|
||||||
|
if (draft) {
|
||||||
|
const s = ctx.status()
|
||||||
|
const models = ctx.models()
|
||||||
|
const model = models.find((m) => m.id === draft.model)
|
||||||
|
const parts = []
|
||||||
|
if (draft.kind === "agent") {
|
||||||
|
const roots = s?.remote?.enabled ? s.remote.roots : []
|
||||||
|
if (!roots.includes(draft.dir)) draft.dir = roots[0] || ""
|
||||||
|
parts.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
|
||||||
|
parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
|
||||||
|
}
|
||||||
|
const shell = terminalTarget()
|
||||||
|
aimTerminal(shell)
|
||||||
|
parts.push(modelChip(models, draft.model, (id) => {
|
||||||
|
draft.model = id
|
||||||
|
draft.effort = levelsOf(models.find((m) => m.id === id)).start
|
||||||
|
renderChips()
|
||||||
|
}, mayPick))
|
||||||
|
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
|
||||||
|
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), { cap: subagentsCap(), own: namedOnly(model?.subagents) }))
|
||||||
|
// An agent chat's subagents run on its device: offered as the server says for that device.
|
||||||
|
const device = draft.kind === "agent" ? draftDevice() : ""
|
||||||
|
const known = device ? agentFacts.get(device) : null
|
||||||
|
const offer = device ? agentOffer(known, model) : null
|
||||||
|
if (offer) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), offer))
|
||||||
|
if (device && (!known || Date.now() - known.at > 5000)) {
|
||||||
|
readAgentFacts(device).then((f) => {
|
||||||
|
if (f && drawing === chipsDrawn && draft?.kind === "agent") renderChips()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Last, so the chat's own settings come first where the line scrolls.
|
||||||
|
if (shell) {
|
||||||
|
const b = chip({ ico: ICONS.terminal, label: t("Terminal"), menuish: false, cls: "cchip--terminal", onclick: toggleTerminal })
|
||||||
|
b.dataset.terminalToggle = ""
|
||||||
|
b.setAttribute("aria-pressed", String(terminalOpen()))
|
||||||
|
b.setAttribute("aria-controls", "terminal")
|
||||||
|
parts.push(b)
|
||||||
|
}
|
||||||
|
box.replaceChildren(...parts.filter(Boolean))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const chat = open?.chat
|
||||||
|
aimTerminal(terminalTarget())
|
||||||
|
if (!chat?.kind) return box.replaceChildren()
|
||||||
|
const save = async (body) => {
|
||||||
try {
|
try {
|
||||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { agents: wanted })
|
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, body)
|
||||||
if (open?.chat?.id === chat.id) open.chat = r.chat
|
if (open?.chat?.id === chat.id) {
|
||||||
|
open.chat = r.chat
|
||||||
|
renderChips()
|
||||||
|
if (body.model) activityChanged()
|
||||||
|
}
|
||||||
alertIn("")
|
alertIn("")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alertIn(why(err))
|
alertIn(why(err))
|
||||||
team.value = open?.chat?.agents || chat.agents
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
})
|
const models = await chatModels(chat)
|
||||||
slot.append(team)
|
if (drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
|
||||||
|
const current = models.find((m) => m.id === chat.model) || { id: chat.model, name: chat.model, efforts: [] }
|
||||||
|
const parts = []
|
||||||
|
if (chat.kind === "agent") parts.push(modeChip(chat.mode || "edit", (m) => save({ mode: m })))
|
||||||
|
if (chat.model) parts.push(modelChip(models, chat.model, (id) => save({ model: id }), mayPick))
|
||||||
|
parts.push(effortChip(current, chat.effort || levelsOf(current).start, (l) => save({ effort: l })))
|
||||||
|
box.replaceChildren(...parts.filter(Boolean))
|
||||||
|
const offer = await teamOffered(chat)
|
||||||
|
if (!offer || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
|
||||||
|
box.append(agentsChip(open.chat.agents || chat.agents, (a) => save({ agents: a }), offer))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Above the composer, what stands in the way of an agent chat on this computer: not linked, the
|
||||||
|
* server refused the link, no folder. A new agent chat says it; an open one on this computer too.
|
||||||
|
*/
|
||||||
|
function renderNotice() {
|
||||||
|
const box = $("link-notice")
|
||||||
|
const s = ctx.status()
|
||||||
|
const wants = draft ? draft.kind === "agent" : open?.chat?.kind === "agent" && onThisComputer(open.chat)
|
||||||
|
const show = !!s && wants && !agentReady(s) && agentState(s).tone !== "busy"
|
||||||
|
box.hidden = !show
|
||||||
|
if (show) box.replaceChildren(agentBlock(s, { compact: !draft }))
|
||||||
|
else box.replaceChildren()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app's status changed (the agent, its folders): what depends on it is drawn again. */
|
||||||
|
export function statusChanged() {
|
||||||
|
renderNotice()
|
||||||
|
if (draft) {
|
||||||
|
renderChips()
|
||||||
|
renderHello()
|
||||||
|
} else if (open?.chat?.kind) {
|
||||||
|
// The devices were read again: whether this chat's opens a terminal may have changed.
|
||||||
|
renderHead(open.chat)
|
||||||
|
aimTerminal(terminalTarget())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The chat's own menu, under its ⋯ button or a row of the sidebar: `chat` as the list has it. */
|
/** The chat's own menu, under its ⋯ button or a row of the sidebar: `chat` as the list has it. */
|
||||||
@@ -735,9 +1036,14 @@ export async function openChat(id) {
|
|||||||
if (open?.chat?.id !== id) discard()
|
if (open?.chat?.id !== id) discard()
|
||||||
if (open?.live) open.live.stop()
|
if (open?.live) open.live.stop()
|
||||||
open = { chat: { id }, live: null, more: false }
|
open = { chat: { id }, live: null, more: false }
|
||||||
|
draft = null
|
||||||
|
closePopover()
|
||||||
invoke("set_open_chat", { chat: id })
|
invoke("set_open_chat", { chat: id })
|
||||||
showComposer(true)
|
showComposer(true)
|
||||||
$("new-chat").hidden = true
|
$("new-chat").hidden = true
|
||||||
|
$("main").dataset.new = "false"
|
||||||
|
clear($("composer-chips"))
|
||||||
|
renderNotice()
|
||||||
const list = clear($("messages"))
|
const list = clear($("messages"))
|
||||||
renderCards({ id }, [])
|
renderCards({ id }, [])
|
||||||
alertIn("")
|
alertIn("")
|
||||||
@@ -755,6 +1061,8 @@ export async function openChat(id) {
|
|||||||
open.more = r.more
|
open.more = r.more
|
||||||
watchActivity(r.chat)
|
watchActivity(r.chat)
|
||||||
renderHead(r.chat)
|
renderHead(r.chat)
|
||||||
|
renderChips()
|
||||||
|
renderNotice()
|
||||||
clear(list)
|
clear(list)
|
||||||
if (r.more) list.append(earlier())
|
if (r.more) list.append(earlier())
|
||||||
for (const m of r.messages) {
|
for (const m of r.messages) {
|
||||||
@@ -805,121 +1113,131 @@ export function closeChat() {
|
|||||||
invoke("set_open_chat", { chat: null })
|
invoke("set_open_chat", { chat: null })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- A new chat ------------------------------------------------------------------------------------
|
// --- A new chat ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
let draft = null // { kind, level (the effort control), agents (the team's size) }
|
let draft = null // { kind, dir, mode, model, effort, agents } while a new chat is being written
|
||||||
|
let lastKind = "agent"
|
||||||
|
|
||||||
/** The form a new chat starts from: `agent` (on this computer) or `chat` (on the server). */
|
/** The kind a new chat starts as: the last one chosen (an agent chat, at first). */
|
||||||
|
export function draftKind() {
|
||||||
|
return draft?.kind || lastKind
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/models` was read again: a new chat being written is drawn with what it says now. */
|
||||||
|
export function modelsChanged() {
|
||||||
|
if (!draft) return
|
||||||
|
const models = ctx.models()
|
||||||
|
if (!models.some((m) => m.id === draft.model)) {
|
||||||
|
const start = models.find((m) => m.default) || models[0]
|
||||||
|
draft.model = start?.id || ""
|
||||||
|
draft.effort = levelsOf(start).start
|
||||||
|
}
|
||||||
|
renderChips()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The greeting of a new chat, and the choice of what it is. */
|
||||||
|
function renderHello() {
|
||||||
|
const box = $("new-chat")
|
||||||
|
if (!draft) return
|
||||||
|
const me = ctx.me()
|
||||||
|
const first = String(me?.name || "").trim().split(/\s+/)[0]
|
||||||
|
const kind = draft.kind
|
||||||
|
const s = ctx.status()
|
||||||
|
const server = (() => {
|
||||||
|
try {
|
||||||
|
return new URL(s?.server || "").host
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
const tab = (k, ico, label) =>
|
||||||
|
h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
role: "tab",
|
||||||
|
class: "kind__tab",
|
||||||
|
"aria-selected": String(kind === k),
|
||||||
|
onclick: () => {
|
||||||
|
if (draft.kind === k) return
|
||||||
|
draft.kind = lastKind = k
|
||||||
|
renderHello()
|
||||||
|
renderChips()
|
||||||
|
renderNotice()
|
||||||
|
ctx.drafted?.(k)
|
||||||
|
$("composer-text").focus()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
icon(ico),
|
||||||
|
h("span", { text: label }),
|
||||||
|
)
|
||||||
|
box.replaceChildren(
|
||||||
|
h("img", { class: "hello__mark", src: "logo-mark.svg", alt: "", width: "40", height: "40" }),
|
||||||
|
h("h2", { class: "hello__title", text: first ? t("Hello, %(name)s", { name: first }) : t("Hello") }),
|
||||||
|
h(
|
||||||
|
"p",
|
||||||
|
{ class: "hello__sub" },
|
||||||
|
kind === "agent"
|
||||||
|
? t("An agent chat works in a folder on this computer, through the agent built into this app.")
|
||||||
|
: t("A chat on %(server)s, with your account's models and files.", { server: server || t("Server") }),
|
||||||
|
),
|
||||||
|
h("div", { class: "kind", role: "tablist", "aria-label": t("New chat") }, tab("agent", ICONS.terminal, t("Agent")), tab("chat", ICONS.chat, t("Chat"))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new chat in the main view: the page empty, the greeting, and the composer ready. */
|
||||||
export function newChat(kind) {
|
export function newChat(kind) {
|
||||||
closeChat()
|
closeChat()
|
||||||
discard()
|
discard()
|
||||||
draft = { kind }
|
closePopover()
|
||||||
|
const models = ctx.models()
|
||||||
|
const start = models.find((m) => m.default) || models[0]
|
||||||
|
lastKind = kind || lastKind
|
||||||
|
draft = { kind: lastKind, dir: "", mode: "edit", model: start?.id || "", effort: levelsOf(start).start, agents: null }
|
||||||
clear($("messages"))
|
clear($("messages"))
|
||||||
renderCards({ id: "" }, [])
|
renderCards({ id: "" }, [])
|
||||||
alertIn("")
|
alertIn("")
|
||||||
setTitle(kind === "agent" ? t("New agent chat") : t("New chat"))
|
setTitle(t("New chat"))
|
||||||
clear($("chat-place"))
|
clear($("chat-place"))
|
||||||
clear($("chat-tools"))
|
clear($("chat-tools"))
|
||||||
const form = clear($("new-chat"))
|
$("main").dataset.new = "true"
|
||||||
form.hidden = false
|
$("new-chat").hidden = false
|
||||||
const s = ctx.status()
|
renderHello()
|
||||||
const models = ctx.models()
|
renderChips()
|
||||||
const start = (models.find((m) => m.default) || models[0])?.id || ""
|
renderNotice()
|
||||||
// How hard it thinks, and the team's size: drawn for the chosen model, again when it changes.
|
|
||||||
// Each slot is a field of its own (label, control, hint), so the row lines up whichever is shown.
|
|
||||||
const effortField = field(t("Reasoning effort"), h("span"), "")
|
|
||||||
const agentsField = field(t("Agents"), h("span"), t("Its own agent included. Above one, it can add members that do the work while it leads them."))
|
|
||||||
const showLevels = (id) => {
|
|
||||||
const model = models.find((m) => m.id === id)
|
|
||||||
const control = effortControl({ model, value: levelsOf(model).start, id: "new-effort" })
|
|
||||||
effortField.children[0].htmlFor = "new-effort"
|
|
||||||
effortField.children[1].replaceWith(control || h("span", { id: "new-effort" }))
|
|
||||||
effortField.hidden = !control
|
|
||||||
draft.level = control
|
|
||||||
const team = kind === "chat" && teamPossible(model)
|
|
||||||
draft.agents = team ? agentsControl({ value: null, id: "new-agents" }) : null
|
|
||||||
agentsField.children[1].replaceWith(draft.agents || h("span", { id: "new-agents" }))
|
|
||||||
agentsField.children[0].htmlFor = "new-agents"
|
|
||||||
agentsField.hidden = !team
|
|
||||||
}
|
|
||||||
const modelField = field(
|
|
||||||
t("Model"),
|
|
||||||
modelPicker({ id: "new-model", models, value: start, label: t("Model"), onChange: showLevels }),
|
|
||||||
models.length ? "" : t("No model is available to this account."),
|
|
||||||
)
|
|
||||||
showLevels(start)
|
|
||||||
if (kind === "agent") {
|
|
||||||
const roots = s.remote?.enabled ? s.remote.roots : []
|
|
||||||
const devices = ctx.thisDevices()
|
|
||||||
form.append(
|
|
||||||
h("p", { class: "muted", text: t("An agent chat works in a folder on this computer, through the agent built into this app.") }),
|
|
||||||
)
|
|
||||||
if (!devices.length) {
|
|
||||||
form.append(
|
|
||||||
h("p", { class: "new__warn", text: t("The agent is not connected to the server yet: start it, or link this computer, under Settings.") }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
form.append(
|
|
||||||
h(
|
|
||||||
"div",
|
|
||||||
{ class: "fields" },
|
|
||||||
field(
|
|
||||||
t("Folder"),
|
|
||||||
h("select", { id: "new-dir" }, roots.map((r) => h("option", { value: r, text: r }))),
|
|
||||||
roots.length ? t("Only the agent's folders: Settings, Project folders.") : t("No folder yet: add one under Settings, Project folders."),
|
|
||||||
),
|
|
||||||
field(
|
|
||||||
t("Mode"),
|
|
||||||
h(
|
|
||||||
"select",
|
|
||||||
{ id: "new-mode" },
|
|
||||||
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: m === "edit" })),
|
|
||||||
),
|
|
||||||
t("A chat asking for more than the folder allows is lowered to it."),
|
|
||||||
),
|
|
||||||
modelField,
|
|
||||||
effortField,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
form.append(h("div", { class: "fields" }, modelField, effortField, agentsField))
|
|
||||||
}
|
|
||||||
showComposer(true)
|
showComposer(true)
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
invoke("set_open_chat", { chat: null })
|
invoke("set_open_chat", { chat: null })
|
||||||
$("composer-text").focus()
|
$("composer-text").focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A field: label, control, hint -- always all three, so a row of them lines up. */
|
|
||||||
function field(label, control, hint) {
|
|
||||||
const id = control.id || `f${Math.random().toString(36).slice(2)}`
|
|
||||||
control.id = id
|
|
||||||
return h("div", { class: "field" }, h("label", { for: id, text: label }), control, h("p", { class: "hint", text: hint || "" }))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createChat(text, attachments) {
|
async function createChat(text, attachments) {
|
||||||
const body = { text }
|
const body = { text }
|
||||||
if (attachments.length) body.attachments = attachments
|
if (attachments.length) body.attachments = attachments
|
||||||
const model = $("new-model")?.value
|
if (draft.model) body.model = draft.model
|
||||||
if (model) body.model = model
|
if (typeof draft.effort === "string" && draft.effort) body.effort = draft.effort
|
||||||
const effort = draft.level?.value
|
const agents = draft.agents
|
||||||
if (typeof effort === "string" && effort) body.effort = effort
|
|
||||||
const agents = draft.agents?.value
|
|
||||||
if (agents && (agents.min || agents.max)) body.agents = agents
|
if (agents && (agents.min || agents.max)) body.agents = agents
|
||||||
if (draft.kind === "agent") {
|
if (draft.kind === "agent") {
|
||||||
const device = ctx.thisDevices()[0]
|
const device = draftDevice()
|
||||||
const dir = $("new-dir")?.value
|
if (!device || !agentReady(ctx.status())) {
|
||||||
if (!device) throw { detail: t("The agent is not connected to the server yet: start it, or link this computer, under Settings.") }
|
renderNotice()
|
||||||
if (!dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") }
|
throw { detail: agentState(ctx.status()).sentence }
|
||||||
Object.assign(body, { kind: "agent", device, directory: dir, mode: $("new-mode")?.value || "edit" })
|
}
|
||||||
|
if (!draft.dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") }
|
||||||
|
Object.assign(body, { kind: "agent", device, directory: draft.dir, mode: draft.mode || "edit" })
|
||||||
}
|
}
|
||||||
const r = await post("/chats", body)
|
const r = await post("/chats", body)
|
||||||
draft = null
|
draft = null
|
||||||
$("new-chat").hidden = true
|
$("new-chat").hidden = true
|
||||||
|
$("main").dataset.new = "false"
|
||||||
open = { chat: r.chat, live: null, more: false }
|
open = { chat: r.chat, live: null, more: false }
|
||||||
invoke("set_open_chat", { chat: r.chat.id })
|
invoke("set_open_chat", { chat: r.chat.id })
|
||||||
watchActivity(r.chat)
|
watchActivity(r.chat)
|
||||||
renderHead(r.chat)
|
renderHead(r.chat)
|
||||||
|
renderChips()
|
||||||
|
renderNotice()
|
||||||
clear($("messages"))
|
clear($("messages"))
|
||||||
if (r.message) $("messages").append(userMessage(r.message))
|
if (r.message) $("messages").append(userMessage(r.message))
|
||||||
ctx.changed(r.chat)
|
ctx.changed(r.chat)
|
||||||
@@ -973,6 +1291,8 @@ function grow() {
|
|||||||
|
|
||||||
export function initChat(context) {
|
export function initChat(context) {
|
||||||
ctx = context
|
ctx = context
|
||||||
|
$("send").replaceChildren(icon(ICONS.send))
|
||||||
|
$("stop").replaceChildren(icon(ICONS.stop))
|
||||||
$("composer").addEventListener("submit", (e) => {
|
$("composer").addEventListener("submit", (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
submit()
|
submit()
|
||||||
@@ -996,6 +1316,6 @@ export function initChat(context) {
|
|||||||
alertIn(why(err))
|
alertIn(why(err))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
onAgentChange(renderNotice)
|
||||||
showComposer(false)
|
showComposer(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
+17
-1
@@ -27,7 +27,8 @@ export function h(tag, attrs, ...children) {
|
|||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
export function append(el, children) {
|
/** append(el, child, [more], …): children one by one or in arrays, nested as deep as they come. */
|
||||||
|
export function append(el, ...children) {
|
||||||
for (const c of children.flat(Infinity)) {
|
for (const c of children.flat(Infinity)) {
|
||||||
if (c === null || c === undefined || c === false) continue
|
if (c === null || c === undefined || c === false) continue
|
||||||
el.append(c instanceof Node ? c : document.createTextNode(String(c)))
|
el.append(c instanceof Node ? c : document.createTextNode(String(c)))
|
||||||
@@ -75,15 +76,30 @@ export const ICONS = {
|
|||||||
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
|
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
|
||||||
restore: "M5.5 3h7.5v7.5h-2.5V13H3V5.5h2.5zM7 4.5v1h3.5V9h1V4.5zM4.5 7v4.5H9V7z",
|
restore: "M5.5 3h7.5v7.5h-2.5V13H3V5.5h2.5zM7 4.5v1h3.5V9h1V4.5zM4.5 7v4.5H9V7z",
|
||||||
chevronDown: "M3.5 6L8 10.5 12.5 6 11.4 4.9 8 8.3 4.6 4.9z",
|
chevronDown: "M3.5 6L8 10.5 12.5 6 11.4 4.9 8 8.3 4.6 4.9z",
|
||||||
|
chevronUp: "M3.5 10L8 5.5 12.5 10 11.4 11.1 8 7.7 4.6 11.1z",
|
||||||
more: "M2.5 6.75h2.5v2.5H2.5zM6.75 6.75h2.5v2.5h-2.5zM11 6.75h2.5v2.5H11z",
|
more: "M2.5 6.75h2.5v2.5H2.5zM6.75 6.75h2.5v2.5h-2.5zM11 6.75h2.5v2.5H11z",
|
||||||
edit: "M10.75 1.75l3.5 3.5-8.5 8.5h-3.5v-3.5zM3.75 10.87v1.38h1.38l6.62-6.62-1.38-1.38z",
|
edit: "M10.75 1.75l3.5 3.5-8.5 8.5h-3.5v-3.5zM3.75 10.87v1.38h1.38l6.62-6.62-1.38-1.38z",
|
||||||
copy: "M5 1.5h8.5V11H5zM6.5 3v6.5H12V3zM2.5 5H4v8.5h6.5V15h-8z",
|
copy: "M5 1.5h8.5V11H5zM6.5 3v6.5H12V3zM2.5 5H4v8.5h6.5V15h-8z",
|
||||||
attach: "M7.25 2.5h1.5v4.75h4.75v1.5H8.75v4.75h-1.5V8.75H2.5v-1.5h4.75z",
|
attach: "M7.25 2.5h1.5v4.75h4.75v1.5H8.75v4.75h-1.5V8.75H2.5v-1.5h4.75z",
|
||||||
bolt: "M9.5 1L3 9.25h4.25L6.5 15 13 6.75H8.75z",
|
bolt: "M9.5 1L3 9.25h4.25L6.5 15 13 6.75H8.75z",
|
||||||
stop: "M4.5 4.5h7v7h-7z",
|
stop: "M4.5 4.5h7v7h-7z",
|
||||||
|
tasks: "M2 3.25h2v2H2zM5.5 3.5H14V5H5.5zM2 7h2v2H2zM5.5 7.25H14v1.5H5.5zM2 10.75h2v2H2zM5.5 11H14v1.5H5.5z",
|
||||||
back: "M10 3.5L5.5 8 10 12.5 11.1 11.4 7.7 8 11.1 4.6z",
|
back: "M10 3.5L5.5 8 10 12.5 11.1 11.4 7.7 8 11.1 4.6z",
|
||||||
terminal: "M1.5 2.5h13v11h-13zM3 4v8h10V4zM4.2 5.4l2.6 2.6-2.6 2.6-1-1L4.8 8 3.2 6.4zM7.5 10h4v1.5h-4z",
|
terminal: "M1.5 2.5h13v11h-13zM3 4v8h10V4zM4.2 5.4l2.6 2.6-2.6 2.6-1-1L4.8 8 3.2 6.4zM7.5 10h4v1.5h-4z",
|
||||||
search: "M7 2a5 5 0 013.95 8.07l3.24 3.24-1.06 1.06-3.24-3.24A5 5 0 117 2zm0 1.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z",
|
search: "M7 2a5 5 0 013.95 8.07l3.24 3.24-1.06 1.06-3.24-3.24A5 5 0 117 2zm0 1.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z",
|
||||||
|
send: "M8 2.25l5.03 5.03-1.06 1.06-3.22-3.22V13.5h-1.5V5.12L4.03 8.34 2.97 7.28z",
|
||||||
|
clock: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M7.25 4.5h1.5v3.19l2.03 2.03-1.06 1.06-2.47-2.47z"],
|
||||||
|
chat: "M2 2.5h12v8.5H7l-3.5 3v-3H2zM3.5 4v5.5h1.5v1.25L6.45 9.5h6.05V4z",
|
||||||
|
bulb: ["M8 1.5a4.5 4.5 0 012.75 8.06v1.94h-5.5V9.56A4.5 4.5 0 018 1.5zM8 3a3 3 0 00-1.83 5.38l.58.45V10h2.5V8.83l.58-.45A3 3 0 008 3z", "M5.75 12.5h4.5V14h-4.5z"],
|
||||||
|
users: ["M6 2.5a2.5 2.5 0 110 5 2.5 2.5 0 010-5zM6 4a1 1 0 100 2 1 1 0 000-2z", "M1.5 13.5c0-2.5 2-4.25 4.5-4.25s4.5 1.75 4.5 4.25H9c0-1.6-1.3-2.75-3-2.75S3 11.9 3 13.5z", "M11 3.5a2 2 0 110 4 2 2 0 010-4z", "M11.75 9.4c1.65.35 2.75 1.85 2.75 4.1H13c0-1.4-.55-2.4-1.6-2.7z"],
|
||||||
|
shield: "M8 1l5.5 2v4.5c0 3.3-2.3 6-5.5 7.5-3.2-1.5-5.5-4.2-5.5-7.5V3zM4 4.05V7.5c0 2.45 1.6 4.6 4 5.85 2.4-1.25 4-3.4 4-5.85V4.05L8 2.6z",
|
||||||
|
link: ["M6.47 8.47l2-2 1.06 1.06-2 2z", "M4.6 7.4l1.06 1.06-1.4 1.4a1.5 1.5 0 002.12 2.12l1.4-1.4 1.06 1.06-1.4 1.4a3 3 0 01-4.24-4.24z", "M11.4 8.6l-1.06-1.06 1.4-1.4a1.5 1.5 0 00-2.12-2.12l-1.4 1.4L7.16 4.36l1.4-1.4a3 3 0 014.24 4.24z"],
|
||||||
|
pause: ["M4.5 3.5H7v9H4.5z", "M9 3.5h2.5v9H9z"],
|
||||||
|
play: "M5 2.75L13 8l-8 5.25z",
|
||||||
|
user: ["M8 2a3 3 0 110 6 3 3 0 010-6zm0 1.5a1.5 1.5 0 100 3 1.5 1.5 0 000-3z", "M2.5 14c0-2.9 2.4-4.75 5.5-4.75S13.5 11.1 13.5 14H12c0-2-1.65-3.25-4-3.25S4 12 4 14z"],
|
||||||
|
check: "M6.5 10.4l6-6 1.06 1.06L6.5 12.53 2.94 8.97 4 7.9z",
|
||||||
|
bell: ["M8 1.5a4.5 4.5 0 014.5 4.5v3l1.5 2.5H2L3.5 9V6A4.5 4.5 0 018 1.5zM8 3a3 3 0 00-3 3v3.42l-.35.58h6.7L11 9.42V6a3 3 0 00-3-3z", "M6.25 12.5h3.5a1.75 1.75 0 01-3.5 0z"],
|
||||||
|
info: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M7.25 7h1.5v4.5h-1.5zM7.25 4.5h1.5V6h-1.5z"],
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "3 min ago"-style, in the system's language, for a stamp the server sent. */
|
/** "3 min ago"-style, in the system's language, for a stamp the server sent. */
|
||||||
|
|||||||
+23
-14
@@ -1,6 +1,6 @@
|
|||||||
// The two explorers, kept apart: this computer's -- the agent's working directory, read through
|
// The two explorers of the Files page, kept apart as two tabs: this computer's -- the agent's
|
||||||
// a command that reaches only the agent's folders (src-tauri/src/files.rs) -- and the server's
|
// folders, read through a command that reaches only them (src-tauri/src/files.rs) -- and the
|
||||||
// tree, for the chats, read through /api/v2. A file opens in the viewer either way.
|
// server's tree, for the chats, read through /api/v2. A file opens in the viewer either way.
|
||||||
|
|
||||||
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
||||||
import { invoke, get, why } from "./bridge.js"
|
import { invoke, get, why } from "./bridge.js"
|
||||||
@@ -84,7 +84,7 @@ function renderLocal() {
|
|||||||
if (!local.roots.length) {
|
if (!local.roots.length) {
|
||||||
clear(box).append(
|
clear(box).append(
|
||||||
h("p", { class: "pane__note", text: t("No project folder yet: the agent can open nothing on this computer.") }),
|
h("p", { class: "pane__note", text: t("No project folder yet: the agent can open nothing on this computer.") }),
|
||||||
h("button", { type: "button", class: "btn btn--sm", text: t("Add folder…"), onclick: () => invoke("remote_add") }),
|
h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") }),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -155,17 +155,26 @@ export async function openServerFile(path, name) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initExplorer() {
|
let tab = "local"
|
||||||
for (const pane of document.querySelectorAll(".pane")) {
|
let serverRead = false
|
||||||
const toggle = pane.querySelector(".pane__toggle")
|
|
||||||
toggle.prepend(h("span", { class: "tree__chevron" }, icon(ICONS.chevron)))
|
/** Show one of the two trees: `local` (this computer) or `server`. The server's is read once. */
|
||||||
toggle.addEventListener("click", () => {
|
export function showFilesTab(which) {
|
||||||
const open = pane.dataset.open !== "true"
|
tab = which === "server" ? "server" : "local"
|
||||||
pane.dataset.open = String(open)
|
for (const b of $("files-tabs").querySelectorAll("button")) b.setAttribute("aria-selected", String(b.dataset.tab === tab))
|
||||||
toggle.setAttribute("aria-expanded", String(open))
|
$("pane-local").hidden = tab !== "local"
|
||||||
$("side").dataset[pane.id === "pane-local" ? "local" : "server"] = String(open)
|
$("pane-server").hidden = tab !== "server"
|
||||||
})
|
if (tab === "server" && !serverRead) {
|
||||||
|
serverRead = true
|
||||||
|
renderServer()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initExplorer() {
|
||||||
|
const tabBtn = (which, ico, label) =>
|
||||||
|
h("button", { type: "button", role: "tab", class: "tabs__tab", dataset: { tab: which }, "aria-selected": String(which === tab), onclick: () => showFilesTab(which) }, icon(ico), h("span", { text: label }))
|
||||||
|
$("files-tabs").replaceChildren(tabBtn("local", ICONS.folder, t("This computer")), tabBtn("server", ICONS.file, t("Server")))
|
||||||
|
$("server-where").textContent = t("Your files on the server, as the chats see them.")
|
||||||
$("server-refresh").replaceChildren(icon(ICONS.refresh))
|
$("server-refresh").replaceChildren(icon(ICONS.refresh))
|
||||||
$("server-refresh").addEventListener("click", renderServer)
|
$("server-refresh").addEventListener("click", renderServer)
|
||||||
$("local-refresh").replaceChildren(icon(ICONS.refresh))
|
$("local-refresh").replaceChildren(icon(ICONS.refresh))
|
||||||
|
|||||||
+79
-33
@@ -1,20 +1,28 @@
|
|||||||
// The sidebar's chats: agent chats (the default -- the agent on this computer is what the app is
|
// The sidebar's chats: agent chats and chats on the server in one list, newest first, under
|
||||||
// for) or chats on the server. Agent chats on this computer come first; those on another device
|
// Today and Earlier. An agent chat carries the agent's mark and its folder's name; one that works
|
||||||
// of the account follow under their own heading.
|
// on another device of the account says so in its title. The search line above narrows the list
|
||||||
|
// by title (and an agent chat by its folder) as it is typed; Ctrl+K searches everything.
|
||||||
|
|
||||||
import { $, h, clear, when, append } from "./dom.js"
|
import { $, h, clear, append, icon, ICONS } from "./dom.js"
|
||||||
import { get, why } from "./bridge.js"
|
import { get, why } from "./bridge.js"
|
||||||
|
|
||||||
let ctx = null // { kind(), thisDevices(), open(id), current(), menu(anchor, chat) }
|
let ctx = null // { thisDevices(), open(id, kind), current(), menu(anchor, chat) }
|
||||||
let rows = []
|
let rows = []
|
||||||
let more = false
|
let more = false
|
||||||
|
let query = ""
|
||||||
|
|
||||||
|
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
|
||||||
|
function fold(text) {
|
||||||
|
return String(text || "")
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/\p{M}+/gu, "")
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
/** The list again, from the server. */
|
/** The list again, from the server. */
|
||||||
export async function refreshList() {
|
export async function refreshList() {
|
||||||
const kind = ctx.kind()
|
|
||||||
try {
|
try {
|
||||||
const r = await get(`/chats?kind=${kind}&limit=50`)
|
const r = await get("/chats?limit=50")
|
||||||
if (ctx.kind() !== kind) return
|
|
||||||
rows = r.chats || []
|
rows = r.chats || []
|
||||||
more = !!r.more
|
more = !!r.more
|
||||||
render()
|
render()
|
||||||
@@ -26,9 +34,7 @@ export async function refreshList() {
|
|||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
const last = rows[rows.length - 1]
|
const last = rows[rows.length - 1]
|
||||||
if (!last) return
|
if (!last) return
|
||||||
const kind = ctx.kind()
|
const r = await get(`/chats?limit=50&before=${encodeURIComponent(last.id)}`)
|
||||||
const r = await get(`/chats?kind=${kind}&limit=50&before=${encodeURIComponent(last.id)}`)
|
|
||||||
if (ctx.kind() !== kind) return
|
|
||||||
rows = rows.concat(r.chats || [])
|
rows = rows.concat(r.chats || [])
|
||||||
more = !!r.more
|
more = !!r.more
|
||||||
render()
|
render()
|
||||||
@@ -39,16 +45,30 @@ export function markCurrent() {
|
|||||||
for (const b of $("chat-list").querySelectorAll(".chatrow")) b.setAttribute("aria-current", String(b.dataset.id === id))
|
for (const b of $("chat-list").querySelectorAll(".chatrow")) b.setAttribute("aria-current", String(b.dataset.id === id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The folder's last part: what tells one project from another at a glance. */
|
||||||
|
export function folderName(dir) {
|
||||||
|
const parts = String(dir || "").replace(/\/+$/, "").split("/")
|
||||||
|
return parts[parts.length - 1] || dir || ""
|
||||||
|
}
|
||||||
|
|
||||||
function row(c) {
|
function row(c) {
|
||||||
|
const agent = c.kind === "agent"
|
||||||
|
const elsewhere = agent && c.device && !ctx.thisDevices().includes(c.device.token_id)
|
||||||
|
const title = c.title || t("New chat")
|
||||||
const b = h(
|
const b = h(
|
||||||
"button",
|
"button",
|
||||||
{ type: "button", class: `chatrow${c.unread ? " is-unread" : ""}`, dataset: { id: c.id }, title: c.title || "" },
|
{
|
||||||
h("span", { class: `dot${c.running ? " is-busy" : c.unread ? " is-new" : ""}`, "aria-hidden": "true" }),
|
type: "button",
|
||||||
h("span", { class: "row__title", text: c.title || t("New chat") }),
|
class: `chatrow${c.unread ? " is-unread" : ""}`,
|
||||||
h("span", { class: "row__when", text: when(c.updated_at) }),
|
dataset: { id: c.id, kind: c.kind },
|
||||||
c.device?.directory ? h("span", { class: "row__sub", title: c.device.directory }, h("bdi", { text: c.device.directory })) : null,
|
title: agent && c.device?.directory ? `${title}\n${c.device.directory}${elsewhere ? ` · ${t("Another device")}` : ""}` : title,
|
||||||
|
},
|
||||||
|
h("span", { class: `chatrow__icon${agent ? " is-agent" : ""}` }, icon(agent ? ICONS.terminal : ICONS.chat)),
|
||||||
|
h("span", { class: "chatrow__title", text: title }),
|
||||||
|
agent && c.device?.directory ? h("span", { class: "chatrow__where", text: folderName(c.device.directory) }) : h("span", { class: "chatrow__where" }),
|
||||||
|
h("span", { class: `chatrow__state${c.running ? " is-busy" : c.unread ? " is-new" : ""}`, "aria-hidden": "true" }),
|
||||||
)
|
)
|
||||||
b.addEventListener("click", () => ctx.open(c.id))
|
b.addEventListener("click", () => ctx.open(c.id, c.kind))
|
||||||
// The chat's own menu (rename, archive, delete…) on a right click, or the context-menu key.
|
// The chat's own menu (rename, archive, delete…) on a right click, or the context-menu key.
|
||||||
b.addEventListener("contextmenu", (e) => {
|
b.addEventListener("contextmenu", (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -57,30 +77,56 @@ function row(c) {
|
|||||||
return h("li", null, b)
|
return h("li", null, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isToday(stamp) {
|
||||||
|
const d = new Date(stamp)
|
||||||
|
if (Number.isNaN(d.getTime())) return false
|
||||||
|
const now = new Date()
|
||||||
|
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const list = clear($("chat-list"))
|
const list = clear($("chat-list"))
|
||||||
const kind = ctx.kind()
|
const words = fold(query).split(/\s+/).filter(Boolean)
|
||||||
const visible = rows.filter((r) => !r.archived && !r.temporary)
|
const visible = rows
|
||||||
|
.filter((r) => !r.archived && !r.temporary)
|
||||||
|
.filter((r) => {
|
||||||
|
if (!words.length) return true
|
||||||
|
const text = fold(`${r.title || t("New chat")} ${r.device?.directory ? folderName(r.device.directory) : ""}`)
|
||||||
|
return words.every((w) => text.includes(w))
|
||||||
|
})
|
||||||
if (!visible.length) {
|
if (!visible.length) {
|
||||||
list.append(
|
list.append(h("li", { class: "list__note", text: words.length ? t("Nothing matches.") : t("No chat yet. Start one with New chat.") }))
|
||||||
h("li", {
|
|
||||||
class: "list__note",
|
|
||||||
text: kind === "agent" ? t("No agent chat yet. Start one: it works in a folder on this computer.") : t("No chat yet."),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (kind === "agent") {
|
const today = visible.filter((r) => isToday(r.updated_at))
|
||||||
const mine = ctx.thisDevices()
|
const earlier = visible.filter((r) => !isToday(r.updated_at))
|
||||||
const here = visible.filter((r) => r.device && mine.includes(r.device.token_id))
|
if (today.length) append(list, [h("li", { class: "list__head", text: t("Today") }), today.map(row)])
|
||||||
const there = visible.filter((r) => !(r.device && mine.includes(r.device.token_id)))
|
if (earlier.length) append(list, [h("li", { class: "list__head", text: t("Earlier") }), earlier.map(row)])
|
||||||
if (here.length) append(list, [h("li", { class: "list__head", text: t("This computer") }), here.map(row)])
|
if (more && !words.length) list.append(h("li", null, h("button", { type: "button", class: "btn btn--sm list__more", text: t("More"), onclick: loadMore })))
|
||||||
if (there.length) append(list, [h("li", { class: "list__head", text: t("Other devices") }), there.map(row)])
|
|
||||||
} else append(list, visible.map(row))
|
|
||||||
if (more) list.append(h("li", null, h("button", { type: "button", class: "btn btn--sm list__more", text: t("More"), onclick: loadMore })))
|
|
||||||
markCurrent()
|
markCurrent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The chats as the list has them (the palette's and the sidebar's first look). */
|
||||||
|
export function listed() {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
export function initList(context) {
|
export function initList(context) {
|
||||||
ctx = context
|
ctx = context
|
||||||
|
$("side-search-icon").replaceChildren(icon(ICONS.search))
|
||||||
|
$("side-search").addEventListener("input", (e) => {
|
||||||
|
query = e.target.value
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
$("side-search").addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && e.target.value) {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.target.value = ""
|
||||||
|
query = ""
|
||||||
|
render()
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
// The first match opens.
|
||||||
|
$("chat-list").querySelector(".chatrow")?.click()
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
+159
-73
@@ -1,30 +1,38 @@
|
|||||||
// The app's page: which view (the setup card, or the app), and the app itself -- the sidebar
|
// The app's page: which view (the setup card, or the app), and the app itself -- the sidebar (New
|
||||||
// (agent chats by default, chats on the server, and the two explorers), the chat, the viewer and
|
// chat, search, Scheduled, Files, the chats, Settings), and in the main area one of: a chat (a new
|
||||||
// the settings. Everything it knows comes from Rust: `status` (the app, the agent, the server) and
|
// one is the same page, empty), the files, the scheduled tasks, or the settings; the activity
|
||||||
// `api` (the server's /api/v2, as the signed-in account).
|
// panel and the viewer beside it. Everything it knows comes from Rust: `status` (the app, the
|
||||||
|
// agent, the server) and `api` (the server's /api/v2, as the signed-in account).
|
||||||
|
|
||||||
import { $, h, clear, icon, ICONS } from "./dom.js"
|
import { $, h, icon, ICONS } from "./dom.js"
|
||||||
import { invoke, listen, get, why } from "./bridge.js"
|
import { invoke, listen, get, why } from "./bridge.js"
|
||||||
import { renderBar, initBar } from "./titlebar.js"
|
import { renderBar, initBar } from "./titlebar.js"
|
||||||
import { renderSetup, initSetup } from "./setup.js"
|
import { renderSetup, initSetup } from "./setup.js"
|
||||||
import { renderSettings, initSettings } from "./settings.js"
|
import { renderSettings, initSettings, scrollToSection } from "./settings.js"
|
||||||
import { initViewer, onViewer, closeViewer } from "./viewer.js"
|
import { initViewer, onViewer, closeViewer } from "./viewer.js"
|
||||||
import { initExplorer, showLocal, renderServer } from "./explorer.js"
|
import { initExplorer, showLocal, showFilesTab } from "./explorer.js"
|
||||||
import { initList, refreshList, markCurrent } from "./list.js"
|
import { initList, refreshList, markCurrent } from "./list.js"
|
||||||
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu } from "./chat.js"
|
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged, intoComposer } from "./chat.js"
|
||||||
|
import { initTerminal, showTerminal, terminalOpen, terminalAvailable, toggleTerminal, terminalThemeChanged } from "./terminal.js"
|
||||||
import { initAttach, allowAttach } from "./attach.js"
|
import { initAttach, allowAttach } from "./attach.js"
|
||||||
import { initPalette, openPalette } from "./palette.js"
|
import { initPalette, openPalette } from "./palette.js"
|
||||||
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
|
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
|
||||||
|
import { initAgent, link } from "./agent.js"
|
||||||
|
import { initScheduled, showScheduled, scheduledCommands } from "./scheduled.js"
|
||||||
|
import { closePopover } from "./popup.js"
|
||||||
|
import { noteSubagentsCap } from "./models.js"
|
||||||
|
|
||||||
let status = null
|
let status = null
|
||||||
let view = "" // "setup" | "app"
|
let view = "" // "setup" | "app"
|
||||||
|
let page = "chat" // "chat" | "files" | "scheduled" | "settings"
|
||||||
let me = null
|
let me = null
|
||||||
let models = []
|
let models = []
|
||||||
let devices = [] // this account's linked devices
|
let devices = [] // this account's linked devices
|
||||||
let kind = "agent"
|
|
||||||
let started = false
|
let started = false
|
||||||
let currentChat = null
|
let currentChat = null
|
||||||
|
|
||||||
|
const SIDE_KEY = "lembas.side"
|
||||||
|
|
||||||
const remembered = (() => {
|
const remembered = (() => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.getItem("lembas.thisDevices") || "[]")
|
return JSON.parse(localStorage.getItem("lembas.thisDevices") || "[]")
|
||||||
@@ -60,6 +68,7 @@ function applyTheme(name) {
|
|||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
root.dataset.theme = name
|
root.dataset.theme = name
|
||||||
root.dataset.base = name
|
root.dataset.base = name
|
||||||
|
terminalThemeChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Views ------------------------------------------------------------------------------------
|
// --- Views ------------------------------------------------------------------------------------
|
||||||
@@ -71,13 +80,17 @@ function route() {
|
|||||||
view = want
|
view = want
|
||||||
$("setup").hidden = want !== "setup"
|
$("setup").hidden = want !== "setup"
|
||||||
$("shell").hidden = want !== "app"
|
$("shell").hidden = want !== "app"
|
||||||
|
// The sidebar's button keeps its place in the title bar, unseen while there is no sidebar.
|
||||||
|
$("side-toggle").style.visibility = want === "app" ? "" : "hidden"
|
||||||
if (want === "app") startApp()
|
if (want === "app") startApp()
|
||||||
else stopApp()
|
else stopApp()
|
||||||
}
|
}
|
||||||
if (view === "setup") renderSetup(status)
|
if (view === "setup") renderSetup(status)
|
||||||
if (view === "app") {
|
if (view === "app") {
|
||||||
if (!$("settings").hidden) renderSettings(status)
|
if (page === "settings") renderSettings(status)
|
||||||
localPane()
|
localPane()
|
||||||
|
statusChanged()
|
||||||
|
accountButton()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,10 +105,11 @@ async function startApp() {
|
|||||||
$("thread-note").hidden = false
|
$("thread-note").hidden = false
|
||||||
}
|
}
|
||||||
await Promise.all([loadModels(), loadDevices()])
|
await Promise.all([loadModels(), loadDevices()])
|
||||||
$("new-chat-btn").hidden = me?.permissions && me.permissions["chat.create"] === false
|
$("new-chat-btn").disabled = me?.permissions && me.permissions["chat.create"] === false
|
||||||
allowAttach(me?.permissions?.["files.upload"] !== false)
|
allowAttach(me?.permissions?.["files.upload"] !== false)
|
||||||
setKind(kind)
|
accountButton()
|
||||||
renderServer()
|
refreshList()
|
||||||
|
if (!openChatId()) startNew()
|
||||||
invoke("remote_refresh")
|
invoke("remote_refresh")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,11 +119,17 @@ function stopApp() {
|
|||||||
closeViewer()
|
closeViewer()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadModels() {
|
let modelsAt = 0 // when /models was last read
|
||||||
|
|
||||||
|
/** The account's models, with what a new chat on each is offered. `keep`: a failed read keeps the last list. */
|
||||||
|
async function loadModels({ keep = false } = {}) {
|
||||||
try {
|
try {
|
||||||
models = (await get("/models")).models || []
|
const r = await get("/models")
|
||||||
|
models = r.models || []
|
||||||
|
noteSubagentsCap(r.subagents_cap)
|
||||||
|
modelsAt = Date.now()
|
||||||
} catch {
|
} catch {
|
||||||
models = []
|
if (!keep) models = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,61 +141,95 @@ async function loadDevices() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- The sidebar --------------------------------------------------------------------------------
|
/** The sidebar's foot: Settings, with the account it is signed in as. */
|
||||||
|
function accountButton() {
|
||||||
function setKind(k) {
|
const who = status?.account?.name || me?.name || status?.account?.email || ""
|
||||||
kind = k
|
const initial = (who.trim()[0] || "?").toUpperCase()
|
||||||
for (const b of document.querySelectorAll(".switch button")) b.setAttribute("aria-selected", String(b.dataset.kind === k))
|
$("settings-btn").replaceChildren(
|
||||||
$("new-chat-btn").lastChild.textContent = k === "agent" ? t("New agent chat") : t("New chat")
|
h("span", { class: "navrow__avatar", "aria-hidden": "true", text: initial }),
|
||||||
clear($("chat-list"))
|
h("span", { class: "navrow__two" }, h("span", { class: "navrow__label", text: t("Settings") }), who ? h("span", { class: "navrow__sub", text: who }) : null),
|
||||||
refreshList()
|
h("span", { class: "navrow__icon" }, icon(ICONS.gear)),
|
||||||
// With no chat open, the main view is where a chat of this kind starts.
|
)
|
||||||
if (!openChatId() && $("settings").hidden) {
|
|
||||||
currentChat = null
|
|
||||||
newChat(k)
|
|
||||||
localPane()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** This computer's pane: the open agent chat's folder when it runs here, else a folder chosen. */
|
// --- The pages ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Show one page in the main area: the chat, the files, the scheduled tasks, or the settings. */
|
||||||
|
function showPage(name) {
|
||||||
|
page = name
|
||||||
|
closePopover()
|
||||||
|
$("shell").dataset.view = name
|
||||||
|
$("main").hidden = name !== "chat"
|
||||||
|
$("page-files").hidden = name !== "files"
|
||||||
|
$("page-scheduled").hidden = name !== "scheduled"
|
||||||
|
$("settings").hidden = name !== "settings"
|
||||||
|
for (const b of document.querySelectorAll(".navrow[data-view]")) b.setAttribute("aria-current", String(b.dataset.view === name))
|
||||||
|
if (name !== "chat") {
|
||||||
|
showPanel(false)
|
||||||
|
showTerminal(false)
|
||||||
|
}
|
||||||
|
if (narrow()) sideOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This computer's tree: the open agent chat's folder when it runs here, else the one chosen. */
|
||||||
function localPane(chat) {
|
function localPane(chat) {
|
||||||
const roots = status?.remote?.enabled ? status.remote.roots : []
|
const roots = status?.remote?.enabled ? status.remote.roots : []
|
||||||
const c = chat || currentChat
|
const c = chat || currentChat
|
||||||
const here = c?.device && thisDevices().includes(c.device.token_id)
|
const here = c?.device && thisDevices().includes(c.device.token_id)
|
||||||
if (here && c.device.directory) showLocal({ roots, root: c.device.directory, fixed: true })
|
showLocal({ roots, root: here && roots.includes(c.device.directory) ? c.device.directory : "", fixed: false })
|
||||||
else showLocal({ roots, root: "", fixed: false })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A new chat of `k`'s kind, in the main view. */
|
/** A new chat in the main view, of the kind chosen last (an agent chat, at first). */
|
||||||
function startNew(k) {
|
function startNew(kind) {
|
||||||
showSettings(false)
|
showPage("chat")
|
||||||
if (k !== kind) setKind(k)
|
|
||||||
currentChat = null
|
currentChat = null
|
||||||
if (narrow()) sideOpen(false)
|
newChat(kind || draftKind())
|
||||||
newChat(k)
|
|
||||||
markCurrent()
|
markCurrent()
|
||||||
localPane()
|
localPane()
|
||||||
|
// What a new chat is offered (its models, Subagents) is the server's word now, not at sign-in:
|
||||||
|
// an administrator may have switched subagents on or added a model while the app stayed open in
|
||||||
|
// the tray. Drawn at once from the last list, then again from the fresh one.
|
||||||
|
if (started && Date.now() - modelsAt > 5000) loadModels({ keep: true }).then(modelsChanged)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open a chat, from the list or the palette: its kind's tab comes with it. */
|
/** Open a chat, from the list or the palette. */
|
||||||
function showChat(id, k) {
|
function showChat(id) {
|
||||||
showSettings(false)
|
showPage("chat")
|
||||||
if (k && k !== kind) setKind(k)
|
|
||||||
if (narrow()) sideOpen(false)
|
|
||||||
openChat(id).then(markCurrent)
|
openChat(id).then(markCurrent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showSettings(on, section) {
|
||||||
|
if (!on) {
|
||||||
|
showPage("chat")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showPage("settings")
|
||||||
|
renderSettings(status)
|
||||||
|
invoke("remote_refresh")
|
||||||
|
if (section) scrollToSection(section)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFiles(which) {
|
||||||
|
showPage("files")
|
||||||
|
if (which) showFilesTab(which)
|
||||||
|
invoke("remote_refresh")
|
||||||
|
}
|
||||||
|
|
||||||
/** The palette's commands: what the menus and buttons do, by name. */
|
/** The palette's commands: what the menus and buttons do, by name. */
|
||||||
function commands() {
|
function commands() {
|
||||||
const out = [
|
const out = [
|
||||||
{ label: t("New agent chat"), run: () => startNew("agent") },
|
{ label: t("New agent chat"), run: () => startNew("agent") },
|
||||||
{ label: t("New chat"), run: () => startNew("chat") },
|
{ label: t("New chat"), run: () => startNew("chat") },
|
||||||
|
{ label: t("Scheduled"), run: () => (showPage("scheduled"), showScheduled()) },
|
||||||
|
...scheduledCommands(),
|
||||||
|
{ label: t("Files"), run: () => showFiles() },
|
||||||
{ label: t("Settings"), run: () => showSettings(true) },
|
{ label: t("Settings"), run: () => showSettings(true) },
|
||||||
]
|
]
|
||||||
if (openChatId()) out.push({ label: panelOpen() ? t("Close the activity panel") : t("Open the activity panel"), run: togglePanel })
|
if (openChatId()) out.push({ label: panelOpen() ? t("Close the activity panel") : t("Open the activity panel"), run: togglePanel })
|
||||||
|
if (terminalAvailable()) out.push({ label: terminalOpen() ? t("Close terminal") : t("Terminal"), run: toggleTerminal })
|
||||||
if (status?.linked) out.push({ label: status.agent_wanted ? t("Stop the agent") : t("Start the agent"), run: () => invoke("set_agent", { on: !status.agent_wanted }) })
|
if (status?.linked) out.push({ label: status.agent_wanted ? t("Stop the agent") : t("Start the agent"), run: () => invoke("set_agent", { on: !status.agent_wanted }) })
|
||||||
out.push(
|
out.push(
|
||||||
{ label: status?.linked ? t("Link again") : t("Link this computer"), run: () => invoke("link_computer").catch(() => showSettings(true)) },
|
{ label: status?.linked ? t("Link again") : t("Link this computer"), run: link },
|
||||||
{ label: status?.update ? t("Install LLeMbas %(version)s…", { version: status.update }) : t("Check for updates…"), run: () => invoke("update_app") },
|
{ label: status?.update ? t("Install LLeMbas %(version)s…", { version: status.update }) : t("Check for updates…"), run: () => invoke("update_app") },
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
@@ -185,42 +239,39 @@ function narrow() {
|
|||||||
return window.matchMedia("(max-width: 48rem)").matches
|
return window.matchMedia("(max-width: 48rem)").matches
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The sidebar: shown or folded away (remembered); a drawer over the page on a narrow window. */
|
||||||
function sideOpen(on) {
|
function sideOpen(on) {
|
||||||
$("shell").dataset.side = String(on)
|
$("shell").dataset.side = String(on)
|
||||||
$("side-toggle").setAttribute("aria-expanded", String(on))
|
$("side-toggle").setAttribute("aria-expanded", String(on))
|
||||||
}
|
if (!narrow()) {
|
||||||
|
try {
|
||||||
// --- Settings -----------------------------------------------------------------------------------
|
localStorage.setItem(SIDE_KEY, on ? "open" : "closed")
|
||||||
|
} catch {}
|
||||||
function showSettings(on) {
|
|
||||||
if (on) showPanel(false)
|
|
||||||
$("settings").hidden = !on
|
|
||||||
$("main").hidden = on
|
|
||||||
if (on) {
|
|
||||||
renderSettings(status)
|
|
||||||
invoke("remote_refresh")
|
|
||||||
$("settings-close").focus()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Start --------------------------------------------------------------------------------------
|
// --- Start --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
initBar()
|
initAgent()
|
||||||
|
initBar({ status: () => status, openSettings: (section) => showSettings(true, section) })
|
||||||
initViewer()
|
initViewer()
|
||||||
initExplorer()
|
initExplorer()
|
||||||
initSetup(() => status)
|
initSetup(() => status)
|
||||||
initSettings(() => status)
|
initSettings(() => status)
|
||||||
initList({
|
initList({
|
||||||
kind: () => kind,
|
|
||||||
thisDevices,
|
thisDevices,
|
||||||
current: () => openChatId(),
|
current: () => openChatId(),
|
||||||
open: (id) => showChat(id),
|
open: (id) => {
|
||||||
|
if (narrow()) sideOpen(false)
|
||||||
|
showChat(id)
|
||||||
|
},
|
||||||
menu: (anchor, c) => chatMenu(anchor, c),
|
menu: (anchor, c) => chatMenu(anchor, c),
|
||||||
})
|
})
|
||||||
initChat({
|
initChat({
|
||||||
status: () => status,
|
status: () => status,
|
||||||
thisDevices,
|
thisDevices,
|
||||||
|
devices: () => devices,
|
||||||
models: () => models,
|
models: () => models,
|
||||||
me: () => me,
|
me: () => me,
|
||||||
changed: () => refreshList(),
|
changed: () => refreshList(),
|
||||||
@@ -233,57 +284,92 @@ async function boot() {
|
|||||||
gone: (chat) => {
|
gone: (chat) => {
|
||||||
if (!openChatId() || openChatId() === chat.id) {
|
if (!openChatId() || openChatId() === chat.id) {
|
||||||
currentChat = null
|
currentChat = null
|
||||||
newChat(kind)
|
newChat()
|
||||||
localPane()
|
localPane()
|
||||||
}
|
}
|
||||||
refreshList()
|
refreshList()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false })
|
initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false })
|
||||||
initPalette({ commands, open: (c) => showChat(c.id, c.kind === "agent" ? "agent" : "chat") })
|
initPalette({ commands, open: (c) => showChat(c.id) })
|
||||||
|
initScheduled({ status: () => status, models: () => models, me: () => me, openChat: (id) => showChat(id), show: () => showPage("scheduled") })
|
||||||
onViewer((on) => {
|
onViewer((on) => {
|
||||||
$("shell").dataset.viewer = String(on)
|
$("shell").dataset.viewer = String(on)
|
||||||
})
|
})
|
||||||
|
// One panel column: the activity panel and the terminal take turns in it (the shell stays open).
|
||||||
|
initTerminal({
|
||||||
|
onLayout: (on) => {
|
||||||
|
$("shell").dataset.terminal = String(on)
|
||||||
|
if (on && panelOpen()) showPanel(false)
|
||||||
|
},
|
||||||
|
composer: intoComposer,
|
||||||
|
})
|
||||||
initActivity({
|
initActivity({
|
||||||
onLayout: (on) => {
|
onLayout: (on) => {
|
||||||
$("shell").dataset.activity = String(on)
|
$("shell").dataset.activity = String(on)
|
||||||
|
if (on && terminalOpen()) showTerminal(false)
|
||||||
},
|
},
|
||||||
|
here: onThisComputer,
|
||||||
|
// The conversation as it now stands: the summary's divider, read again, if it is still open.
|
||||||
|
compacted: (id) => openChatId() === id && openChat(id),
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const b of document.querySelectorAll(".switch button")) b.addEventListener("click", () => setKind(b.dataset.kind))
|
|
||||||
$("new-chat-btn").prepend(icon(ICONS.plus))
|
$("new-chat-btn").prepend(icon(ICONS.plus))
|
||||||
$("new-chat-btn").addEventListener("click", () => startNew(kind))
|
$("new-chat-btn").addEventListener("click", () => {
|
||||||
$("settings-btn").replaceChildren(icon(ICONS.gear), h("span", { text: t("Settings") }))
|
if (narrow()) sideOpen(false)
|
||||||
$("settings-btn").addEventListener("click", () => showSettings($("settings").hidden))
|
startNew()
|
||||||
|
})
|
||||||
|
$("nav-scheduled").querySelector(".navrow__icon").replaceChildren(icon(ICONS.clock))
|
||||||
|
$("nav-scheduled").addEventListener("click", () => {
|
||||||
|
showPage("scheduled")
|
||||||
|
showScheduled()
|
||||||
|
})
|
||||||
|
$("nav-files").querySelector(".navrow__icon").replaceChildren(icon(ICONS.folder))
|
||||||
|
$("nav-files").addEventListener("click", () => showFiles())
|
||||||
|
accountButton()
|
||||||
|
$("settings-btn").addEventListener("click", () => showSettings(page !== "settings"))
|
||||||
$("settings-close").replaceChildren(icon(ICONS.close))
|
$("settings-close").replaceChildren(icon(ICONS.close))
|
||||||
$("settings-close").addEventListener("click", () => showSettings(false))
|
$("settings-close").addEventListener("click", () => showSettings(false))
|
||||||
$("side-toggle").addEventListener("click", () => sideOpen($("shell").dataset.side !== "true"))
|
$("side-toggle").addEventListener("click", () => sideOpen($("shell").dataset.side !== "true"))
|
||||||
$("scrim").addEventListener("click", () => sideOpen(false))
|
$("scrim").addEventListener("click", () => sideOpen(false))
|
||||||
|
// A wide window starts with the sidebar as it was left; a narrow one with it closed.
|
||||||
|
let saved = "open"
|
||||||
|
try {
|
||||||
|
saved = localStorage.getItem(SIDE_KEY) || "open"
|
||||||
|
} catch {}
|
||||||
|
sideOpen(!narrow() && saved !== "closed")
|
||||||
|
window.matchMedia("(max-width: 48rem)").addEventListener("change", (e) => {
|
||||||
|
if (e.matches) sideOpen(false)
|
||||||
|
})
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape" && $("shell").dataset.side === "true") sideOpen(false)
|
if (e.key === "Escape" && narrow() && $("shell").dataset.side === "true") sideOpen(false)
|
||||||
if (view !== "app" || document.querySelector("dialog[open]")) return
|
if (view !== "app" || document.querySelector("dialog[open]")) return
|
||||||
const mod = e.ctrlKey || e.metaKey
|
const mod = e.ctrlKey || e.metaKey
|
||||||
// Ctrl+K: the palette. Ctrl+N: a new chat of the tab's kind. Ctrl+,: the settings.
|
const key = e.key.toLowerCase()
|
||||||
if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "k") {
|
// Ctrl+K: the palette. Ctrl+N: a new chat. Ctrl+,: the settings. Ctrl+B: the sidebar.
|
||||||
|
if (mod && !e.shiftKey && !e.altKey && key === "k") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
openPalette()
|
openPalette()
|
||||||
} else if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "n") {
|
} else if (mod && !e.shiftKey && !e.altKey && key === "n") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
startNew(kind)
|
startNew()
|
||||||
} else if (mod && e.key === ",") {
|
} else if (mod && e.key === ",") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
showSettings(true)
|
showSettings(true)
|
||||||
|
} else if (mod && !e.shiftKey && !e.altKey && key === "b") {
|
||||||
|
e.preventDefault()
|
||||||
|
sideOpen($("shell").dataset.side !== "true")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
invoke("set_words", { words: { reply: t("The reply is ready."), agent: t("The agent has finished.") } })
|
invoke("set_words", { words: { reply: t("The reply is ready."), agent: t("The agent has finished."), scheduled: t("A scheduled task has a result."), scheduled_failed: t("A scheduled task failed."), scheduled_pause: t("A scheduled task failed 3 times in a row. Pause it?") } })
|
||||||
listen("status", (e) => {
|
listen("status", (e) => {
|
||||||
const before = status
|
const before = status
|
||||||
status = e.payload
|
status = e.payload
|
||||||
renderBar(status)
|
renderBar(status)
|
||||||
route()
|
route()
|
||||||
// The agent came up or went: which devices are linked changes with it.
|
// The agent came up or went: which devices are linked changes with it.
|
||||||
if (started && before && before.agent.state !== status.agent.state) loadDevices().then(refreshList)
|
if (started && before && before.agent.state !== status.agent.state) loadDevices().then(() => (refreshList(), statusChanged()))
|
||||||
})
|
})
|
||||||
listen("open-settings", () => {
|
listen("open-settings", () => {
|
||||||
if (view === "app") showSettings(true)
|
if (view === "app") showSettings(true)
|
||||||
|
|||||||
+93
-42
@@ -14,7 +14,7 @@ import { menu } from "./popup.js"
|
|||||||
// so a missing picture is not asked for again with every redraw).
|
// so a missing picture is not asked for again with every redraw).
|
||||||
const pictures = new Map()
|
const pictures = new Map()
|
||||||
|
|
||||||
function picture(path) {
|
export function picture(path) {
|
||||||
if (!pictures.has(path)) pictures.set(path, invoke("model_image", { path }).catch(() => null))
|
if (!pictures.has(path)) pictures.set(path, invoke("model_image", { path }).catch(() => null))
|
||||||
return pictures.get(path)
|
return pictures.get(path)
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ export function modelPicker({ id, models, value, onChange, label }) {
|
|||||||
return btn
|
return btn
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- How hard a model thinks, and how many agents a chat may have --------------------------------
|
// --- How hard a model thinks, and how many subagents a chat may have --------------------------------
|
||||||
//
|
//
|
||||||
// The levels are the harness spec's (3.2.0, `models/schema.json`), as /api/v2/models gives them:
|
// The levels are the harness spec's (3.2.0, `models/schema.json`), as /api/v2/models gives them:
|
||||||
// `levels` in offering order (`off` first where the model can switch thinking off, then its
|
// `levels` in offering order (`off` first where the model can switch thinking off, then its
|
||||||
@@ -193,69 +193,120 @@ export function effortControl({ model, value, onChange, id, compact }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a new chat on `model` may have a team: a chat on the server (an agent chat's team runs
|
* Whether a new chat on `model` is offered subagents: the model's `subagents.offered` in `/models`
|
||||||
* on its device), a model that calls tools and serves two requests at once (`parallel` null is a
|
* (server 2.0.0-beta.7: subagents on for the model and the instance, and at least one model could
|
||||||
* hosted API with no stated limit). The server decides again; this only keeps the fields away
|
* serve as one -- the model itself with room for two requests at once, or a usable model outside
|
||||||
* where they could never apply. An open chat asks the server (`GET /chats/{chat}/team`).
|
* its one-model-at-a-time server). A server that does not say it: a model that calls tools and
|
||||||
|
* serves two requests at once (`parallel` null is a hosted API with no stated limit). The server
|
||||||
|
* decides again; this only keeps the chip away where it could never apply. An open chat has its
|
||||||
|
* own `subagents` (chat.js).
|
||||||
*/
|
*/
|
||||||
export function teamPossible(model) {
|
export function teamPossible(model) {
|
||||||
|
const said = model?.subagents
|
||||||
|
if (said !== undefined && said !== null) return typeof said === "object" ? !!said.offered : !!said
|
||||||
return !!model?.tools && (model.parallel === null || model.parallel === undefined || model.parallel >= 2)
|
return !!model?.tools && (model.parallel === null || model.parallel === undefined || model.parallel >= 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether only a model the person names in the chat could serve its subagents (`named_only`). */
|
||||||
|
export function namedOnly(said) {
|
||||||
|
return !!(said && typeof said === "object" && said.named_only)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The instance's most, as `/models` said it last (`subagents_cap`); 32 where it says nothing.
|
||||||
|
export const SUBAGENTS_CAP = 32
|
||||||
|
let knownCap = null
|
||||||
|
export function noteSubagentsCap(n) {
|
||||||
|
knownCap = Number(n) >= 1 ? Number(n) : null
|
||||||
|
}
|
||||||
|
/** The most subagents a chat may have: the first of `sources` that says it (`cap`), else the instance's. */
|
||||||
|
export function subagentsCap(...sources) {
|
||||||
|
for (const s of sources) {
|
||||||
|
const n = Number(s?.cap)
|
||||||
|
if (Number.isFinite(n) && n >= 1) return n
|
||||||
|
}
|
||||||
|
return knownCap || SUBAGENTS_CAP
|
||||||
|
}
|
||||||
|
// The numbers count the chat's own agent: 1 is the agent alone.
|
||||||
|
export const SUBAGENTS_HINT = () => t("The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.")
|
||||||
|
// The spec's `subagents.setting.own`: beside the setting where the chat's model cannot serve them.
|
||||||
|
export const SUBAGENTS_OWN = () => t("This model serves one request at a time: subagents need another model, on another server, named in the chat.")
|
||||||
|
|
||||||
|
// A new chat's setting as the harness spec seeds it (`loop.json` agents): 1 to 4.
|
||||||
|
export const AGENTS_DEFAULT = { min: 1, max: 4 }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The team's size: the fewest and the most agents, the chat's own agent included, as two number
|
* The agents a chat may run, its own agent counted: one range from 1 to `cap` with two handles,
|
||||||
* fields on one shared track. `value` is { min, max } (empty fields for a new chat: the person's
|
* Min and Max -- two range inputs overlaid on one track, the span between them filled. Each is a
|
||||||
* default for new chats then stands); `onChange({ min, max })` once a field is changed, with the
|
* real slider (arrow keys, Home/End, Page Up/Down), and Min never passes Max. `value` is
|
||||||
* empty one left out. The server checks the pair and says the bounds when it refuses one.
|
* { min, max } (empty for a new chat: the spec's defaults are shown, and left to the server
|
||||||
|
* unless a handle moves); `el.value` the pair. The server checks it again.
|
||||||
*/
|
*/
|
||||||
export function agentsControl({ value, onChange, id, compact }) {
|
export function agentsControl({ value, onChange, id, cap = SUBAGENTS_CAP }) {
|
||||||
const field = (which, v, label) =>
|
const top = Math.max(1, Number(cap) || SUBAGENTS_CAP)
|
||||||
|
const clamp = (n, d) => Math.min(top, Math.max(1, Number.isFinite(Number(n)) && n !== null && n !== "" ? Number(n) : d))
|
||||||
|
let given = value || {}
|
||||||
|
let touched = false
|
||||||
|
const handle = (which, label) =>
|
||||||
h("input", {
|
h("input", {
|
||||||
type: "number",
|
type: "range",
|
||||||
class: "agents__input",
|
class: `range2__input range2__input--${which}`,
|
||||||
id: which === "min" ? id : null,
|
id: which === "min" ? id : null,
|
||||||
min: "1",
|
min: "1",
|
||||||
|
max: String(top),
|
||||||
step: "1",
|
step: "1",
|
||||||
inputmode: "numeric",
|
|
||||||
value: v === undefined || v === null ? "" : String(v),
|
|
||||||
placeholder: compact ? null : t("default"),
|
|
||||||
"aria-label": label,
|
"aria-label": label,
|
||||||
title: label,
|
title: label,
|
||||||
})
|
})
|
||||||
const low = field("min", value?.min, t("Fewest"))
|
const low = handle("min", t("Min"))
|
||||||
const high = field("max", value?.max, t("Most"))
|
const high = handle("max", t("Max"))
|
||||||
const bounds = () => {
|
const said = h("output", { class: "range2__said", "aria-live": "polite", for: id || null })
|
||||||
high.min = low.value || "1"
|
const fill = h("span", { class: "range2__fill", "aria-hidden": "true" })
|
||||||
if (high.value) low.max = high.value
|
const box = h("span", { class: "range2__box" }, h("span", { class: "range2__track", "aria-hidden": "true" }, fill), low, high)
|
||||||
else low.removeAttribute("max")
|
const draw = () => {
|
||||||
|
const lo = Number(low.value)
|
||||||
|
const hi = Number(high.value)
|
||||||
|
const at = (n) => (top > 1 ? ((n - 1) / (top - 1)) * 100 : 0)
|
||||||
|
box.style.setProperty("--lo", `${at(lo)}%`)
|
||||||
|
box.style.setProperty("--hi", `${at(hi)}%`)
|
||||||
|
// Where the two meet, the one that can still move is on top: Min at the far end, else Max.
|
||||||
|
low.classList.toggle("is-top", lo === hi && hi === top)
|
||||||
|
low.setAttribute("aria-valuetext", `${t("Min")} ${lo}`)
|
||||||
|
high.setAttribute("aria-valuetext", `${t("Max")} ${hi}`)
|
||||||
|
said.textContent = `${t("Min")} ${lo} · ${t("Max")} ${hi}`
|
||||||
}
|
}
|
||||||
bounds()
|
const set = (v) => {
|
||||||
const read = () => {
|
const hi = clamp(v?.max, Math.min(AGENTS_DEFAULT.max, top))
|
||||||
const out = {}
|
const lo = Math.min(hi, clamp(v?.min, AGENTS_DEFAULT.min))
|
||||||
if (low.value) out.min = Number(low.value)
|
high.value = String(hi)
|
||||||
if (high.value) out.max = Number(high.value)
|
low.value = String(lo)
|
||||||
return out
|
draw()
|
||||||
}
|
}
|
||||||
for (const input of [low, high]) {
|
low.addEventListener("input", () => {
|
||||||
input.addEventListener("change", () => {
|
if (Number(low.value) > Number(high.value)) low.value = high.value
|
||||||
bounds()
|
touched = true
|
||||||
onChange?.(read())
|
draw()
|
||||||
})
|
})
|
||||||
}
|
high.addEventListener("input", () => {
|
||||||
|
if (Number(high.value) < Number(low.value)) high.value = low.value
|
||||||
|
touched = true
|
||||||
|
draw()
|
||||||
|
})
|
||||||
|
for (const input of [low, high]) input.addEventListener("change", () => onChange?.(read()))
|
||||||
|
const read = () => (touched || given.min || given.max ? { min: Number(low.value), max: Number(high.value) } : {})
|
||||||
|
set(given)
|
||||||
const el = h(
|
const el = h(
|
||||||
"span",
|
"span",
|
||||||
{ class: `agents${compact ? " agents--compact" : ""}`, role: "group", "aria-label": t("Agents"), title: t("Its own agent included. Above one, it can add members that do the work while it leads them.") },
|
{ class: "range2", role: "group", "aria-label": t("Subagents") },
|
||||||
compact ? h("span", { class: "agents__label", text: t("Agents") }) : null,
|
said,
|
||||||
low,
|
box,
|
||||||
h("span", { class: "agents__dash", "aria-hidden": "true", text: "–" }),
|
h("span", { class: "range2__ends", "aria-hidden": "true" }, h("span", { text: "1" }), h("span", { text: String(top) })),
|
||||||
high,
|
|
||||||
)
|
)
|
||||||
Object.defineProperty(el, "value", {
|
Object.defineProperty(el, "value", {
|
||||||
get: read,
|
get: read,
|
||||||
set: (v) => {
|
set: (v) => {
|
||||||
low.value = v?.min ?? ""
|
given = v || {}
|
||||||
high.value = v?.max ?? ""
|
touched = false
|
||||||
bounds()
|
set(given)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return el
|
return el
|
||||||
|
|||||||
+73
-3
@@ -21,11 +21,12 @@ function outside(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A menu of `items` ({ label, run, lead?, checked?, danger?, disabled? }, or null for a separator), under
|
* A menu of `items` ({ label, hint?, run, lead?, checked?, danger?, disabled? }, or null for a separator), under
|
||||||
* `anchor` (an element) or at `{ x, y }` (page pixels). Arrow keys move, Enter chooses.
|
* `anchor` (an element) or at `{ x, y }` (page pixels). Arrow keys move, Enter chooses.
|
||||||
*/
|
*/
|
||||||
export function menu(anchor, items) {
|
export function menu(anchor, items) {
|
||||||
closeMenu()
|
closeMenu()
|
||||||
|
closePopover()
|
||||||
const plain = !items.some((i) => i?.lead)
|
const plain = !items.some((i) => i?.lead)
|
||||||
const el = h("div", { class: `menu${plain ? " menu--plain" : ""}`, role: "menu" })
|
const el = h("div", { class: `menu${plain ? " menu--plain" : ""}`, role: "menu" })
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
@@ -50,7 +51,9 @@ export function menu(anchor, items) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
h("span", { class: "menu__lead" }, item.lead || null),
|
h("span", { class: "menu__lead" }, item.lead || null),
|
||||||
h("span", { class: "menu__label", text: item.label }),
|
item.hint
|
||||||
|
? h("span", { class: "menu__label menu__label--two" }, h("span", { text: item.label }), h("span", { class: "menu__hint", text: item.hint }))
|
||||||
|
: h("span", { class: "menu__label", text: item.label }),
|
||||||
h("span", { class: "menu__check", "aria-hidden": "true", text: item.checked ? "✓" : "" }),
|
h("span", { class: "menu__check", "aria-hidden": "true", text: item.checked ? "✓" : "" }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -76,7 +79,9 @@ export function menu(anchor, items) {
|
|||||||
// Its width as measured at the left edge, kept: moved right, it would shrink to fit instead.
|
// Its width as measured at the left edge, kept: moved right, it would shrink to fit instead.
|
||||||
el.style.width = `${Math.ceil(r.width)}px`
|
el.style.width = `${Math.ceil(r.width)}px`
|
||||||
el.style.left = `${Math.max(0, Math.min(at.left, window.innerWidth - r.width))}px`
|
el.style.left = `${Math.max(0, Math.min(at.left, window.innerWidth - r.width))}px`
|
||||||
el.style.top = `${Math.max(0, Math.min(at.bottom, window.innerHeight - r.height))}px`
|
// Below the anchor where it fits, else above it (a chip under the message box opens upwards).
|
||||||
|
const above = at.top !== undefined && at.bottom + r.height > window.innerHeight && at.top - r.height >= 0
|
||||||
|
el.style.top = above ? `${at.top - r.height}px` : `${Math.max(0, Math.min(at.bottom, window.innerHeight - r.height))}px`
|
||||||
openMenu = { el, back: anchor instanceof Element ? anchor : document.activeElement }
|
openMenu = { el, back: anchor instanceof Element ? anchor : document.activeElement }
|
||||||
document.addEventListener("pointerdown", outside, true)
|
document.addEventListener("pointerdown", outside, true)
|
||||||
el.querySelector(".menu__item:not(:disabled)")?.focus()
|
el.querySelector(".menu__item:not(:disabled)")?.focus()
|
||||||
@@ -166,3 +171,68 @@ export function showLine({ title, text, code, copy, copied, close }) {
|
|||||||
copyBtn.focus()
|
copyBtn.focus()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let openPop = null
|
||||||
|
|
||||||
|
/** Close the popover that is open, if any. */
|
||||||
|
export function closePopover() {
|
||||||
|
if (!openPop) return
|
||||||
|
const { el, back, onClose } = openPop
|
||||||
|
openPop = null
|
||||||
|
el.remove()
|
||||||
|
document.removeEventListener("pointerdown", outsidePop, true)
|
||||||
|
document.removeEventListener("keydown", escPop, true)
|
||||||
|
onClose?.()
|
||||||
|
back?.focus?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
function outsidePop(e) {
|
||||||
|
if (openPop && !openPop.el.contains(e.target) && !openPop.back?.contains?.(e.target)) closePopover()
|
||||||
|
}
|
||||||
|
|
||||||
|
function escPop(e) {
|
||||||
|
if (e.key === "Escape" && openPop) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
closePopover()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small panel under `anchor` (or above it, `up`), holding `content` (an element, or a function
|
||||||
|
* that draws it and is called again by `redraw`). Escape and a click outside close it. Returns
|
||||||
|
* { redraw, close }. `align`: "start" (the anchor's left edge) or "end" (its right edge).
|
||||||
|
*/
|
||||||
|
export function popover(anchor, content, { up = false, align = "start", label = "", onClose } = {}) {
|
||||||
|
closePopover()
|
||||||
|
closeMenu()
|
||||||
|
const el = h("div", { class: "pop", role: "dialog", "aria-label": label || null })
|
||||||
|
const draw = () => el.replaceChildren(typeof content === "function" ? content() : content)
|
||||||
|
draw()
|
||||||
|
document.body.append(el)
|
||||||
|
const place = () => {
|
||||||
|
const at = anchor.getBoundingClientRect()
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
const left = align === "end" ? at.right - r.width : at.left
|
||||||
|
el.style.left = `${Math.max(8, Math.min(left, window.innerWidth - r.width - 8))}px`
|
||||||
|
const top = up ? at.top - r.height - 6 : at.bottom + 6
|
||||||
|
el.style.top = `${Math.max(8, Math.min(top, window.innerHeight - r.height - 8))}px`
|
||||||
|
}
|
||||||
|
place()
|
||||||
|
openPop = { el, back: anchor, onClose }
|
||||||
|
document.addEventListener("pointerdown", outsidePop, true)
|
||||||
|
document.addEventListener("keydown", escPop, true)
|
||||||
|
el.querySelector("button, input, select")?.focus()
|
||||||
|
return {
|
||||||
|
redraw() {
|
||||||
|
if (openPop?.el !== el) return
|
||||||
|
draw()
|
||||||
|
place()
|
||||||
|
},
|
||||||
|
close: closePopover,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function popoverOpen() {
|
||||||
|
return !!openPop
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// What a session is doing, as one line: the harness's `progress` object (spec 3.4.0,
|
||||||
|
// harness/progress.md in the LLeMbas CLI) drawn the way every client draws it --
|
||||||
|
// `{{text}} · {{elapsed}}`, with the shared texts (`loop.json` progress.texts) as this page's
|
||||||
|
// sentences. The server sends the object beside the old `status` text in a chat's stream; where it
|
||||||
|
// sends none (an older server), the status text is drawn as before.
|
||||||
|
//
|
||||||
|
// The texts are the spec's, word for word with their `{{field}}`s: the server's catalogues
|
||||||
|
// translate them under the same keys (scripts/gen-i18n.py takes them from there).
|
||||||
|
|
||||||
|
/** The thirteen phases: a text for each, and a variant where a field may be missing. */
|
||||||
|
const TEXTS = {
|
||||||
|
queued: "Queued",
|
||||||
|
"queued.ahead": "Queued: {{ahead}} ahead",
|
||||||
|
waiting_for_model: "Waiting for {{model}}: {{reason}}",
|
||||||
|
loading_model: "Loading {{model}}",
|
||||||
|
reading_prompt: "Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)",
|
||||||
|
"reading_prompt.bare": "Reading the prompt",
|
||||||
|
thinking: "Thinking",
|
||||||
|
writing: "Writing: {{rate}} tok/s",
|
||||||
|
"writing.bare": "Writing",
|
||||||
|
running_tool: "Running {{tool}}: {{what}}",
|
||||||
|
"running_tool.bare": "Running {{tool}}",
|
||||||
|
waiting_for_person: "Waiting for you: {{what}}",
|
||||||
|
waiting_for_subagents: "Waiting for subagents: {{running}} running",
|
||||||
|
compacting: "Compacting the conversation",
|
||||||
|
retrying: "Retrying in {{in}} s: {{reason}}",
|
||||||
|
stopped: "Stopped",
|
||||||
|
"stopped.reason": "Stopped: {{reason}}",
|
||||||
|
failed: "Failed: {{reason}}",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The words a field stands for. */
|
||||||
|
const REASONS = {
|
||||||
|
busy: "it serves one request at a time and is answering another",
|
||||||
|
slots: "all its slots are in use",
|
||||||
|
group: "its server holds another model now",
|
||||||
|
}
|
||||||
|
const WHAT = {
|
||||||
|
approval: "allow {{tool}}?",
|
||||||
|
"approval.bare": "an approval",
|
||||||
|
question: "a question",
|
||||||
|
plan: "the plan",
|
||||||
|
}
|
||||||
|
const STOPPED = { person: "by you", steps: "step limit", budget: "budget" }
|
||||||
|
|
||||||
|
/** Every sentence above, for scripts/gen-i18n.py to find (it reads `t("…")` calls by hand). */
|
||||||
|
export const PROGRESS_SENTENCES = [...Object.values(TEXTS), ...Object.values(REASONS), ...Object.values(WHAT), ...Object.values(STOPPED)]
|
||||||
|
|
||||||
|
const NO_ELAPSED = ["stopped", "failed"]
|
||||||
|
|
||||||
|
const has = (p, k) => p[k] !== undefined && p[k] !== null && p[k] !== ""
|
||||||
|
|
||||||
|
/** A sentence in the page's language (the spec's text is the key, as in the server's
|
||||||
|
* catalogues), its `{{field}}`s filled -- after the lookup, so a field's own text is never read
|
||||||
|
* as a template. */
|
||||||
|
function say(text, fields = {}) {
|
||||||
|
return t(text).replace(/\{\{(\w+)\}\}/g, (all, name) => (Object.prototype.hasOwnProperty.call(fields, name) ? String(fields[name]) : all))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An integer as it is; a rate with one decimal, in the page's language (12.5, 12,5). */
|
||||||
|
function rate(n) {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(document.documentElement.lang || "en", { useGrouping: false, minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(n)
|
||||||
|
} catch {
|
||||||
|
return Number(n).toFixed(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const int = (n) => String(Math.trunc(Number(n) || 0))
|
||||||
|
|
||||||
|
/** Whole seconds, as a ticking clock writes them: 5s, 1m 05s, 1h 02m. */
|
||||||
|
export function elapsed(ms) {
|
||||||
|
const total = Math.max(0, Math.floor((Number(ms) || 0) / 1000))
|
||||||
|
if (total < 60) return `${total}s`
|
||||||
|
const minutes = Math.floor(total / 60)
|
||||||
|
if (minutes < 60) return `${minutes}m ${String(total % 60).padStart(2, "0")}s`
|
||||||
|
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `p` is a progress object this page can draw (one of the thirteen phases). */
|
||||||
|
export function isProgress(p) {
|
||||||
|
return !!p && typeof p === "object" && Object.prototype.hasOwnProperty.call(TEXTS, p.phase)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The phase's text, its fields filled; "" for nothing this page knows. */
|
||||||
|
export function progressText(p, now = Date.now()) {
|
||||||
|
if (!isProgress(p)) return ""
|
||||||
|
switch (p.phase) {
|
||||||
|
case "queued":
|
||||||
|
return has(p, "ahead") ? say(TEXTS["queued.ahead"], { ahead: int(p.ahead) }) : say(TEXTS.queued)
|
||||||
|
case "waiting_for_model":
|
||||||
|
return say(TEXTS.waiting_for_model, { model: p.model || "", reason: REASONS[p.reason] ? say(REASONS[p.reason]) : p.reason || "" })
|
||||||
|
case "loading_model":
|
||||||
|
return say(TEXTS.loading_model, { model: p.model || "" })
|
||||||
|
case "reading_prompt":
|
||||||
|
if (!has(p, "done") || !(Number(p.total) > 0)) return say(TEXTS["reading_prompt.bare"])
|
||||||
|
return say(TEXTS.reading_prompt, { done: int(p.done), total: int(p.total), percent: int(Math.floor((Number(p.done) * 100) / Number(p.total))) })
|
||||||
|
case "writing":
|
||||||
|
return has(p, "rate") ? say(TEXTS.writing, { rate: rate(p.rate) }) : say(TEXTS["writing.bare"])
|
||||||
|
case "running_tool":
|
||||||
|
return has(p, "what") ? say(TEXTS.running_tool, { tool: p.tool || "", what: p.what }) : say(TEXTS["running_tool.bare"], { tool: p.tool || "" })
|
||||||
|
case "waiting_for_person": {
|
||||||
|
const what = p.what === "approval" ? (has(p, "tool") ? say(WHAT.approval, { tool: p.tool }) : say(WHAT["approval.bare"])) : WHAT[p.what] ? say(WHAT[p.what]) : p.what || ""
|
||||||
|
return say(TEXTS.waiting_for_person, { what })
|
||||||
|
}
|
||||||
|
case "waiting_for_subagents":
|
||||||
|
return say(TEXTS.waiting_for_subagents, { running: int(p.running) })
|
||||||
|
case "retrying": {
|
||||||
|
const left = Math.max(0, Math.trunc(Number(p.in) || 0) - Math.floor((now - Number(p.since || now)) / 1000))
|
||||||
|
return say(TEXTS.retrying, { in: String(left), reason: p.reason || "" })
|
||||||
|
}
|
||||||
|
case "stopped":
|
||||||
|
return has(p, "reason") ? say(TEXTS["stopped.reason"], { reason: STOPPED[p.reason] ? say(STOPPED[p.reason]) : p.reason }) : say(TEXTS.stopped)
|
||||||
|
case "failed":
|
||||||
|
return say(TEXTS.failed, { reason: p.reason || "" })
|
||||||
|
default:
|
||||||
|
return say(TEXTS[p.phase])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line: `{{text}} · {{elapsed}}`, the text alone for stopped and failed (and where `bare`:
|
||||||
|
* a row with no room for the clock).
|
||||||
|
*/
|
||||||
|
export function progressLine(p, now = Date.now(), { bare = false } = {}) {
|
||||||
|
const text = progressText(p, now)
|
||||||
|
if (!text || bare || NO_ELAPSED.includes(p.phase) || !has(p, "since")) return text
|
||||||
|
// progress.line, `{{text}} · {{elapsed}}`: the same in every language.
|
||||||
|
return `${text} · ${elapsed(now - Number(p.since))}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the line changes by itself as time passes (it has a clock, or a countdown). */
|
||||||
|
export function progressTicks(p) {
|
||||||
|
return isProgress(p) && !NO_ELAPSED.includes(p.phase) && has(p, "since")
|
||||||
|
}
|
||||||
@@ -0,0 +1,742 @@
|
|||||||
|
// Scheduled tasks: standing instructions the server runs on its own -- "every weekday at 8:00,
|
||||||
|
// summarise …". The list (each task's next run, how its last run went, a switch to pause it), a
|
||||||
|
// task's page (what it does, when, its settings, the next runs, its history with each run's
|
||||||
|
// result, Run now, edit, pause, delete), and a task said in words: the server reads them into a
|
||||||
|
// rule and an instruction (compile), the next runs are shown, the settings chosen, and only then
|
||||||
|
// is it saved. The server runs them; this page only reads and asks.
|
||||||
|
//
|
||||||
|
// The server's /api/v2 for scheduling (LLeMbas `api/v2/schedules.py`; the shapes are its contract
|
||||||
|
// fixtures `schedules.json`, `schedule*.json`, `event_schedules.json`):
|
||||||
|
//
|
||||||
|
// GET /schedules { schedules: [schedule] }, newest first
|
||||||
|
// POST /schedules { title, instruction, request, rule, model, notify,
|
||||||
|
// mode, effort, agents } -> 201 { schedule }
|
||||||
|
// GET /schedules/{id} { schedule }
|
||||||
|
// PATCH /schedules/{id} any of the create fields -> { schedule }
|
||||||
|
// DELETE /schedules/{id}?keep_chat=true 204
|
||||||
|
// POST /schedules/{id}/pause | /resume { schedule }
|
||||||
|
// POST /schedules/{id}/run 202 { run }: now, not counted against the rule
|
||||||
|
// GET /schedules/{id}/runs?before=&limit= { runs: [run], more }, newest first
|
||||||
|
// GET /schedules/{id}/runs/{run} { run, text }: the result, Markdown (untrusted)
|
||||||
|
// POST /schedules/compile { text } -> { ok, reason, title, instruction,
|
||||||
|
// target, rule, description, next_runs, timezone }
|
||||||
|
// POST /schedules/preview { rule } -> { rule, description, next_runs, timezone }
|
||||||
|
// GET /schedules/models { models: [model + free, busy, chosen], instance, mine }
|
||||||
|
// PUT /schedules/models { models: [id] }: the person's narrowing
|
||||||
|
// GET /events `schedules` frames, { version, schedules }: followed
|
||||||
|
// by the app (Rust, watch.rs), handed here as the
|
||||||
|
// `schedules` event
|
||||||
|
//
|
||||||
|
// A server without them answers 404: the page says this server has no scheduled tasks yet.
|
||||||
|
// Everything a task says (its title, instruction, a run's result or error) is the person's or a
|
||||||
|
// model's words, set as text; a result's Markdown goes through markdown.js.
|
||||||
|
|
||||||
|
import { $, h, clear, icon, ICONS, when } from "./dom.js"
|
||||||
|
import { api, get, post, patch, del, why, listen } from "./bridge.js"
|
||||||
|
import { ask, menu, popover } from "./popup.js"
|
||||||
|
import { avatar, levelsOf, levelWord } from "./models.js"
|
||||||
|
import { markdown } from "./markdown.js"
|
||||||
|
|
||||||
|
let ctx = null // { status(), models(), me(), openChat(id), show() }
|
||||||
|
let view = { name: "list" } // { name: "list" } | { name: "task", id } | { name: "form", task? }
|
||||||
|
let tasks = []
|
||||||
|
let missing = false // the server has no scheduling routes
|
||||||
|
let shown = null // the task whose page is open: { id, last, running }
|
||||||
|
|
||||||
|
const STATUS = {
|
||||||
|
ok: () => t("Done"),
|
||||||
|
silent: () => t("Nothing to report"),
|
||||||
|
failed: () => t("Failed"),
|
||||||
|
missed: () => t("Missed"),
|
||||||
|
skipped: () => t("Skipped"),
|
||||||
|
waiting: () => t("Waiting for a free model"),
|
||||||
|
running: () => t("Running"),
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTIFY = {
|
||||||
|
matters: () => t("When it matters"),
|
||||||
|
always: () => t("Always"),
|
||||||
|
never: () => t("Never"),
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTIFY_HINT = {
|
||||||
|
matters: () => t("A result, but not a run with nothing to report."),
|
||||||
|
always: () => t("Every result, even with nothing to report."),
|
||||||
|
never: () => t("Only in its history."),
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE = {
|
||||||
|
fresh: () => t("A new chat for each run"),
|
||||||
|
continue: () => t("One chat that grows"),
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIMING = {
|
||||||
|
late: () => t("late"),
|
||||||
|
catch_up: () => t("caught up"),
|
||||||
|
manual: () => t("run by hand"),
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_AGENTS = 8 // services/schedules.py MAX_AGENTS
|
||||||
|
const enc = encodeURIComponent
|
||||||
|
const RUNS_PAGE = 20
|
||||||
|
|
||||||
|
function statusChip(run, running) {
|
||||||
|
if (running) return h("span", { class: "schip is-running", text: STATUS.running() })
|
||||||
|
if (!run) return h("span", { class: "schip", text: t("Not run yet") })
|
||||||
|
const word = STATUS[run.status]?.() || run.status
|
||||||
|
// A missed run counts the slots that passed unrun; any other run says how many it followed.
|
||||||
|
const n = run.status === "missed" ? (run.missed > 1 ? ` ×${run.missed}` : "") : run.missed > 0 ? ` · ${t("%(n)s missed", { n: run.missed })}` : ""
|
||||||
|
return h("span", { class: `schip is-${run.status}`, title: `${word}${n}`, text: `${word}${n}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stamp as a date and time in the person's language: "Mon 10 Nov, 08:00". */
|
||||||
|
function stamp(value) {
|
||||||
|
const d = new Date(value)
|
||||||
|
if (!value || Number.isNaN(d.getTime())) return ""
|
||||||
|
return d.toLocaleString(document.documentElement.lang || undefined, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextWords(task) {
|
||||||
|
if (!task.enabled) return t("Paused")
|
||||||
|
if (!task.next_run) return t("No more runs")
|
||||||
|
return `${when(task.next_run)} · ${stamp(task.next_run)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPage() {
|
||||||
|
return !$("page-scheduled").hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reading ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
tasks = (await get("/schedules")).schedules || []
|
||||||
|
missing = false
|
||||||
|
} catch (err) {
|
||||||
|
tasks = []
|
||||||
|
missing = err?.status === 404 || err?.status === 405
|
||||||
|
if (!missing) throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The scheduling models, in the order they are tried; null when the server has none to say. */
|
||||||
|
async function schedulingModels() {
|
||||||
|
try {
|
||||||
|
return await get("/schedules/models")
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show the page: the list, or one task's page (`id`). */
|
||||||
|
export async function showScheduled(id) {
|
||||||
|
view = id ? { name: "task", id } : { name: "list" }
|
||||||
|
await draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function draw() {
|
||||||
|
const body = clear($("scheduled-body"))
|
||||||
|
clear($("scheduled-tools"))
|
||||||
|
clear($("scheduled-place"))
|
||||||
|
shown = null
|
||||||
|
body.append(h("p", { class: "muted page__loading", text: t("Loading…") }))
|
||||||
|
try {
|
||||||
|
if (view.name === "task") await drawTask(view.id)
|
||||||
|
else if (view.name === "form") await drawForm(view.task)
|
||||||
|
else {
|
||||||
|
await load()
|
||||||
|
drawList()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
clear(body).append(h("p", { class: "status is-error", text: why(err) }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page's title; with a way back, "Scheduled ›" before it. */
|
||||||
|
function header(title, back) {
|
||||||
|
const h1 = $("scheduled-title")
|
||||||
|
if (back) h1.replaceChildren(h("button", { type: "button", class: "crumb", onclick: back, text: t("Scheduled") }), h("span", { class: "crumb__sep", "aria-hidden": "true", text: "›" }), h("span", { text: title }))
|
||||||
|
else h1.textContent = title
|
||||||
|
}
|
||||||
|
|
||||||
|
function go(next) {
|
||||||
|
view = next
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The list -------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function drawList() {
|
||||||
|
header(t("Scheduled"))
|
||||||
|
const tools = clear($("scheduled-tools"))
|
||||||
|
const body = clear($("scheduled-body"))
|
||||||
|
if (missing) {
|
||||||
|
body.append(empty(t("This server has no scheduled tasks yet."), t("Scheduled tasks need a newer LLeMbas server. Ask its administrator to update it.")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tools.append(
|
||||||
|
h("button", { type: "button", class: "btn btn--sm", id: "sched-models", "aria-haspopup": "dialog", onclick: (e) => modelsPanel(e.currentTarget) }, h("span", { text: t("Models") })),
|
||||||
|
h("button", { type: "button", class: "btn btn--sm btn--primary", id: "sched-new", onclick: () => go({ name: "form" }) }, icon(ICONS.plus), h("span", { text: t("New scheduled task") })),
|
||||||
|
)
|
||||||
|
if (!tasks.length) {
|
||||||
|
body.append(
|
||||||
|
empty(t("Nothing is scheduled."), t("Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.")),
|
||||||
|
h("div", { class: "empty__actions" }, h("button", { type: "button", class: "btn btn--primary", onclick: () => go({ name: "form" }) }, icon(ICONS.plus), h("span", { text: t("New scheduled task") }))),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const list = h("ul", { class: "tasks-list", role: "list" })
|
||||||
|
for (const task of tasks) list.append(taskRow(task))
|
||||||
|
body.append(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
function empty(title, text) {
|
||||||
|
return h("div", { class: "empty" }, h("span", { class: "empty__icon" }, icon(ICONS.clock)), h("h2", { class: "empty__title", text: title }), h("p", { class: "empty__text", text }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One task: icon · title and its rule in words · next run · last status · pause switch. */
|
||||||
|
function taskRow(task) {
|
||||||
|
const toggle = h("input", {
|
||||||
|
type: "checkbox",
|
||||||
|
role: "switch",
|
||||||
|
checked: !!task.enabled,
|
||||||
|
"aria-label": task.enabled ? t("Pause %(title)s", { title: task.title }) : t("Resume %(title)s", { title: task.title }),
|
||||||
|
title: task.enabled ? t("Pause") : t("Resume"),
|
||||||
|
onclick: (e) => e.stopPropagation(),
|
||||||
|
onchange: (e) => setEnabled(task, e.target.checked),
|
||||||
|
})
|
||||||
|
return h(
|
||||||
|
"li",
|
||||||
|
{ class: `task${task.enabled ? "" : " is-paused"}`, dataset: { id: task.id } },
|
||||||
|
h(
|
||||||
|
"button",
|
||||||
|
{ type: "button", class: "task__open", onclick: () => go({ name: "task", id: task.id }) },
|
||||||
|
h("span", { class: "task__icon" }, icon(ICONS.clock)),
|
||||||
|
h("span", { class: "task__what" }, h("span", { class: "task__title", text: task.title || task.request || "" }), h("span", { class: "task__rule", text: task.description || "" })),
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ class: "task__next", title: task.enabled && task.next_run ? stamp(task.next_run) : "" },
|
||||||
|
h("span", { class: "task__label", text: t("Next run") }),
|
||||||
|
h("span", { class: "task__value", text: task.enabled && task.next_run ? when(task.next_run) : nextWords(task) }),
|
||||||
|
),
|
||||||
|
h("span", { class: "task__last" }, statusChip(task.last_run, task.running)),
|
||||||
|
),
|
||||||
|
h("label", { class: "toggle task__toggle" }, toggle),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pause or resume: the server's own verbs, which answer the schedule as it now is. */
|
||||||
|
async function setEnabled(task, on) {
|
||||||
|
try {
|
||||||
|
const r = await post(`/schedules/${enc(task.id)}/${on ? "resume" : "pause"}`)
|
||||||
|
Object.assign(task, r.schedule || { enabled: on })
|
||||||
|
} catch (err) {
|
||||||
|
await ask({ title: t("Scheduled"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
|
||||||
|
}
|
||||||
|
if (view.name === "list") drawList()
|
||||||
|
else draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scheduling models: the ones scheduled work may run on, in the order they are tried, each
|
||||||
|
* free now or busy (and why). A task without a model of its own runs on the first free one of
|
||||||
|
* those ticked; ticking none means all of them.
|
||||||
|
*/
|
||||||
|
async function modelsPanel(anchor) {
|
||||||
|
const data = await schedulingModels()
|
||||||
|
if (!data) return
|
||||||
|
let mine = new Set(data.mine || [])
|
||||||
|
const said = h("p", { class: "status is-error", hidden: true })
|
||||||
|
const save = async () => {
|
||||||
|
try {
|
||||||
|
const r = await api("PUT", "/schedules/models", { models: data.models.filter((m) => mine.has(m.id)).map((m) => m.id) })
|
||||||
|
Object.assign(data, r)
|
||||||
|
mine = new Set(r.mine || [])
|
||||||
|
said.hidden = true
|
||||||
|
} catch (err) {
|
||||||
|
said.textContent = why(err)
|
||||||
|
said.hidden = false
|
||||||
|
}
|
||||||
|
pop.redraw()
|
||||||
|
}
|
||||||
|
const content = () =>
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "pop__body smodels" },
|
||||||
|
h("p", { class: "pop__title", text: t("Models for scheduled tasks") }),
|
||||||
|
h("p", { class: "smodels__hint", text: t("A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.") }),
|
||||||
|
h(
|
||||||
|
"ul",
|
||||||
|
{ class: "smodels__list", role: "list" },
|
||||||
|
data.models.map((m) =>
|
||||||
|
h(
|
||||||
|
"li",
|
||||||
|
null,
|
||||||
|
h(
|
||||||
|
"label",
|
||||||
|
{ class: "smodel" },
|
||||||
|
h("input", {
|
||||||
|
type: "checkbox",
|
||||||
|
checked: mine.has(m.id),
|
||||||
|
onchange: (e) => {
|
||||||
|
if (e.target.checked) mine.add(m.id)
|
||||||
|
else mine.delete(m.id)
|
||||||
|
save()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
avatar(m),
|
||||||
|
h("span", { class: "smodel__name", text: m.name }),
|
||||||
|
h("span", { class: `smodel__state${m.free ? " is-free" : ""}`, title: m.busy || "", text: m.free ? t("free") : t("busy") }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
data.models.length ? null : h("p", { class: "muted", text: t("No model is available to this account.") }),
|
||||||
|
said,
|
||||||
|
)
|
||||||
|
const pop = popover(anchor, content, { align: "end", label: t("Models for scheduled tasks") })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- One task -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function drawTask(id) {
|
||||||
|
const [r, page] = await Promise.all([get(`/schedules/${enc(id)}`), get(`/schedules/${enc(id)}/runs?limit=${RUNS_PAGE}`)])
|
||||||
|
const task = r.schedule
|
||||||
|
shown = { id: task.id, last: task.last_run?.id || null, status: task.last_run?.status || null, running: task.running }
|
||||||
|
header(task.title || t("Scheduled"), () => go({ name: "list" }))
|
||||||
|
const tools = clear($("scheduled-tools"))
|
||||||
|
tools.append(
|
||||||
|
h("button", { type: "button", class: "btn btn--sm btn--primary", id: "sched-run", disabled: !!task.running, onclick: (e) => runNow(task, e.currentTarget) }, icon(ICONS.play), h("span", { text: t("Run now") })),
|
||||||
|
h("button", { type: "button", class: "btn btn--sm", id: "sched-edit", onclick: () => go({ name: "form", task }) }, icon(ICONS.edit), h("span", { text: t("Edit") })),
|
||||||
|
h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
class: "tool",
|
||||||
|
"aria-label": t("More"),
|
||||||
|
title: t("More"),
|
||||||
|
onclick: (e) =>
|
||||||
|
menu(e.currentTarget, [
|
||||||
|
{ label: task.enabled ? t("Pause") : t("Resume"), run: () => setEnabled(task, !task.enabled) },
|
||||||
|
...(task.chat_id ? [{ label: t("Open its chat"), run: () => ctx.openChat(task.chat_id) }] : []),
|
||||||
|
null,
|
||||||
|
{ label: t("Delete"), danger: true, run: () => remove(task) },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
icon(ICONS.more),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const models = (await schedulingModels())?.models || ctx.models()
|
||||||
|
const body = clear($("scheduled-body"))
|
||||||
|
body.append(
|
||||||
|
h(
|
||||||
|
"section",
|
||||||
|
{ class: "sched" },
|
||||||
|
task.failure_streak > 0 && task.last_error ? h("p", { class: "status is-error sched__trouble", text: t("The last %(n)s runs failed: %(why)s", { n: task.failure_streak, why: task.last_error }) }) : null,
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "sched__facts" },
|
||||||
|
fact(t("When"), h("span", { text: task.description || "" }), task.timezone ? t("Times in %(zone)s", { zone: task.timezone }) : ""),
|
||||||
|
fact(t("Next run"), h("span", { text: nextWords(task) })),
|
||||||
|
fact(t("Last run"), statusChip(task.last_run, task.running)),
|
||||||
|
fact(t("Model"), modelWords(models, task.model)),
|
||||||
|
fact(t("Reasoning effort"), h("span", { text: task.effort ? levelWord(task.effort) : t("The model's own") })),
|
||||||
|
fact(t("Subagents"), h("span", { text: task.agents ? t("Up to %(n)s at once", { n: task.agents }) : t("None") })),
|
||||||
|
fact(t("Tell me"), h("span", { text: NOTIFY[task.notify]?.() || NOTIFY.matters() })),
|
||||||
|
fact(t("Runs"), h("span", { text: MODE[task.mode]?.() || MODE.fresh() })),
|
||||||
|
),
|
||||||
|
h("div", { class: "sched__what" }, h("h2", { class: "sched__h", text: t("What it does") }), h("p", { class: "sched__instruction", text: task.instruction || task.request || "" }), task.request && task.request !== task.instruction ? h("p", { class: "sched__asked", text: t("You asked: “%(text)s”", { text: task.request }) }) : null),
|
||||||
|
upcoming(task.next_runs || []),
|
||||||
|
),
|
||||||
|
history(task, page.runs || [], page.more),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fact(label, value, hint) {
|
||||||
|
return h("div", { class: "fact" }, h("span", { class: "fact__label", text: label }), h("span", { class: "fact__value" }, value), hint ? h("span", { class: "fact__hint", text: hint }) : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelWords(models, id) {
|
||||||
|
if (!id) return h("span", { text: t("The first free scheduling model") })
|
||||||
|
const m = models.find((x) => x.id === id)
|
||||||
|
return m ? h("span", { class: "sched__model" }, avatar(m), h("span", { text: m.name })) : h("span", { text: id })
|
||||||
|
}
|
||||||
|
|
||||||
|
function upcoming(next) {
|
||||||
|
if (!next.length) return null
|
||||||
|
return h("div", { class: "sched__next" }, h("h2", { class: "sched__h", text: t("The next runs") }), h("ol", { class: "runs-next" }, next.slice(0, 5).map((s) => h("li", null, h("span", { text: stamp(s) }), h("span", { class: "muted", text: when(s) })))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The history: one row per run on fixed tracks -- status, when, model, and a way to its result. */
|
||||||
|
function history(task, runs, more) {
|
||||||
|
const box = h("section", { class: "sched__history" }, h("h2", { class: "sched__h", text: t("History") }))
|
||||||
|
if (!runs.length) {
|
||||||
|
box.append(h("p", { class: "muted", text: t("It has not run yet.") }))
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
const list = h("ul", { class: "runs" })
|
||||||
|
const add = (page) => {
|
||||||
|
for (const run of page) list.append(runRow(task, run))
|
||||||
|
}
|
||||||
|
add(runs)
|
||||||
|
box.append(list)
|
||||||
|
if (more) {
|
||||||
|
const earlier = h("button", { type: "button", class: "btn btn--sm sched__more", text: t("Earlier runs") })
|
||||||
|
earlier.addEventListener("click", async () => {
|
||||||
|
earlier.disabled = true
|
||||||
|
try {
|
||||||
|
const last = list.lastElementChild?.dataset.id || ""
|
||||||
|
const r = await get(`/schedules/${enc(task.id)}/runs?limit=${RUNS_PAGE}&before=${enc(last)}`)
|
||||||
|
add(r.runs || [])
|
||||||
|
if (!r.more) earlier.remove()
|
||||||
|
else earlier.disabled = false
|
||||||
|
} catch (err) {
|
||||||
|
earlier.disabled = false
|
||||||
|
await ask({ title: t("History"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
box.append(earlier)
|
||||||
|
}
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a run that did not finish well says: waiting and until when, or its error. */
|
||||||
|
function runNote(run) {
|
||||||
|
if (run.status === "waiting") {
|
||||||
|
const again = run.retry_at ? t("tried again %(when)s", { when: when(run.retry_at) }) : ""
|
||||||
|
return [run.error?.text || STATUS.waiting(), again].filter(Boolean).join(" · ")
|
||||||
|
}
|
||||||
|
return run.error?.text || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function runRow(task, run) {
|
||||||
|
const model = run.model || {}
|
||||||
|
const switched = model.reason === "switched" && model.switched_from ? t("%(model)s was busy, so it ran on this one.", { model: model.switched_from }) : ""
|
||||||
|
const hasResult = run.message_id || run.report_path || run.preview
|
||||||
|
const detail = h("div", { class: "run__detail", hidden: true })
|
||||||
|
const open = hasResult
|
||||||
|
? h("button", { type: "button", class: "btn btn--sm run__open", "aria-expanded": "false", text: t("Result") })
|
||||||
|
: null
|
||||||
|
open?.addEventListener("click", async () => {
|
||||||
|
const opening = detail.hidden
|
||||||
|
open.setAttribute("aria-expanded", String(opening))
|
||||||
|
if (!opening) {
|
||||||
|
detail.hidden = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail.hidden = false
|
||||||
|
detail.replaceChildren(h("p", { class: "muted", text: t("Loading…") }))
|
||||||
|
try {
|
||||||
|
const r = await get(`/schedules/${enc(task.id)}/runs/${enc(run.id)}`)
|
||||||
|
detail.replaceChildren(
|
||||||
|
r.text ? h("div", { class: "md run__text" }, markdown(r.text)) : h("p", { class: "muted", text: run.preview || t("The result is no longer there.") }),
|
||||||
|
r.run?.report_path ? h("p", { class: "run__report", text: t("Written to %(path)s", { path: r.run.report_path }) }) : null,
|
||||||
|
r.run?.chat_id ? h("div", { class: "set__buttons" }, h("button", { type: "button", class: "btn btn--sm", text: t("Open the chat"), onclick: () => ctx.openChat(r.run.chat_id) })) : null,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
detail.replaceChildren(h("p", { class: "status is-error", text: why(err) }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const note = runNote(run)
|
||||||
|
return h(
|
||||||
|
"li",
|
||||||
|
{ class: "run", dataset: { id: run.id } },
|
||||||
|
h("span", { class: "run__status" }, statusChip(run)),
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ class: "run__when", title: run.due_at ? t("Due %(when)s", { when: stamp(run.due_at) }) : "" },
|
||||||
|
h("span", { text: stamp(run.started_at || run.due_at) }),
|
||||||
|
TIMING[run.timing] ? h("span", { class: "run__late", text: TIMING[run.timing]() }) : null,
|
||||||
|
),
|
||||||
|
h("span", { class: "run__model", title: switched }, h("span", { text: model.name || "" }), switched ? h("span", { class: "run__switched", text: t("switched") }) : null),
|
||||||
|
open || h("span", { class: "run__error", title: note, text: note }),
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runNow(task, btn) {
|
||||||
|
btn.disabled = true
|
||||||
|
try {
|
||||||
|
await post(`/schedules/${enc(task.id)}/run`)
|
||||||
|
await draw()
|
||||||
|
} catch (err) {
|
||||||
|
btn.disabled = false
|
||||||
|
await ask({ title: t("Run now"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(task) {
|
||||||
|
const yes = await ask({ title: t("Delete this scheduled task?"), text: t("“%(title)s” stops, and its history goes. The chats it wrote stay."), ok: t("Delete"), cancel: t("Cancel"), danger: true })
|
||||||
|
if (!yes) return
|
||||||
|
try {
|
||||||
|
await del(`/schedules/${enc(task.id)}?keep_chat=true`)
|
||||||
|
go({ name: "list" })
|
||||||
|
} catch (err) {
|
||||||
|
await ask({ title: t("Delete"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- A task said in words: new, or an existing one edited ---------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form. The words first: Read them asks the server (compile) for the rule, its title and
|
||||||
|
* instruction, and the next runs; the title and instruction can then be changed. Below, the
|
||||||
|
* settings the server keeps per task. An existing task starts from what it is: its words, title,
|
||||||
|
* instruction, and its rule read back (preview) -- a paused task's next runs too.
|
||||||
|
*/
|
||||||
|
async function drawForm(task) {
|
||||||
|
const editing = !!task
|
||||||
|
header(editing ? t("Edit %(title)s", { title: task.title }) : t("New scheduled task"), () => go(editing ? { name: "task", id: task.id } : { name: "list" }))
|
||||||
|
clear($("scheduled-tools"))
|
||||||
|
const smodels = (await schedulingModels())?.models || []
|
||||||
|
// What will be saved: the rule only from the server's reading of words (or the task's own).
|
||||||
|
const draft = {
|
||||||
|
request: task?.request || "",
|
||||||
|
title: task?.title || "",
|
||||||
|
instruction: task?.instruction || "",
|
||||||
|
rule: task?.rule || null,
|
||||||
|
target: task?.target || "chat",
|
||||||
|
model: task?.model || "",
|
||||||
|
effort: task?.effort || "",
|
||||||
|
agents: task?.agents || null,
|
||||||
|
notify: task?.notify || "matters",
|
||||||
|
mode: task?.mode || "fresh",
|
||||||
|
}
|
||||||
|
let reading = editing ? await post("/schedules/preview", { rule: task.rule }).catch(() => null) : null
|
||||||
|
|
||||||
|
const words = h("textarea", { id: "sched-words", class: "sched__words", rows: "3", placeholder: t("Every weekday at 8:00, summarise the news on my reading list"), "aria-label": t("What should happen, and when"), value: draft.request })
|
||||||
|
const readBtn = h("button", { type: "submit", class: "btn", text: editing ? t("Read again") : t("Preview") })
|
||||||
|
const said = h("p", { class: "status is-error", hidden: true })
|
||||||
|
const when_ = h("span", { class: "sched__desc", id: "sched-when" })
|
||||||
|
const zone = h("p", { class: "hint" })
|
||||||
|
const title = h("input", { type: "text", id: "sched-title", value: draft.title, "aria-label": t("Title") })
|
||||||
|
const instruction = h("textarea", { id: "sched-instruction", class: "sched__words", rows: "3", "aria-label": t("What it does"), value: draft.instruction })
|
||||||
|
const settings = h("div", { class: "fields sched__settings" })
|
||||||
|
const saveBtn = h("button", { type: "button", class: "btn btn--primary", id: "sched-save", text: t("Save") })
|
||||||
|
const saveSaid = h("p", { class: "status is-error", hidden: true })
|
||||||
|
const nextBox = h("div", { class: "sched__nextbox" })
|
||||||
|
const manual = manualRule((rule) => {
|
||||||
|
post("/schedules/preview", { rule })
|
||||||
|
.then((r) => {
|
||||||
|
reading = r
|
||||||
|
draft.rule = r.rule || {}
|
||||||
|
drawWhen()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
saveSaid.textContent = why(err)
|
||||||
|
saveSaid.hidden = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const manualBtn = h("button", { type: "button", class: "btn btn--sm sched__manual-open", text: t("Set the time yourself"), onclick: () => ((manual.hidden = false), (manualBtn.hidden = true), manual.querySelector("input")?.focus()) })
|
||||||
|
const rest = h(
|
||||||
|
"div",
|
||||||
|
{ class: "sched__card", hidden: !editing },
|
||||||
|
h("div", { class: "fields" }, field(t("Title"), title, "", "sched-title"), h("div", { class: "field" }, h("span", { class: "label", text: t("When") }), when_, zone)),
|
||||||
|
manualBtn,
|
||||||
|
manual,
|
||||||
|
nextBox,
|
||||||
|
h("label", { class: "sched__h", for: "sched-instruction", text: t("What it does") }),
|
||||||
|
instruction,
|
||||||
|
h("p", { class: "hint", text: t("The instruction each run is given, on its own: say everything it needs.") }),
|
||||||
|
settings,
|
||||||
|
saveSaid,
|
||||||
|
h("div", { class: "set__buttons" }, saveBtn),
|
||||||
|
)
|
||||||
|
|
||||||
|
const drawWhen = () => {
|
||||||
|
const r = reading
|
||||||
|
const next = r?.next_runs || []
|
||||||
|
const runs = !!(draft.rule && Object.keys(draft.rule).length)
|
||||||
|
when_.textContent = (runs && r?.description) || t("These words give no run in the future.")
|
||||||
|
when_.title = when_.textContent
|
||||||
|
when_.classList.toggle("is-error", !runs)
|
||||||
|
zone.textContent = runs && r?.timezone ? t("Times in %(zone)s", { zone: r.timezone }) : ""
|
||||||
|
nextBox.replaceChildren(upcoming(next) || "")
|
||||||
|
saveBtn.disabled = !(draft.rule && Object.keys(draft.rule).length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawSettings = () => {
|
||||||
|
const model = smodels.find((m) => m.id === draft.model) || ctx.models().find((m) => m.id === draft.model)
|
||||||
|
const select = (id, table, key, onChange) =>
|
||||||
|
h(
|
||||||
|
"select",
|
||||||
|
{ id, onchange: (e) => ((draft[key] = e.target.value), onChange?.()) },
|
||||||
|
Object.entries(table).map(([v, word]) => h("option", { value: v, text: word(), selected: draft[key] === v })),
|
||||||
|
)
|
||||||
|
const modelSelect = h(
|
||||||
|
"select",
|
||||||
|
{ id: "sched-model", onchange: (e) => ((draft.model = e.target.value), (draft.effort = ""), drawSettings()) },
|
||||||
|
h("option", { value: "", text: t("The first free scheduling model"), selected: !draft.model }),
|
||||||
|
smodels.map((m) => h("option", { value: m.id, text: m.free ? m.name : `${m.name} · ${t("busy")}`, selected: draft.model === m.id })),
|
||||||
|
draft.model && !smodels.some((m) => m.id === draft.model) ? h("option", { value: draft.model, text: draft.model, selected: true }) : null,
|
||||||
|
)
|
||||||
|
const levels = model ? levelsOf(model).levels.filter((l) => l !== "default") : []
|
||||||
|
const effort = h(
|
||||||
|
"select",
|
||||||
|
{ id: "sched-effort", disabled: !levels.length && !draft.effort, onchange: (e) => (draft.effort = e.target.value) },
|
||||||
|
h("option", { value: "", text: t("The model's own"), selected: !draft.effort }),
|
||||||
|
levels.map((l) => h("option", { value: l, text: levelWord(l), selected: draft.effort === l })),
|
||||||
|
// A level the task has that its model no longer offers stays, as it is, until changed.
|
||||||
|
draft.effort && !levels.includes(draft.effort) ? h("option", { value: draft.effort, text: levelWord(draft.effort), selected: true }) : null,
|
||||||
|
)
|
||||||
|
const agents = h(
|
||||||
|
"select",
|
||||||
|
{ id: "sched-agents", onchange: (e) => (draft.agents = Number(e.target.value) || null) },
|
||||||
|
h("option", { value: "", text: t("None"), selected: !draft.agents }),
|
||||||
|
Array.from({ length: MAX_AGENTS - 1 }, (_, i) => i + 2).map((n) => h("option", { value: String(n), text: t("Up to %(n)s at once", { n }), selected: draft.agents === n })),
|
||||||
|
)
|
||||||
|
settings.replaceChildren(
|
||||||
|
field(t("Tell me"), select("sched-notify", NOTIFY, "notify", () => (settings.querySelector("#sched-notify-hint").textContent = NOTIFY_HINT[draft.notify]())), NOTIFY_HINT[draft.notify]?.() || "", "sched-notify", "sched-notify-hint"),
|
||||||
|
field(t("Runs"), select("sched-mode", MODE, "mode"), draft.mode === "continue" ? t("Each run sees the ones before it.") : t("Each run starts with nothing but the instruction."), "sched-mode"),
|
||||||
|
field(t("Model"), modelSelect, t("A busy model is waited for, then the next free one is used."), "sched-model"),
|
||||||
|
field(t("Reasoning effort"), effort, levels.length ? "" : t("Choose a model to choose its effort."), "sched-effort"),
|
||||||
|
field(t("Subagents"), agents, t("How many work on it at once, the run's own agent among them, on the same model."), "sched-agents"),
|
||||||
|
)
|
||||||
|
settings.querySelector("#sched-mode").addEventListener("change", drawSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = h(
|
||||||
|
"form",
|
||||||
|
{ class: "sched sched--new" },
|
||||||
|
h("label", { class: "sched__h", for: "sched-words", text: t("What should happen, and when") }),
|
||||||
|
words,
|
||||||
|
h("p", { class: "hint", text: t("In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.") }),
|
||||||
|
h("div", { class: "set__buttons" }, readBtn),
|
||||||
|
said,
|
||||||
|
rest,
|
||||||
|
)
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const text = words.value.trim()
|
||||||
|
if (!text) return words.focus()
|
||||||
|
readBtn.disabled = true
|
||||||
|
said.hidden = true
|
||||||
|
try {
|
||||||
|
const c = await post("/schedules/compile", { text })
|
||||||
|
draft.request = text
|
||||||
|
draft.rule = c.rule || {}
|
||||||
|
if (c.title || !editing) title.value = c.title || ""
|
||||||
|
if (c.instruction || !editing) instruction.value = c.instruction || text
|
||||||
|
if (c.target) draft.target = c.target
|
||||||
|
reading = c
|
||||||
|
if (!c.ok && c.reason) {
|
||||||
|
said.textContent = c.reason
|
||||||
|
said.hidden = false
|
||||||
|
}
|
||||||
|
// The words gave no rule: the form for one, rather than a task that cannot run.
|
||||||
|
if (!(c.rule && Object.keys(c.rule).length)) {
|
||||||
|
manual.hidden = false
|
||||||
|
manualBtn.hidden = true
|
||||||
|
}
|
||||||
|
rest.hidden = false
|
||||||
|
drawWhen()
|
||||||
|
;(c.ok ? saveBtn : instruction).focus()
|
||||||
|
} catch (err) {
|
||||||
|
said.textContent = why(err)
|
||||||
|
said.hidden = false
|
||||||
|
} finally {
|
||||||
|
readBtn.disabled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
words.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
|
||||||
|
})
|
||||||
|
saveBtn.addEventListener("click", async () => {
|
||||||
|
saveBtn.disabled = true
|
||||||
|
saveSaid.hidden = true
|
||||||
|
const body = {
|
||||||
|
request: draft.request || words.value.trim(),
|
||||||
|
title: title.value.trim(),
|
||||||
|
instruction: instruction.value.trim(),
|
||||||
|
rule: draft.rule,
|
||||||
|
target: draft.target,
|
||||||
|
model: draft.model,
|
||||||
|
effort: draft.effort,
|
||||||
|
agents: draft.agents,
|
||||||
|
notify: draft.notify,
|
||||||
|
mode: draft.mode,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = editing ? await patch(`/schedules/${enc(task.id)}`, body) : await post("/schedules", body)
|
||||||
|
go({ name: "task", id: r.schedule.id })
|
||||||
|
} catch (err) {
|
||||||
|
saveSaid.textContent = why(err)
|
||||||
|
saveSaid.hidden = false
|
||||||
|
saveBtn.disabled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
clear($("scheduled-body")).append(form)
|
||||||
|
drawSettings()
|
||||||
|
drawWhen()
|
||||||
|
words.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule set by hand, for words the server could not read into one: the days of the week (none
|
||||||
|
* ticked: every day) and a time, as the rule's calendar (`{ at: { weekdays, times } }`, Monday 0).
|
||||||
|
* `onRule(rule)` on every change; the server reads it back (preview).
|
||||||
|
*/
|
||||||
|
function manualRule(onRule) {
|
||||||
|
const lang = document.documentElement.lang || undefined
|
||||||
|
const days = Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const d = new Date(2024, 0, 1 + i) // 1 January 2024 was a Monday
|
||||||
|
return h("label", { class: "sday", title: d.toLocaleDateString(lang, { weekday: "long" }) }, h("input", { type: "checkbox", value: String(i) }), h("span", { text: d.toLocaleDateString(lang, { weekday: "short" }) }))
|
||||||
|
})
|
||||||
|
const time = h("input", { type: "time", id: "sched-time", value: "08:00", required: true })
|
||||||
|
const box = h(
|
||||||
|
"div",
|
||||||
|
{ class: "sched__manual", hidden: true },
|
||||||
|
h("div", { class: "fields" }, h("div", { class: "field" }, h("span", { class: "label", text: t("Days") }), h("div", { class: "sdays", role: "group", "aria-label": t("Days") }, days), h("p", { class: "hint", text: t("No day ticked: every day.") })), field(t("Time"), time, "", "sched-time")),
|
||||||
|
)
|
||||||
|
const changed = () => {
|
||||||
|
if (!time.value) return
|
||||||
|
const weekdays = days.map((d) => d.querySelector("input")).filter((i) => i.checked).map((i) => Number(i.value))
|
||||||
|
onRule({ at: { ...(weekdays.length ? { weekdays } : {}), times: [time.value] } })
|
||||||
|
}
|
||||||
|
box.addEventListener("change", changed)
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A field on the form's shared tracks: label, control, hint -- always all three. */
|
||||||
|
function field(label, control, hint, forId, hintId) {
|
||||||
|
return h("div", { class: "field" }, h("label", { for: forId || null, text: label }), control, h("p", { class: "hint", id: hintId || null, text: hint || "" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Live: the list as the server's event stream gives it ---------------------------------------
|
||||||
|
|
||||||
|
/** A `schedules` frame (`{ version, schedules }`), handed on by the app: redraw what it changes. */
|
||||||
|
function onSchedules(list) {
|
||||||
|
if (!Array.isArray(list?.schedules)) return
|
||||||
|
tasks = list.schedules
|
||||||
|
missing = false
|
||||||
|
if (!onPage() || popoverOpenHere()) return
|
||||||
|
if (view.name === "list") drawList()
|
||||||
|
else if (view.name === "task" && shown) {
|
||||||
|
const now = tasks.find((x) => x.id === shown.id)
|
||||||
|
if (!now) return go({ name: "list" })
|
||||||
|
const last = now.last_run || {}
|
||||||
|
if ((last.id || null) !== shown.last || (last.status || null) !== shown.status || now.running !== shown.running) draw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function popoverOpenHere() {
|
||||||
|
return !!document.querySelector(".pop .smodels")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The palette's commands for scheduled tasks. */
|
||||||
|
export function scheduledCommands() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t("New scheduled task"),
|
||||||
|
run: () => {
|
||||||
|
ctx.show()
|
||||||
|
go({ name: "form" })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initScheduled(context) {
|
||||||
|
ctx = context
|
||||||
|
listen("schedules", (e) => onSchedules(e.payload))
|
||||||
|
}
|
||||||
+86
-109
@@ -1,96 +1,81 @@
|
|||||||
// Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone
|
// Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone
|
||||||
// sees). The server and the app's sign-in, the agent and the folders it may work in, the
|
// sees). Grouped, with the groups listed beside them: the account (the server and the app's
|
||||||
// notifications, and this release.
|
// sign-in), the agent and the folders it may work in, notifications, and this release.
|
||||||
|
|
||||||
import { $, h, clear } from "./dom.js"
|
import { $, h, clear, icon, ICONS } from "./dom.js"
|
||||||
import { invoke, listen, why } from "./bridge.js"
|
import { invoke, listen } from "./bridge.js"
|
||||||
|
import { agentBlock, onAgentChange, link } from "./agent.js"
|
||||||
const AGENT = {
|
|
||||||
off: () => [t("The agent is off."), ""],
|
|
||||||
not_linked: () => [t("This computer is not linked to the server yet."), ""],
|
|
||||||
starting: () => [t("The agent is starting…"), ""],
|
|
||||||
connecting: () => [t("The agent is connecting to the server…"), ""],
|
|
||||||
running: () => [t("The agent is running: agent chats can use this computer."), "is-ok"],
|
|
||||||
waiting: (why) => [t("The agent is not connected: %(why)s. It keeps trying.", { why }), "is-error"],
|
|
||||||
refused: (why) => [t("The agent will not connect: %(why)s. Link this computer again.", { why }), "is-error"],
|
|
||||||
failed: (why) => [t("The agent stopped: %(why)s. Trying again shortly.", { why }), "is-error"],
|
|
||||||
}
|
|
||||||
|
|
||||||
let folderError = ""
|
let folderError = ""
|
||||||
let linking = null // { user_code, uri } while the computer is being linked
|
let current = () => null
|
||||||
let linkError = ""
|
|
||||||
|
|
||||||
/** The agent's state in a sentence, and its class: the CLI's word for its link. */
|
const SECTIONS = [
|
||||||
export function agentWords(s) {
|
["account", ICONS.user, () => t("Account")],
|
||||||
const a = s.agent
|
["agent", ICONS.terminal, () => t("Agent")],
|
||||||
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
|
["folders", ICONS.folder, () => t("Project folders")],
|
||||||
if (a.state === "elsewhere") {
|
["notify", ICONS.bell, () => t("Notifications")],
|
||||||
const pid = a.detail?.pid
|
["about", ICONS.info, () => t("About")],
|
||||||
if (!a.detail?.linked) return [t("A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s", { pid, why: a.detail?.why || "…" }), "is-error"]
|
]
|
||||||
if (noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
|
|
||||||
return [t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }), "is-ok"]
|
/** Bring one group into view (the title bar's agent panel opens Settings at Agent). */
|
||||||
}
|
export function scrollToSection(id) {
|
||||||
if (a.state === "running" && noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
|
const el = $(`set-${id}`)
|
||||||
return (AGENT[a.state] ?? AGENT.off)(a.detail || "…")
|
if (!el) return
|
||||||
|
el.scrollIntoView({ block: "start" })
|
||||||
|
markNav(id)
|
||||||
|
el.querySelector("button, select, input")?.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function markNav(id) {
|
||||||
|
for (const b of $("settings-nav").querySelectorAll("button")) b.setAttribute("aria-current", String(b.dataset.id === id))
|
||||||
}
|
}
|
||||||
|
|
||||||
function section(id, title, ...body) {
|
function section(id, title, ...body) {
|
||||||
return h("section", { class: "set", "aria-labelledby": id }, h("h2", { id, text: title }), body)
|
return h("section", { class: "set", id: `set-${id}`, "aria-labelledby": `set-${id}-h` }, h("h2", { class: "set__title", id: `set-${id}-h`, text: title }), h("div", { class: "set__card" }, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line of a group: its words on the left (a label and a hint), its control on the right. */
|
||||||
|
function line(label, hint, control) {
|
||||||
|
return h("div", { class: "set__line" }, h("div", { class: "set__words" }, h("p", { class: "set__label", text: label }), h("p", { class: "set__hint", text: hint || "" })), h("div", { class: "set__control" }, control))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderSettings(s) {
|
export function renderSettings(s) {
|
||||||
|
if (!s) return
|
||||||
|
const nav = clear($("settings-nav"))
|
||||||
|
for (const [id, ico, title] of SECTIONS) {
|
||||||
|
if (id === "folders" && !(s.linked && s.remote)) continue
|
||||||
|
nav.append(h("button", { type: "button", class: "navrow", dataset: { id }, onclick: () => scrollToSection(id) }, h("span", { class: "navrow__icon" }, icon(ico)), h("span", { text: title() })))
|
||||||
|
}
|
||||||
const box = clear($("settings-body"))
|
const box = clear($("settings-body"))
|
||||||
// The server and the account.
|
|
||||||
const who = s.account ? [s.account.name, s.account.email].filter(Boolean).join(" · ") : ""
|
const who = s.account ? [s.account.name, s.account.email].filter(Boolean).join(" · ") : ""
|
||||||
box.append(
|
box.append(
|
||||||
section(
|
section(
|
||||||
"set-server",
|
"account",
|
||||||
t("Server"),
|
t("Account"),
|
||||||
h("p", null, h("bdi", { text: s.server || "" }), s.discovery ? h("span", { class: "muted", text: ` · LLeMbas ${s.discovery.version}` }) : null),
|
line(t("Server"), s.discovery ? `LLeMbas ${s.discovery.version}` : "", h("bdi", { class: "set__value", text: s.server || "" })),
|
||||||
who ? h("p", { class: "muted", text: t("Signed in as %(who)s.", { who }) }) : null,
|
line(
|
||||||
h("p", { class: "muted", text: t("Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.") }),
|
who ? t("Signed in as %(who)s.", { who }) : t("Sign in"),
|
||||||
h("div", { class: "row--buttons" }, h("button", { type: "button", class: "btn", text: t("Sign out"), onclick: () => invoke("sign_out") })),
|
t("Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server."),
|
||||||
|
h("button", { type: "button", class: "btn btn--sm", text: t("Sign out"), onclick: () => invoke("sign_out") }),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// The agent.
|
// The agent: what it says, and the one thing to do about it; then start or stop, link again.
|
||||||
const [text, cls] = agentWords(s)
|
box.append(
|
||||||
const agent = section(
|
section(
|
||||||
"set-agent",
|
"agent",
|
||||||
t("Agent"),
|
t("Agent"),
|
||||||
h("p", { class: "muted", text: t("The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.") }),
|
h("p", { class: "set__intro", text: t("The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.") }),
|
||||||
h("p", { class: `status ${cls}`, "aria-live": "polite", text }),
|
agentBlock(s),
|
||||||
)
|
|
||||||
if (linking) {
|
|
||||||
agent.append(h("p", null, t("Approve this code in the page that opened in your browser:")), h("p", { class: "code", text: linking.user_code }))
|
|
||||||
}
|
|
||||||
if (linkError) agent.append(h("p", { class: "status is-error", text: linkError }))
|
|
||||||
agent.append(
|
|
||||||
h(
|
h(
|
||||||
"div",
|
"div",
|
||||||
{ class: "row--buttons" },
|
{ class: "set__buttons" },
|
||||||
h("button", {
|
h("button", { type: "button", class: "btn btn--sm", text: s.agent_wanted ? t("Stop the agent") : t("Start the agent"), disabled: !s.linked && !s.agent_wanted, onclick: () => invoke("set_agent", { on: !s.agent_wanted }) }),
|
||||||
type: "button",
|
s.linked && s.agent.state !== "refused" ? h("button", { type: "button", class: "btn btn--sm", text: t("Link again"), title: t("Link it again to give the agent a new token."), onclick: () => link() }) : null,
|
||||||
class: "btn",
|
),
|
||||||
text: s.linked ? t("Link again") : t("Link this computer"),
|
|
||||||
onclick: () => {
|
|
||||||
linkError = ""
|
|
||||||
invoke("link_computer").catch((err) => {
|
|
||||||
linkError = why(err)
|
|
||||||
renderSettings(s)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
h("button", {
|
|
||||||
type: "button",
|
|
||||||
class: "btn",
|
|
||||||
text: s.agent_wanted ? t("Stop the agent") : t("Start the agent"),
|
|
||||||
disabled: !s.linked && !s.agent_wanted,
|
|
||||||
onclick: () => invoke("set_agent", { on: !s.agent_wanted }),
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
box.append(agent)
|
|
||||||
|
|
||||||
// The folders.
|
// The folders.
|
||||||
const r = s.remote
|
const r = s.remote
|
||||||
@@ -102,14 +87,15 @@ export function renderSettings(s) {
|
|||||||
h(
|
h(
|
||||||
"li",
|
"li",
|
||||||
null,
|
null,
|
||||||
|
h("span", { class: "folders__icon" }, icon(ICONS.folder)),
|
||||||
h("span", { class: "path", title: dir }, h("bdi", { text: dir })),
|
h("span", { class: "path", title: dir }, h("bdi", { text: dir })),
|
||||||
h("button", { type: "button", class: "btn", text: t("Remove"), "aria-label": t("Remove %(dir)s", { dir }), onclick: () => invoke("remote_remove", { dir }) }),
|
h("button", { type: "button", class: "btn btn--sm", text: t("Remove"), "aria-label": t("Remove %(dir)s", { dir }), onclick: () => invoke("remote_remove", { dir }) }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const mode = h(
|
const mode = h(
|
||||||
"select",
|
"select",
|
||||||
{ id: "max-mode", onchange: (e) => invoke("remote_mode", { mode: e.target.value }) },
|
{ id: "max-mode", "aria-label": t("The most an agent chat may do here"), onchange: (e) => invoke("remote_mode", { mode: e.target.value }) },
|
||||||
[
|
[
|
||||||
["manual", t("Manual — asks first")],
|
["manual", t("Manual — asks first")],
|
||||||
["edit", t("Edit — asks before commands")],
|
["edit", t("Edit — asks before commands")],
|
||||||
@@ -117,72 +103,63 @@ export function renderSettings(s) {
|
|||||||
["plan", t("Plan — changes nothing")],
|
["plan", t("Plan — changes nothing")],
|
||||||
].map(([v, label]) => h("option", { value: v, text: label, selected: r.max_mode === v })),
|
].map(([v, label]) => h("option", { value: v, text: label, selected: r.max_mode === v })),
|
||||||
)
|
)
|
||||||
const terminal = h("input", { type: "checkbox", id: "terminal", checked: r.terminal, onchange: (e) => invoke("remote_terminal", { on: e.target.checked }) })
|
const terminal = h("input", { type: "checkbox", role: "switch", id: "terminal", checked: r.terminal, "aria-label": t("Let the server open a terminal here"), onchange: (e) => invoke("remote_terminal", { on: e.target.checked }) })
|
||||||
box.append(
|
box.append(
|
||||||
section(
|
section(
|
||||||
"set-folders",
|
"folders",
|
||||||
t("Project folders"),
|
t("Project folders"),
|
||||||
h("p", { class: "muted", text: t("Agent chats can work in these folders and anything inside them — nowhere else on this computer.") }),
|
h("p", { class: "set__intro", text: t("Agent chats can work in these folders and anything inside them — nowhere else on this computer.") }),
|
||||||
list,
|
list,
|
||||||
folderError ? h("p", { class: "status is-error", text: folderError }) : null,
|
folderError ? h("p", { class: "status is-error", text: folderError }) : null,
|
||||||
h("div", { class: "row--buttons" }, h("button", { type: "button", class: "btn btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") })),
|
h("div", { class: "set__buttons" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") })),
|
||||||
h(
|
line(t("The most an agent chat may do here"), t("A chat asking for more is lowered to this."), mode),
|
||||||
"div",
|
line(t("Let the server open a terminal here"), t("A shell as you, in one of these folders."), h("label", { class: "toggle" }, terminal)),
|
||||||
{ class: "fields" },
|
|
||||||
h("div", { class: "field" }, h("label", { for: "max-mode", text: t("The most an agent chat may do here") }), mode, h("p", { class: "hint", text: t("A chat asking for more is lowered to this.") })),
|
|
||||||
h(
|
|
||||||
"div",
|
|
||||||
{ class: "field" },
|
|
||||||
h("span", { class: "label", text: t("Terminal") }),
|
|
||||||
h("label", { class: "check" }, terminal, h("span", { text: t("Let the server open a terminal here") })),
|
|
||||||
h("p", { class: "hint", text: t("A shell as you, in one of these folders.") }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notifications.
|
|
||||||
box.append(
|
box.append(
|
||||||
section(
|
section(
|
||||||
"set-notify",
|
"notify",
|
||||||
t("Notifications"),
|
t("Notifications"),
|
||||||
h(
|
line(
|
||||||
"label",
|
t("Tell me when a reply or an agent's work is finished while I am elsewhere"),
|
||||||
{ class: "check" },
|
t("Scheduled tasks tell you of a result, too, while the app runs."),
|
||||||
h("input", { type: "checkbox", checked: s.notify, onchange: (e) => invoke("set_notify", { on: e.target.checked }) }),
|
h("label", { class: "toggle" }, h("input", { type: "checkbox", role: "switch", checked: s.notify, "aria-label": t("Notifications"), onchange: (e) => invoke("set_notify", { on: e.target.checked }) })),
|
||||||
h("span", { text: t("Tell me when a reply or an agent's work is finished while I am elsewhere") }),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// This release.
|
|
||||||
box.append(
|
box.append(
|
||||||
section(
|
section(
|
||||||
"set-about",
|
"about",
|
||||||
t("About"),
|
t("About"),
|
||||||
h("p", null, `LLeMbas Desktop ${s.version}`),
|
line(
|
||||||
h(
|
`LLeMbas Desktop ${s.version}`,
|
||||||
"div",
|
s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : "",
|
||||||
{ class: "row--buttons" },
|
h("button", { type: "button", class: "btn btn--sm", text: s.update ? t("Install LLeMbas %(version)s…", { version: s.update }) : t("Check for updates…"), onclick: () => invoke("update_app") }),
|
||||||
h("button", { type: "button", class: "btn", text: s.update ? t("Install LLeMbas %(version)s…", { version: s.update }) : t("Check for updates…"), onclick: () => invoke("update_app") }),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initSettings(current) {
|
export function initSettings(getStatus) {
|
||||||
|
current = getStatus
|
||||||
listen("remote-error", (e) => {
|
listen("remote-error", (e) => {
|
||||||
folderError = String(e.payload)
|
folderError = String(e.payload)
|
||||||
renderSettings(current())
|
if (!$("settings").hidden) renderSettings(current())
|
||||||
})
|
})
|
||||||
listen("status", () => {
|
listen("status", () => {
|
||||||
folderError = ""
|
folderError = ""
|
||||||
})
|
})
|
||||||
listen("linking", (e) => {
|
onAgentChange(() => {
|
||||||
const p = e.payload
|
if (!$("settings").hidden) renderSettings(current())
|
||||||
linking = p.state === "code" ? { user_code: p.user_code, uri: p.uri } : null
|
})
|
||||||
linkError = p.state === "failed" ? p.message : ""
|
$("settings-body").addEventListener("scroll", () => {
|
||||||
renderSettings(current())
|
// The group at the top is the one marked beside them.
|
||||||
|
const top = $("settings-body").getBoundingClientRect().top
|
||||||
|
let at = ""
|
||||||
|
for (const sec of $("settings-body").querySelectorAll(".set")) if (sec.getBoundingClientRect().top - top < 64) at = sec.id.replace(/^set-/, "")
|
||||||
|
if (at) markNav(at)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
+6
-3
@@ -1,8 +1,8 @@
|
|||||||
// Before the app: which server, and the app's sign-in to it. A server older than LLeMbas 2.0 is
|
// Before the app: which server, and the app's sign-in to it. A server older than LLeMbas 2.0 is
|
||||||
// refused here, with the reason Rust gave (src-tauri/src/server.rs, `check`). Signing in is the
|
// refused here, with the reason Rust gave (src-tauri/src/server.rs, `check`). Signing in is the
|
||||||
// server's device login: the approval page opens in the system's browser; this page shows the
|
// server's device login: the approval page opens in the system's browser; this page shows the
|
||||||
// code to compare and waits. Where the server offers it (`one_approval`) and this computer is not
|
// code to compare and waits. Where the server offers it (`one_approval`) and the agent's link does
|
||||||
// linked yet, the same approval links it for the agent: there is no second one.
|
// not work yet, the same approval links this computer for the agent: there is no second one.
|
||||||
|
|
||||||
import { $, h, clear } from "./dom.js"
|
import { $, h, clear } from "./dom.js"
|
||||||
import { invoke, listen, why } from "./bridge.js"
|
import { invoke, listen, why } from "./bridge.js"
|
||||||
@@ -61,7 +61,10 @@ export function renderSetup(s) {
|
|||||||
|
|
||||||
if (s.discovery && !s.account) {
|
if (s.discovery && !s.account) {
|
||||||
const sign = h("section", { "aria-labelledby": "setup-sign-h" }, h("h2", { id: "setup-sign-h", text: t("Sign in") }))
|
const sign = h("section", { "aria-labelledby": "setup-sign-h" }, h("h2", { id: "setup-sign-h", text: t("Sign in") }))
|
||||||
const both = s.discovery.one_approval && !s.linked
|
// The same approval links this computer unless the agent's link works already (Rust decides
|
||||||
|
// the same way, `link_works`).
|
||||||
|
const linked = s.agent?.state === "running" || (s.agent?.state === "elsewhere" && s.agent.detail?.linked)
|
||||||
|
const both = s.discovery.one_approval && !linked
|
||||||
sign.append(h("p", { class: "muted", text: both ? t("The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does, and the same approval links this computer for the agent.") : t("The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.") }))
|
sign.append(h("p", { class: "muted", text: both ? t("The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does, and the same approval links this computer for the agent.") : t("The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.") }))
|
||||||
if (s.signing_in && code) {
|
if (s.signing_in && code) {
|
||||||
sign.append(
|
sign.append(
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
// The terminal panel: a shell on the device an agent chat works on, beside the chat -- the web
|
||||||
|
// page's panel (the server's static/js/terminal.js), in this app. Two places it opens:
|
||||||
|
//
|
||||||
|
// - an open agent chat: the chat's own shell (`{ chat }`), the one the web page shares;
|
||||||
|
// - a new agent chat, as soon as its device and folder are chosen (`{ device, directory }`):
|
||||||
|
// a shell there before the first message, which the chat that message makes takes over.
|
||||||
|
//
|
||||||
|
// The socket is Rust's (bridge.js `openTerminal`): the page never holds the token. xterm is the
|
||||||
|
// server's vendored build (ui/vendor, scripts/fetch-vendor.sh), loaded the first time the panel
|
||||||
|
// opens. Never beside the activity panel: the shell's layout has one panel column (as the web
|
||||||
|
// page has), so opening one closes the other -- the shell stays open behind it.
|
||||||
|
//
|
||||||
|
// Three things about xterm the web panel learnt the hard way, kept here: it cannot fit while its
|
||||||
|
// box is hidden (so every fit waits until the panel is shown), the panel changes size without a
|
||||||
|
// window `resize` (so a ResizeObserver), and it reads no CSS variables (so the theme is read from
|
||||||
|
// the computed tokens, and again when the theme changes).
|
||||||
|
|
||||||
|
import { $, h, icon, ICONS } from "./dom.js"
|
||||||
|
import { openTerminal, why } from "./bridge.js"
|
||||||
|
|
||||||
|
const ANSI = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite"]
|
||||||
|
const MAX_MARKED = 200
|
||||||
|
|
||||||
|
let ctx = { layout: () => {}, composer: () => {} }
|
||||||
|
let target = null // what the panel is for: { key, chat } | { key, device, directory }, with `name`
|
||||||
|
let term = null
|
||||||
|
let fit = null
|
||||||
|
let session = null // the open socket's handle, and which target it is for: { handle, key, draft }
|
||||||
|
let observer = null
|
||||||
|
let loading = null
|
||||||
|
// A device's shell marks its own commands (OSC 133 A/B/C/D, OSC 7 for where it is) and says so
|
||||||
|
// in its ready frame (`osc`): then a command and its output are read from the panel's own
|
||||||
|
// buffer. Without the marks, "send" falls back to the screen as it is, and says so.
|
||||||
|
let osc = false
|
||||||
|
let pending = null
|
||||||
|
let finished = []
|
||||||
|
let visiting = -1
|
||||||
|
|
||||||
|
const panel = () => $("terminal")
|
||||||
|
|
||||||
|
function say(text, isError) {
|
||||||
|
const el = $("terminal-message")
|
||||||
|
el.textContent = text || ""
|
||||||
|
el.classList.toggle("is-error", !!isError)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the panel is shown. */
|
||||||
|
export function terminalOpen() {
|
||||||
|
return !panel().hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether there is a shell to open for what the chat page shows now. */
|
||||||
|
export function terminalAvailable() {
|
||||||
|
return !!target
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- xterm, loaded once --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (loading) return loading
|
||||||
|
loading = new Promise((resolve, reject) => {
|
||||||
|
if (typeof window.Terminal === "function" && window.FitAddon) return resolve()
|
||||||
|
document.head.append(h("link", { rel: "stylesheet", href: "vendor/xterm.css" }))
|
||||||
|
const script = (src) =>
|
||||||
|
new Promise((ok, fail) => {
|
||||||
|
const s = h("script", { src })
|
||||||
|
s.addEventListener("load", ok)
|
||||||
|
s.addEventListener("error", () => fail(new Error(src)))
|
||||||
|
document.head.append(s)
|
||||||
|
})
|
||||||
|
script("vendor/xterm.js")
|
||||||
|
.then(() => script("vendor/xterm-addon-fit.js"))
|
||||||
|
.then(resolve, reject)
|
||||||
|
})
|
||||||
|
loading.catch(() => {
|
||||||
|
loading = null
|
||||||
|
})
|
||||||
|
return loading
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTheme() {
|
||||||
|
const style = getComputedStyle(document.documentElement)
|
||||||
|
const token = (name, fallback) => (style.getPropertyValue(name) || "").trim() || fallback
|
||||||
|
const theme = {
|
||||||
|
background: token("--code-bg", "black"),
|
||||||
|
foreground: token("--ink", "white"),
|
||||||
|
cursor: token("--accent", "white"),
|
||||||
|
cursorAccent: token("--code-bg", "black"),
|
||||||
|
selectionBackground: token("--accent-soft", "gray"),
|
||||||
|
}
|
||||||
|
for (const name of ANSI) {
|
||||||
|
const value = token(`--ansi-${name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`, "")
|
||||||
|
if (value) theme[name] = value
|
||||||
|
}
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The theme changed: xterm's colours are read again. */
|
||||||
|
export function terminalThemeChanged() {
|
||||||
|
if (term) term.options.theme = readTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
if (term) return
|
||||||
|
const style = getComputedStyle(document.documentElement)
|
||||||
|
term = new window.Terminal({
|
||||||
|
allowProposedApi: true,
|
||||||
|
cursorBlink: true,
|
||||||
|
fontFamily: style.getPropertyValue("--font-mono").trim() || "monospace",
|
||||||
|
fontSize: parseFloat(style.getPropertyValue("--terminal-font-size")) || 13,
|
||||||
|
scrollback: 5000,
|
||||||
|
theme: readTheme(),
|
||||||
|
})
|
||||||
|
fit = new window.FitAddon.FitAddon()
|
||||||
|
term.loadAddon(fit)
|
||||||
|
term.open($("terminal-screen"))
|
||||||
|
term.parser.registerOscHandler(133, onCommandMark)
|
||||||
|
term.parser.registerOscHandler(7, onWhere)
|
||||||
|
term.onData((data) => session?.handle?.input(data))
|
||||||
|
// Ctrl+C is the shell's interrupt; copy and paste are the shifted pair, as in every terminal.
|
||||||
|
term.attachCustomKeyEventHandler((e) => {
|
||||||
|
if (!e.ctrlKey || !e.shiftKey || e.type !== "keydown") return true
|
||||||
|
if (osc && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
||||||
|
visit(e.key === "ArrowUp" ? -1 : 1)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const key = e.key.toLowerCase()
|
||||||
|
if (key === "c") {
|
||||||
|
const text = term.getSelection()
|
||||||
|
if (text) navigator.clipboard.writeText(text).catch(() => {})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (key === "v") {
|
||||||
|
navigator.clipboard
|
||||||
|
.readText()
|
||||||
|
.then((text) => session?.handle?.input(text))
|
||||||
|
.catch(() => {})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
observer = new ResizeObserver(() => refit())
|
||||||
|
observer.observe($("terminal-screen"))
|
||||||
|
}
|
||||||
|
|
||||||
|
function refit() {
|
||||||
|
if (!term || !fit || !terminalOpen() || !$("terminal-screen").offsetWidth) return
|
||||||
|
try {
|
||||||
|
fit.fit()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session?.handle?.resize(term.cols, term.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The socket ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function decode(b64) {
|
||||||
|
const raw = atob(b64)
|
||||||
|
const bytes = new Uint8Array(raw.length)
|
||||||
|
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i)
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
if (!target || !term || session) return
|
||||||
|
const t0 = target
|
||||||
|
// The session exists before the socket answers, so frames that come first are this one's.
|
||||||
|
const mine = { handle: null, key: t0.key, draft: !t0.chat }
|
||||||
|
session = mine
|
||||||
|
say(t("Connecting…"))
|
||||||
|
let handle
|
||||||
|
try {
|
||||||
|
handle = await openTerminal(t0.chat ? { chat: t0.chat } : { device: t0.device, directory: t0.directory }, term.cols || 80, term.rows || 24, {
|
||||||
|
onOutput: (b64) => {
|
||||||
|
if (session === mine) term.write(decode(b64))
|
||||||
|
},
|
||||||
|
onControl: (frame) => {
|
||||||
|
if (session === mine) control(frame)
|
||||||
|
},
|
||||||
|
onEnd: (end) => {
|
||||||
|
if (session !== mine) return
|
||||||
|
session = null
|
||||||
|
ended(end)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (session === mine) session = null
|
||||||
|
say(why(err), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mine.handle = handle
|
||||||
|
// Let go of meanwhile (another chat, the panel's target changed): closed at once.
|
||||||
|
if (session !== mine) handle.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function control(frame) {
|
||||||
|
if (frame.t === "ready") {
|
||||||
|
say(frame.shared ? t("Connected. This shell is also open in another tab, and they share a size.") : t("Connected."))
|
||||||
|
if (frame.dir) $("terminal-where").textContent = frame.dir
|
||||||
|
osc = !!frame.osc
|
||||||
|
showMarkControls()
|
||||||
|
showLast("")
|
||||||
|
refit()
|
||||||
|
if (terminalOpen()) term.focus()
|
||||||
|
} else if (frame.t === "behind") {
|
||||||
|
// This window stopped reading and was let go so the others kept up. The scrollback is the
|
||||||
|
// state: connecting again costs nothing.
|
||||||
|
say(frame.message || t("Reconnecting…"))
|
||||||
|
drop()
|
||||||
|
term.reset()
|
||||||
|
forgetMarks()
|
||||||
|
connect()
|
||||||
|
} else if (frame.t === "closed" || frame.t === "error") {
|
||||||
|
// No reconnect of its own: a new shell has lost the folder, the environment and the
|
||||||
|
// half-typed command, and quietly putting one in its place is worse than saying it went.
|
||||||
|
say(frame.message || t("This terminal closed."), frame.t === "error")
|
||||||
|
drop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ended(end) {
|
||||||
|
if (end.code === 4401 || end.status === 401) say(t("Sign in again: the server no longer accepts this app's sign-in."), true)
|
||||||
|
else if (end.status === 404) say(t("The terminal needs a newer LLeMbas server. Ask its administrator to update it."), true)
|
||||||
|
else if (end.error) say(end.error, true)
|
||||||
|
else say(t("Disconnected. Close and reopen to reconnect."))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Let go of the socket (the shell on the device lives on, as the server keeps it). */
|
||||||
|
function drop() {
|
||||||
|
const s = session
|
||||||
|
session = null
|
||||||
|
s?.handle?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- What the panel is for -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the panel what the chat page shows: `{ chat, name, directory }` for an open agent chat,
|
||||||
|
* `{ device, directory, name }` for a new one with its device and folder chosen, or null (no shell
|
||||||
|
* to open). A new chat's shell becomes its chat's when the first message has made the chat: the
|
||||||
|
* server took that shell over, so the socket stays as it is.
|
||||||
|
*/
|
||||||
|
export function aimTerminal(next) {
|
||||||
|
const key = next ? (next.chat ? `chat:${next.chat}` : `device:${next.device}:${next.directory}`) : ""
|
||||||
|
const before = target
|
||||||
|
target = next ? { ...next, key } : null
|
||||||
|
if (target) {
|
||||||
|
$("terminal-title").textContent = target.name || t("Terminal")
|
||||||
|
$("terminal-where").textContent = target.directory || ""
|
||||||
|
$("terminal-where").title = target.directory || ""
|
||||||
|
}
|
||||||
|
if (before?.key === key) return
|
||||||
|
// The draft's shell is the new chat's now: the same socket, said for the chat from here on.
|
||||||
|
const adopted = session?.draft && before && !before.chat && target?.chat && before.directory === target.directory && before.device === target.device
|
||||||
|
if (adopted) {
|
||||||
|
session.key = key
|
||||||
|
session.draft = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (session) {
|
||||||
|
drop()
|
||||||
|
if (term) term.reset()
|
||||||
|
forgetMarks()
|
||||||
|
say("")
|
||||||
|
}
|
||||||
|
if (!target) {
|
||||||
|
if (terminalOpen()) showTerminal(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (terminalOpen()) connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Showing it ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Show or hide the panel. Hiding keeps the shell and its socket: reopening is instant. */
|
||||||
|
export async function showTerminal(on) {
|
||||||
|
if (on && !target) return
|
||||||
|
panel().hidden = !on
|
||||||
|
ctx.layout(on)
|
||||||
|
markOpen()
|
||||||
|
if (!on) return
|
||||||
|
try {
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
say(t("The terminal could not be loaded."), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!terminalOpen()) return
|
||||||
|
build()
|
||||||
|
// A frame later: the panel has only just been shown and has no width to fit to yet.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
refit()
|
||||||
|
connect()
|
||||||
|
term.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleTerminal() {
|
||||||
|
showTerminal(!terminalOpen())
|
||||||
|
}
|
||||||
|
|
||||||
|
function markOpen() {
|
||||||
|
for (const b of document.querySelectorAll("[data-terminal-toggle]")) b.setAttribute("aria-pressed", String(terminalOpen()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The header's button for an open chat; a new chat has the chip under the composer instead. */
|
||||||
|
export function terminalButton() {
|
||||||
|
return h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
class: "tool",
|
||||||
|
"aria-label": t("Terminal"),
|
||||||
|
title: t("Terminal"),
|
||||||
|
"aria-controls": "terminal",
|
||||||
|
"aria-pressed": String(terminalOpen()),
|
||||||
|
"data-terminal-toggle": "",
|
||||||
|
onclick: toggleTerminal,
|
||||||
|
},
|
||||||
|
icon(ICONS.terminal),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- A device shell's own marks ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function onCommandMark(data) {
|
||||||
|
if (!osc || !term) return false
|
||||||
|
const [kind, code] = String(data).split(";")
|
||||||
|
if (kind === "A") pending = { prompt: term.registerMarker(0) }
|
||||||
|
else if (kind === "B") {
|
||||||
|
pending = pending || {}
|
||||||
|
pending.input = term.registerMarker(0)
|
||||||
|
pending.inputX = term.buffer.active.cursorX
|
||||||
|
} else if (kind === "C") {
|
||||||
|
pending = pending || {}
|
||||||
|
pending.output = term.registerMarker(0)
|
||||||
|
pending.command = commandText(pending)
|
||||||
|
} else if (kind === "D") {
|
||||||
|
if (pending?.output) {
|
||||||
|
pending.exit = parseInt(code, 10) || 0
|
||||||
|
pending.end = term.registerMarker(0)
|
||||||
|
commandDone(pending)
|
||||||
|
}
|
||||||
|
pending = null
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWhere(data) {
|
||||||
|
if (!osc) return false
|
||||||
|
let path = String(data).replace(/^file:\/\/[^/]*/, "")
|
||||||
|
try {
|
||||||
|
path = decodeURIComponent(path)
|
||||||
|
} catch {}
|
||||||
|
if (path) $("terminal-where").textContent = path
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function linesBetween(from, to, firstX) {
|
||||||
|
const buffer = term.buffer.active
|
||||||
|
const out = []
|
||||||
|
for (let y = from; y < to; y++) {
|
||||||
|
const line = buffer.getLine(y)
|
||||||
|
if (!line) continue
|
||||||
|
const text = line.translateToString(true, y === from ? firstX || 0 : 0)
|
||||||
|
if (line.isWrapped && out.length) out[out.length - 1] += text
|
||||||
|
else out.push(text)
|
||||||
|
}
|
||||||
|
while (out.length && !out[out.length - 1].trim()) out.pop()
|
||||||
|
return out.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandText(mark) {
|
||||||
|
if (!mark.input || !mark.output || mark.input.line < 0) return ""
|
||||||
|
return linesBetween(mark.input.line, Math.max(mark.output.line, mark.input.line + 1), mark.inputX).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputOf(mark) {
|
||||||
|
if (!mark.output || !mark.end || mark.output.line < 0 || mark.end.line < 0) return ""
|
||||||
|
return linesBetween(mark.output.line, mark.end.line, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandDone(mark) {
|
||||||
|
finished.push(mark)
|
||||||
|
if (finished.length > MAX_MARKED) finished.shift()
|
||||||
|
visiting = -1
|
||||||
|
showLast((mark.command || "…") + (mark.exit ? ` · exit ${mark.exit}` : ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockOf(mark) {
|
||||||
|
const where = $("terminal-where").textContent
|
||||||
|
const head = `${where ? `${where} ` : ""}$ ${mark.command || ""}`
|
||||||
|
return "```\n" + head + "\n" + outputOf(mark) + "\n```\n" + (mark.exit ? `exit ${mark.exit}\n` : "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastFinished() {
|
||||||
|
for (let i = finished.length - 1; i >= 0; i--) if (finished[i].output && finished[i].output.line >= 0) return finished[i]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function visit(step) {
|
||||||
|
const live = finished.filter((m) => (m.prompt || m.input || m.output)?.line >= 0)
|
||||||
|
if (!live.length) return
|
||||||
|
visiting = visiting < 0 ? live.length : visiting
|
||||||
|
visiting = Math.max(0, Math.min(live.length - 1, visiting + step))
|
||||||
|
const m = live[visiting]
|
||||||
|
term.scrollToLine((m.prompt || m.input || m.output).line)
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgetMarks() {
|
||||||
|
finished = []
|
||||||
|
pending = null
|
||||||
|
visiting = -1
|
||||||
|
osc = false
|
||||||
|
showMarkControls()
|
||||||
|
showLast("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMarkControls() {
|
||||||
|
$("terminal-prev").hidden = !osc
|
||||||
|
$("terminal-next").hidden = !osc
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLast(text) {
|
||||||
|
$("terminal-last").textContent = text || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Handing it to the chat --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** What "send" and "copy" take: the selection; else the last command (with marks); else the screen. */
|
||||||
|
function capture() {
|
||||||
|
if (!term) return ""
|
||||||
|
const chosen = term.getSelection()
|
||||||
|
if (chosen && chosen.trim()) return "```\n" + chosen + "\n```\n"
|
||||||
|
if (osc) {
|
||||||
|
const mark = lastFinished()
|
||||||
|
if (!mark) {
|
||||||
|
say(t("Nothing has been run in this shell yet."))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return blockOf(mark)
|
||||||
|
}
|
||||||
|
const buffer = term.buffer.active
|
||||||
|
const last = buffer.baseY + buffer.cursorY
|
||||||
|
const lines = []
|
||||||
|
for (let y = Math.max(0, last - 40); y <= last; y++) {
|
||||||
|
const line = buffer.getLine(y)
|
||||||
|
if (line) lines.push(line.translateToString(true))
|
||||||
|
}
|
||||||
|
const text = lines.join("\n").replace(/\n+$/, "")
|
||||||
|
if (!text.trim()) {
|
||||||
|
say(t("Nothing to send: select some output first."))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
say(t("Copied the last of the screen, as it appeared. This shell does not mark where commands begin."))
|
||||||
|
return "```\n" + text + "\n```\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Into the message box, never sent: what a machine printed is for the person to read first. */
|
||||||
|
function sendToChat() {
|
||||||
|
const text = capture()
|
||||||
|
if (!text) return
|
||||||
|
ctx.composer(text)
|
||||||
|
// Where the panel covers the chat (a narrow window), it steps aside to show the message box.
|
||||||
|
if (window.matchMedia("(max-width: 64rem)").matches) showTerminal(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyOut() {
|
||||||
|
const text = capture()
|
||||||
|
if (!text) return
|
||||||
|
navigator.clipboard.writeText(text).then(
|
||||||
|
() => say(t("Copied.")),
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initTerminal({ onLayout, composer }) {
|
||||||
|
ctx = { layout: onLayout || (() => {}), composer: composer || (() => {}) }
|
||||||
|
$("terminal-icon").replaceChildren(icon(ICONS.terminal))
|
||||||
|
$("terminal-prev").replaceChildren(icon(ICONS.chevronUp))
|
||||||
|
$("terminal-next").replaceChildren(icon(ICONS.chevronDown))
|
||||||
|
$("terminal-copy").replaceChildren(icon(ICONS.copy))
|
||||||
|
$("terminal-send").prepend(icon(ICONS.send))
|
||||||
|
$("terminal-close").replaceChildren(icon(ICONS.close))
|
||||||
|
$("terminal-prev").addEventListener("click", () => visit(-1))
|
||||||
|
$("terminal-next").addEventListener("click", () => visit(1))
|
||||||
|
$("terminal-copy").addEventListener("click", copyOut)
|
||||||
|
$("terminal-send").addEventListener("click", sendToChat)
|
||||||
|
$("terminal-close").addEventListener("click", () => showTerminal(false))
|
||||||
|
}
|
||||||
+53
-5
@@ -1,9 +1,12 @@
|
|||||||
// The title bar: the app's own, in the page (the window has no system decoration). Moving and
|
// The title bar: the app's own, in the page (the window has no system decoration). Moving and
|
||||||
// double-click maximising come from `data-tauri-drag-region`; the edges resize the window (Tauri
|
// double-click maximising come from `data-tauri-drag-region`; the edges resize the window (Tauri
|
||||||
// does that for an undecorated one); the menu and the agent pill open the app's native menu.
|
// does that for an undecorated one). The menu opens the app's native menu; the agent's chip opens
|
||||||
|
// a small panel with what the agent says and the one thing to do about it.
|
||||||
|
|
||||||
import { $, icon, ICONS } from "./dom.js"
|
import { $, h, icon, ICONS } from "./dom.js"
|
||||||
import { invoke, listen, win } from "./bridge.js"
|
import { invoke, listen, win } from "./bridge.js"
|
||||||
|
import { agentBlock, onAgentChange } from "./agent.js"
|
||||||
|
import { popover, closePopover } from "./popup.js"
|
||||||
|
|
||||||
const WORDS = {
|
const WORDS = {
|
||||||
off: () => t("agent off"),
|
off: () => t("agent off"),
|
||||||
@@ -19,8 +22,11 @@ const WORDS = {
|
|||||||
no_folders: () => t("agent: no folders"),
|
no_folders: () => t("agent: no folders"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ctx = null // { status(), openSettings() }
|
||||||
|
let pop = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pill's state: what the CLI says of its link, not whether a process runs. Linked but allowed
|
* The chip's state: what the CLI says of its link, not whether a process runs. Linked but allowed
|
||||||
* into no folder is said too -- a green light would say it works when it cannot.
|
* into no folder is said too -- a green light would say it works when it cannot.
|
||||||
*/
|
*/
|
||||||
export function pillState(s) {
|
export function pillState(s) {
|
||||||
@@ -53,6 +59,46 @@ export function renderBar(s) {
|
|||||||
$("update").hidden = !s.update
|
$("update").hidden = !s.update
|
||||||
$("update").textContent = s.update ? t("update %(version)s", { version: s.update }) : t("update")
|
$("update").textContent = s.update ? t("update %(version)s", { version: s.update }) : t("update")
|
||||||
$("update").title = s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : ""
|
$("update").title = s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : ""
|
||||||
|
pop?.redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The chip's panel: the agent's state and its button, then start or stop, and Settings. */
|
||||||
|
function agentPanel() {
|
||||||
|
const s = ctx.status()
|
||||||
|
const box = h("div", { class: "pop__body" }, h("p", { class: "pop__title", text: t("Agent") }), agentBlock(s))
|
||||||
|
const row = h("div", { class: "pop__actions" })
|
||||||
|
if (s.linked) {
|
||||||
|
row.append(h("button", { type: "button", class: "btn btn--sm", text: s.agent_wanted ? t("Stop the agent") : t("Start the agent"), onclick: () => invoke("set_agent", { on: !s.agent_wanted }) }))
|
||||||
|
}
|
||||||
|
row.append(
|
||||||
|
h("button", {
|
||||||
|
type: "button",
|
||||||
|
class: "btn btn--sm",
|
||||||
|
text: t("Settings"),
|
||||||
|
onclick: () => {
|
||||||
|
closePopover()
|
||||||
|
ctx.openSettings("agent")
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
box.append(row)
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAgent() {
|
||||||
|
if (pop) {
|
||||||
|
closePopover()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$("agent").setAttribute("aria-expanded", "true")
|
||||||
|
pop = popover($("agent"), agentPanel, {
|
||||||
|
align: "end",
|
||||||
|
label: t("Agent"),
|
||||||
|
onClose: () => {
|
||||||
|
pop = null
|
||||||
|
$("agent").setAttribute("aria-expanded", "false")
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function maximized(on) {
|
function maximized(on) {
|
||||||
@@ -68,7 +114,8 @@ function menuAt(button) {
|
|||||||
return invoke("titlebar_menu", { x: r.left, y: r.bottom })
|
return invoke("titlebar_menu", { x: r.left, y: r.bottom })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initBar() {
|
export function initBar(context) {
|
||||||
|
ctx = context
|
||||||
$("minimize").replaceChildren(icon(ICONS.min))
|
$("minimize").replaceChildren(icon(ICONS.min))
|
||||||
$("close").replaceChildren(icon(ICONS.close))
|
$("close").replaceChildren(icon(ICONS.close))
|
||||||
$("menu").replaceChildren(icon(ICONS.menu))
|
$("menu").replaceChildren(icon(ICONS.menu))
|
||||||
@@ -78,7 +125,8 @@ export function initBar() {
|
|||||||
$("close").addEventListener("click", () => win.close())
|
$("close").addEventListener("click", () => win.close())
|
||||||
$("update").addEventListener("click", () => invoke("update_app"))
|
$("update").addEventListener("click", () => invoke("update_app"))
|
||||||
$("menu").addEventListener("click", (e) => menuAt(e.currentTarget))
|
$("menu").addEventListener("click", (e) => menuAt(e.currentTarget))
|
||||||
$("agent").addEventListener("click", (e) => menuAt(e.currentTarget))
|
$("agent").addEventListener("click", toggleAgent)
|
||||||
|
onAgentChange(() => pop?.redraw())
|
||||||
listen("maximized", (e) => maximized(!!e.payload))
|
listen("maximized", (e) => maximized(!!e.payload))
|
||||||
win.isMaximized().then(maximized).catch(() => maximized(false))
|
win.isMaximized().then(maximized).catch(() => maximized(false))
|
||||||
}
|
}
|
||||||
+200
@@ -0,0 +1,200 @@
|
|||||||
|
// The subagent tree (harness spec 3.4.0, subagents.md §12) as the activity panel draws it, the
|
||||||
|
// server's own panel in this app (`chat/_activity_tasks.html` tree_row): the main agent at the
|
||||||
|
// root, every subagent under its parent in the order added, how many slots are open, then the
|
||||||
|
// ended ones folded apart, dimmed, with how each ended. A row is the tasks' grid -- chevron, who
|
||||||
|
// it is and what it is doing, its context use in the time column, a stop square -- and opened,
|
||||||
|
// its facts (state, model, mode and request options, messages, findings), its task, its phase
|
||||||
|
// line, the last room message to or from it, and the buttons to read its conversation and its
|
||||||
|
// findings. Its subagents nest under it.
|
||||||
|
//
|
||||||
|
// The data is the server's `GET /api/v2/chats/{chat}/tree` (and the stream's `tree` frame):
|
||||||
|
//
|
||||||
|
// { min, max, size, open_slots,
|
||||||
|
// agents: [{ id, name, position, parent, state, model, mode, request, manager, task,
|
||||||
|
// context: { used, window }, progress, findings, messages }],
|
||||||
|
// ended: [{ ..., ended }] }
|
||||||
|
//
|
||||||
|
// Everything in it but the numbers is a model's words: set as text, never as markup.
|
||||||
|
|
||||||
|
import { h, icon, ICONS } from "./dom.js"
|
||||||
|
import { progressLine, progressText } from "./progress.js"
|
||||||
|
|
||||||
|
/** The states' words (loop.json team.read.states; the server's TREE_STATE_WORDS). */
|
||||||
|
const STATES = {
|
||||||
|
working: "working",
|
||||||
|
waiting: "waiting for a slot",
|
||||||
|
finished: "finished",
|
||||||
|
idle: "idle",
|
||||||
|
paused: "paused: context full",
|
||||||
|
done: "done",
|
||||||
|
out_of_context: "out of context",
|
||||||
|
stopped: "stopped",
|
||||||
|
}
|
||||||
|
/** A subagent that may still be stopped (the server's TREE_LIVE). */
|
||||||
|
export const TREE_LIVE = ["working", "waiting", "paused"]
|
||||||
|
|
||||||
|
export function treeState(state) {
|
||||||
|
return STATES[state] ? t(STATES[state]) : String(state || "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** floor(used × 100 / window), or null when the window is not known. */
|
||||||
|
export function contextPercent(c) {
|
||||||
|
const used = Number(c?.used)
|
||||||
|
const window = Number(c?.window)
|
||||||
|
if (!(window > 0) || !(used >= 0)) return null
|
||||||
|
return Math.min(100, Math.floor((used * 100) / window))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The agents in tree order (an agent, then those under it, depth first), each with its depth. */
|
||||||
|
export function treeOrder(agents) {
|
||||||
|
const names = new Map()
|
||||||
|
for (const a of agents) names.set(a.id, a.id).set(a.name, a.id)
|
||||||
|
const parentOf = (a) => {
|
||||||
|
const p = a.parent ? names.get(a.parent) : null
|
||||||
|
return p && p !== a.id ? p : null
|
||||||
|
}
|
||||||
|
const under = new Map()
|
||||||
|
for (const a of agents) {
|
||||||
|
const p = parentOf(a)
|
||||||
|
if (!under.has(p)) under.set(p, [])
|
||||||
|
under.get(p).push(a)
|
||||||
|
}
|
||||||
|
const out = []
|
||||||
|
const seen = new Set()
|
||||||
|
const walk = (parent, depth) => {
|
||||||
|
for (const a of under.get(parent) || []) {
|
||||||
|
if (seen.has(a.id)) continue
|
||||||
|
seen.add(a.id)
|
||||||
|
out.push({ agent: a, depth })
|
||||||
|
walk(a.id, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(null, 0)
|
||||||
|
for (const a of agents) if (!seen.has(a.id)) out.push({ agent: a, depth: 0 })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The room's messages to or from an agent (by name). */
|
||||||
|
function messagesOf(room, a) {
|
||||||
|
return room.filter((m) => m.from === a.name || (m.to || []).includes(a.name) || (m.recipients || []).includes(a.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstLine(text) {
|
||||||
|
return String(text || "").split("\n").find((l) => l.trim()) || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tree as rows. `act` holds what the rows can do: `open(a)` (its conversation, read-only),
|
||||||
|
* `findings(a)`, `stop(a)`; `isOpen(key)` / `toggled(key, open)` keep unfolded rows unfolded
|
||||||
|
* across redraws. `room` is the room as read so far; `device` says the subagents run on the
|
||||||
|
* computer the chat runs on (an agent chat).
|
||||||
|
*/
|
||||||
|
export function treePart(tree, { room = [], act = {}, now = Date.now(), device = false } = {}) {
|
||||||
|
const box = h("div", { class: "subagents" })
|
||||||
|
box.append(h("p", { class: "tasks__team", text: t("%(size)s of %(max)s", { size: tree.size ?? (tree.agents || []).length, max: tree.max ?? "?" }) }))
|
||||||
|
if (Number(tree.open_slots) > 0) box.append(h("p", { class: "tasks__goal subagents__slots", text: t("Open slots: %(n)s", { n: tree.open_slots }) }))
|
||||||
|
if (device) box.append(h("p", { class: "tasks__goal", text: t("They run on the computer this chat runs on.") }))
|
||||||
|
box.append(h("div", { class: "tasks__list subagents__list" }, treeOrder(tree.agents || []).map(({ agent, depth }) => row(agent, depth, { room, act, now, ended: false }))))
|
||||||
|
const ended = tree.ended || []
|
||||||
|
if (ended.length) {
|
||||||
|
const key = "fold:ended"
|
||||||
|
const fold = h(
|
||||||
|
"details",
|
||||||
|
{ class: "tasks__more subagents__ended", open: !!act.isOpen?.(key) },
|
||||||
|
h("summary", null, h("span", { class: "tasks__chev", "aria-hidden": "true" }, icon(ICONS.chevron)), h("span", { text: `${t("Ended")} ${ended.length}` })),
|
||||||
|
h("div", { class: "tasks__list" }, ended.map((a) => row(a, 0, { room, act, now, ended: true }))),
|
||||||
|
)
|
||||||
|
fold.addEventListener("toggle", () => act.toggled?.(key, fold.open))
|
||||||
|
box.append(fold)
|
||||||
|
}
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(a, depth, { room, act, now, ended }) {
|
||||||
|
const key = `sub:${a.id}`
|
||||||
|
const main = !a.parent && (a.id === "lead" || a.name === "lead")
|
||||||
|
const pct = ended ? null : contextPercent(a.context)
|
||||||
|
const said = messagesOf(room, a)
|
||||||
|
const last = said[said.length - 1]
|
||||||
|
const mode = a.mode ? (a.request?.length ? `${a.mode} + ${a.request.join(", ")}` : a.mode) : ""
|
||||||
|
const live = !ended && TREE_LIVE.includes(a.state)
|
||||||
|
const phase = !ended && a.progress ? progressText(a.progress, now) : ""
|
||||||
|
const stop =
|
||||||
|
live && !main && act.stop
|
||||||
|
? h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
class: "agent__stop",
|
||||||
|
"aria-label": t("Stop %(name)s", { name: a.name }),
|
||||||
|
title: t("Stop %(name)s", { name: a.name }),
|
||||||
|
onclick: (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
act.stop(a)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
h("span", { class: "agent__square", "aria-hidden": "true" }),
|
||||||
|
)
|
||||||
|
: h("span", { class: "agent__stop is-none", "aria-hidden": "true" })
|
||||||
|
const el = h(
|
||||||
|
"details",
|
||||||
|
{ class: `agent subagent is-${String(a.state || "").replace(/[^a-z_]/g, "")}${ended ? " is-ended" : ""}`, open: !!act.isOpen?.(key), dataset: { id: a.id } },
|
||||||
|
h(
|
||||||
|
"summary",
|
||||||
|
{ class: "agent__row" },
|
||||||
|
h("span", { class: "agent__chev", "aria-hidden": "true" }, icon(ICONS.chevron)),
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ class: "agent__what" },
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ class: "subagent__who" },
|
||||||
|
h("bdi", { class: "agent__name", text: a.name || a.id }),
|
||||||
|
a.id !== a.name ? h("span", { class: "agent__id", text: a.id }) : null,
|
||||||
|
),
|
||||||
|
main ? h("span", { class: "agent__role", text: t("the main agent") }) : a.position ? h("span", { class: "agent__role", text: a.position }) : null,
|
||||||
|
// The phase alone where a row has no room for its clock (progress.md §3); ended: how.
|
||||||
|
h("span", { class: "subagent__phase", dataset: { phase: phase ? a.progress.phase : "" }, text: phase || treeState(a.state) }),
|
||||||
|
),
|
||||||
|
h("span", { class: "agent__time", title: t("Context used"), text: pct === null ? "" : `${pct}%` }),
|
||||||
|
stop,
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "agent__body" },
|
||||||
|
h(
|
||||||
|
"p",
|
||||||
|
{ class: "agent__facts" },
|
||||||
|
h("span", { class: "agent__state", text: treeState(a.state) }),
|
||||||
|
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
|
||||||
|
mode ? h("span", { text: mode }) : null,
|
||||||
|
h("span", { text: t("messages: %(n)s", { n: a.messages ?? said.length }) }),
|
||||||
|
a.findings ? h("span", { text: t("findings: %(n)s", { n: a.findings }) }) : null,
|
||||||
|
),
|
||||||
|
a.task && !main ? h("p", { class: "agent__brief", text: a.task }) : null,
|
||||||
|
phase ? h("p", { class: "agent__text subagent__line", dataset: { progress: JSON.stringify(a.progress) }, text: progressLine(a.progress, now) }) : null,
|
||||||
|
last ? h("p", { class: "agent__text", text: `${last.from} → ${(last.to || []).join(", ")}: ${firstLine(last.text)}` }) : null,
|
||||||
|
main
|
||||||
|
? null
|
||||||
|
: h(
|
||||||
|
"p",
|
||||||
|
{ class: "subagent__buttons" },
|
||||||
|
act.open ? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => act.open(a) }) : null,
|
||||||
|
act.findings ? h("button", { type: "button", class: "btn btn--sm", text: t("Findings"), onclick: () => act.findings(a) }) : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
el.style.setProperty("--depth", String(Math.max(0, Math.min(6, depth))))
|
||||||
|
el.addEventListener("toggle", () => act.toggled?.(key, el.open))
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tree's phase lines move on by themselves between reads (their clock). */
|
||||||
|
export function tickTree(root, now = Date.now()) {
|
||||||
|
for (const el of root.querySelectorAll(".subagent__line[data-progress]")) {
|
||||||
|
try {
|
||||||
|
el.textContent = progressLine(JSON.parse(el.dataset.progress), now)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
/* LLeMbas v2.0.0-beta.5 (02d0c874434e) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
|
/* LLeMbas v2.0.0-beta.8 (d06ca353b801) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
|
||||||
The font-face rules are left out; the app bundles no font. */
|
The font-face rules are left out; the app bundles no font. */
|
||||||
/*
|
/*
|
||||||
Design tokens.
|
Design tokens.
|
||||||
|
|||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||||
|
//# sourceMappingURL=addon-fit.js.map
|
||||||
Vendored
+218
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||||
|
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||||
|
* https://github.com/chjj/term.js
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*
|
||||||
|
* Originally forked from (with the author's permission):
|
||||||
|
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||||
|
* http://bellard.org/jslinux/
|
||||||
|
* Copyright (c) 2011 Fabrice Bellard
|
||||||
|
* The original design remains. The terminal itself
|
||||||
|
* has been extended to include xterm CSI codes, among
|
||||||
|
* other features.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default styles for xterm.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
.xterm {
|
||||||
|
cursor: text;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.focus,
|
||||||
|
.xterm:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helpers {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
/**
|
||||||
|
* The z-index of the helpers must be higher than the canvases in order for
|
||||||
|
* IMEs to appear on top.
|
||||||
|
*/
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helper-textarea {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
left: -9999em;
|
||||||
|
top: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
z-index: -5;
|
||||||
|
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view {
|
||||||
|
/* TODO: Composition position got messed up somewhere */
|
||||||
|
background: #000;
|
||||||
|
color: #FFF;
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-viewport {
|
||||||
|
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||||
|
background-color: #000;
|
||||||
|
overflow-y: scroll;
|
||||||
|
cursor: default;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen canvas {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-scroll-area {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-char-measure-element {
|
||||||
|
display: inline-block;
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -9999em;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.enable-mouse-events {
|
||||||
|
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.xterm-cursor-pointer,
|
||||||
|
.xterm .xterm-cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.column-select.focus {
|
||||||
|
/* Column selection mode */
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility:not(.debug),
|
||||||
|
.xterm .xterm-message {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
color: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility-tree {
|
||||||
|
user-select: text;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .live-region {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-dim {
|
||||||
|
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||||
|
* explicitly in the generated class and reset to 1 here */
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-underline-1 { text-decoration: underline; }
|
||||||
|
.xterm-underline-2 { text-decoration: double underline; }
|
||||||
|
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||||
|
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||||
|
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||||
|
|
||||||
|
.xterm-overline {
|
||||||
|
text-decoration: overline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||||
|
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||||
|
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||||
|
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||||
|
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||||
|
|
||||||
|
.xterm-strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||||
|
z-index: 6;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||||
|
z-index: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-overview-ruler {
|
||||||
|
z-index: 8;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-top {
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(9924),a=i(844),h=i(4725),c=i(2585),l=i(3656);let d=t.AccessibilityManager=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=this._coreBrowserService.mainDocument.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=this._coreBrowserService.mainDocument.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;e<this._terminal.rows;e++)this._rowElements[e]=this._createAccessibilityTreeNode(),this._rowContainer.appendChild(this._rowElements[e]);if(this._topBoundaryFocusListener=e=>this._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=this._coreBrowserService.mainDocument.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this.register((0,l.addDisposableDomListener)(document,"selectionchange",(()=>this._handleSelectionChange()))),this.register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t<e;t++)this._handleChar(" ")}_handleChar(e){this._liveRegionLineCount<21&&(this._charsToConsume.length>0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.innerText=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shiftLine truncated
|
||||||
|
//# sourceMappingURL=xterm.js.map
|
||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"tag": "v2.0.0-beta.5",
|
"tag": "v2.0.0-beta.8",
|
||||||
"commit": "02d0c874434e3b726193bf94fb9741b4f8d0a22c",
|
"commit": "d06ca353b801916302f0ce0cc9ae3e5244ff184f",
|
||||||
"files": {
|
"files": {
|
||||||
"cm-autocomplete.mjs": {"server": "26fe431a5f54e29952e094c93151f11473a80677bfb339711ecca08195a89df4", "copy": "fcb7fcbe4e2b072d031520ec898c81ac3f2f05fb4126e03003c9b5e60ffaa8de"},
|
"cm-autocomplete.mjs": {"server": "26fe431a5f54e29952e094c93151f11473a80677bfb339711ecca08195a89df4", "copy": "fcb7fcbe4e2b072d031520ec898c81ac3f2f05fb4126e03003c9b5e60ffaa8de"},
|
||||||
"cm-codemirror.mjs": {"server": "dea86a106e13c9316dfedfd29209780b853bdb2b477edf3f7c90f497b592a223", "copy": "0b80ae1289a1dc3e6718727e78bc757caad657e907d0351597867d4f90c36bd2"},
|
"cm-codemirror.mjs": {"server": "dea86a106e13c9316dfedfd29209780b853bdb2b477edf3f7c90f497b592a223", "copy": "0b80ae1289a1dc3e6718727e78bc757caad657e907d0351597867d4f90c36bd2"},
|
||||||
@@ -19,6 +19,9 @@
|
|||||||
"lezer-common.mjs": {"server": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f", "copy": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f"},
|
"lezer-common.mjs": {"server": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f", "copy": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f"},
|
||||||
"lezer-highlight.mjs": {"server": "b74148d673103ae08330a045a464acddf4ee8b563b213ea26e50f2be01a6d19d", "copy": "df21a1741ae80f2ad2b6d25860cad9ff3cda246a4fba171535d9dc92df150c1a"},
|
"lezer-highlight.mjs": {"server": "b74148d673103ae08330a045a464acddf4ee8b563b213ea26e50f2be01a6d19d", "copy": "df21a1741ae80f2ad2b6d25860cad9ff3cda246a4fba171535d9dc92df150c1a"},
|
||||||
"lezer-lr.mjs": {"server": "4c2dcc61b5c3aedb091dfd60ce8abaeb94704b58fa9036547b0b8d2c75f701e4", "copy": "f41bc21dc3fd874132e1d9862aae57946ae2604d91330303924c03aecfd3bc21"},
|
"lezer-lr.mjs": {"server": "4c2dcc61b5c3aedb091dfd60ce8abaeb94704b58fa9036547b0b8d2c75f701e4", "copy": "f41bc21dc3fd874132e1d9862aae57946ae2604d91330303924c03aecfd3bc21"},
|
||||||
|
"xterm.js": {"server": "1f991ac3b4b283ebf96e60ae23a00a52765dd3a2e46fa6fdda9f1aab032f7495", "copy": "1f991ac3b4b283ebf96e60ae23a00a52765dd3a2e46fa6fdda9f1aab032f7495"},
|
||||||
|
"xterm.css": {"server": "ba8e6985669488981ccf40c0cefe3aba80722cb6c92de7ad628b0bd717faf2b6", "copy": "ba8e6985669488981ccf40c0cefe3aba80722cb6c92de7ad628b0bd717faf2b6"},
|
||||||
|
"xterm-addon-fit.js": {"server": "bdaefa370b1bfc42ee88d46fe6072400902a4d4b2d45cd93438dda9b23c97089", "copy": "bdaefa370b1bfc42ee88d46fe6072400902a4d4b2d45cd93438dda9b23c97089"},
|
||||||
"node-process.mjs": {"server": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981", "copy": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981"}
|
"node-process.mjs": {"server": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981", "copy": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user