diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 13207b8147..4a0f2eb8f9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -368,7 +368,10 @@ jobs:
libayatana-appindicator3-dev \
librsvg2-dev \
xdg-utils \
- patchelf
+ patchelf \
+ gstreamer1.0-plugins-base \
+ gstreamer1.0-plugins-good \
+ gstreamer1.0-alsa
- name: Verify toolchain versions
run: |
@@ -411,10 +414,68 @@ jobs:
if: matrix.platform == 'win'
run: node scripts/run-msvc.mjs cargo test -p bibcode-server --test git_system_config -j 2
+ # #341 was reported on Windows: background Git must not lock the index there either.
+ - name: Run background Git index lock tests (Windows)
+ if: matrix.platform == 'win'
+ run: node scripts/run-msvc.mjs cargo test -p bibcode-server --lib -j 2 -- background_status_ git_stop_ index_lock_cleanup_
+
+ # #293: a review prompt reaches node through an npm-style .cmd shim with every class intact.
+ - name: Run review prompt argument tests (Windows)
+ if: matrix.platform == 'win'
+ run: node scripts/run-msvc.mjs cargo test -p bibcode-server --test windows_terminal_shims -j 2 -- review_prompt_shim_
+
- name: Run Git system configuration tests (macOS)
if: matrix.platform == 'mac'
run: cargo test -p bibcode-server --test git_system_config -j 2
+ - name: Run background Git index lock tests (macOS)
+ if: matrix.platform == 'mac'
+ run: cargo test -p bibcode-server --lib -j 2 -- background_status_ git_stop_ index_lock_cleanup_
+
+ # #292: the host collector, its exit with a disk read held, and its runtime start and
+ # quiesce. Each command fails the step when its filter matches no test.
+ - name: Run host metrics tests (Windows)
+ if: matrix.platform == 'win'
+ shell: bash
+ run: |
+ counted() {
+ local expected="$1"; shift
+ local log; log="$(mktemp)"
+ "$@" 2>&1 | tee "$log"
+ local passed
+ passed="$(sed -n 's/^test result: ok\. \([0-9]*\) passed.*/\1/p' "$log" | awk '{ total += $1 } END { print total + 0 }')"
+ if [ "$expected" = exactly-one ] && [ "$passed" -ne 1 ]; then
+ echo "::error::expected exactly 1 passed test, got $passed"; exit 1
+ fi
+ if [ "$passed" -lt 1 ]; then
+ echo "::error::the filter matched no test"; exit 1
+ fi
+ }
+ counted at-least-one node scripts/run-msvc.mjs cargo test -p bibcode-server --lib -j 2 -- host_metrics::
+ counted exactly-one node scripts/run-msvc.mjs cargo test -p bibcode-server --test host_metrics_exit -j 2 -- --exact a_held_disk_read_does_not_block_exit
+ counted exactly-one node scripts/run-msvc.mjs cargo test -p bibcode-server --lib -j 2 -- --exact production::runtime::tests::host_metrics_start_with_the_runtime_and_stop_in_quiesce
+
+ - name: Run host metrics tests (macOS)
+ if: matrix.platform == 'mac'
+ shell: bash
+ run: |
+ counted() {
+ local expected="$1"; shift
+ local log; log="$(mktemp)"
+ "$@" 2>&1 | tee "$log"
+ local passed
+ passed="$(sed -n 's/^test result: ok\. \([0-9]*\) passed.*/\1/p' "$log" | awk '{ total += $1 } END { print total + 0 }')"
+ if [ "$expected" = exactly-one ] && [ "$passed" -ne 1 ]; then
+ echo "::error::expected exactly 1 passed test, got $passed"; exit 1
+ fi
+ if [ "$passed" -lt 1 ]; then
+ echo "::error::the filter matched no test"; exit 1
+ fi
+ }
+ counted at-least-one cargo test -p bibcode-server --lib -j 2 -- host_metrics::
+ counted exactly-one cargo test -p bibcode-server --test host_metrics_exit -j 2 -- --exact a_held_disk_read_does_not_block_exit
+ counted exactly-one cargo test -p bibcode-server --lib -j 2 -- --exact production::runtime::tests::host_metrics_start_with_the_runtime_and_stop_in_quiesce
+
# build-desktop-artifact.ts --symbols runs dump_syms on the unstripped binary. Rust Cache
# keeps ~/.cargo/bin, so a restored runner skips the compile.
- name: Install dump_syms
diff --git a/.github/workflows/desktop-ui-smoke.yml b/.github/workflows/desktop-ui-smoke.yml
index 8fa85777e8..f35681c10a 100644
--- a/.github/workflows/desktop-ui-smoke.yml
+++ b/.github/workflows/desktop-ui-smoke.yml
@@ -93,8 +93,12 @@ jobs:
librsvg2-dev \
xdg-utils \
patchelf \
+ gstreamer1.0-plugins-base \
+ gstreamer1.0-plugins-good \
+ gstreamer1.0-alsa \
xvfb \
- xdotool
+ xdotool \
+ lsof
- name: Install frozen dependencies
run: vp install --frozen-lockfile
@@ -139,6 +143,24 @@ jobs:
exit 1
fi
+ # Audio in Files needs the bundled GStreamer core to find its own plugins: exactly the
+ # wrapper's allowlist, nothing else, plus the scanner and the AppRun hook.
+ for required in \
+ usr/lib/gstreamer1.0/gstreamer-1.0/gst-plugin-scanner \
+ apprun-hooks/linuxdeploy-plugin-gstreamer.sh; do
+ if [[ ! -f "$extract_directory/squashfs-root/$required" ]]; then
+ printf 'Linux AppImage is missing GStreamer file: %s\n' "$required" >&2
+ exit 1
+ fi
+ done
+ expected_plugins="$(bash scripts/tauri/linuxdeploy-plugin-gstreamer.sh --list-plugins | LC_ALL=C sort)"
+ bundled_plugins="$(ls -1 "$extract_directory/squashfs-root/usr/lib/gstreamer-1.0" | LC_ALL=C sort)"
+ if [[ "$bundled_plugins" != "$expected_plugins" ]]; then
+ printf 'Linux AppImage GStreamer plugins differ from the allowlist:\n' >&2
+ diff <(printf '%s\n' "$expected_plugins") <(printf '%s\n' "$bundled_plugins") >&2 || true
+ exit 1
+ fi
+
- name: Install macOS application from DMG build
if: matrix.platform == 'mac'
shell: bash
diff --git a/.github/workflows/desktop-upgrade-smoke.yml b/.github/workflows/desktop-upgrade-smoke.yml
index 223a9d6e78..735cb9cb11 100644
--- a/.github/workflows/desktop-upgrade-smoke.yml
+++ b/.github/workflows/desktop-upgrade-smoke.yml
@@ -118,7 +118,9 @@ jobs:
s''udo apt-get install -y \
build-essential curl wget file libxdo-dev libssl-dev \
libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \
- librsvg2-dev xdg-utils patchelf xvfb xdotool
+ librsvg2-dev xdg-utils patchelf xvfb xdotool \
+ gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
+ gstreamer1.0-alsa
- name: Install frozen dependencies
run: vp install --frozen-lockfile
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2f13fc8db2..79e47b3f6c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -470,7 +470,10 @@ jobs:
libayatana-appindicator3-dev \
librsvg2-dev \
xdg-utils \
- patchelf
+ patchelf \
+ gstreamer1.0-plugins-base \
+ gstreamer1.0-plugins-good \
+ gstreamer1.0-alsa
- name: Verify stable updater signing configuration
if: needs.preflight.outputs.is_update_candidate == 'true'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f7c571c85..21dc024e79 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,55 @@
# Changelog
+## Unreleased
+
+### Diagnostics
+
+- Each environment has a Host dashboard (the environment card's menu, Settings > Remote Servers, and the status bar's Resource Manager) with its host's CPU, memory and volumes, live and since the server started. It lists the volumes it hides and why, shows per-volume statistics, never reads network volumes, and says when the volume list is not answering. Agents can read the same figures with the `host_metrics` MCP tool. (#292)
+
+### Pipelines
+
+- Stage, layer and Jobs headers in the pipeline detail view now read as group labels on their own strip, apart from the job rows below them, in the wide and the stacked layout; each is still one heading with its status, name and job count. (#340)
+
+### Git
+
+- A push that fails for missing credentials on a remote server now says that Git ran on that server, names it, and tells you to run `git push` in a terminal there; Source Control and the Git Manager show the same guidance. (#343)
+- Background status reads no longer take Git's index lock, so your own `git add` or `git commit` in a terminal no longer fails with "index.lock: File exists" while BiBCode is open. On Linux and macOS BiBCode asks Git to stop before it forces it; on Windows it forces it as before. Either way it then removes an `index.lock` that its own stopped process left and that nothing else is using, including when it quits. (#341)
+- Local status for a worktree is delivered while a stacked action or a Git Manager push, publish, force push or remote branch delete waits on the network. Writes stay one at a time; pull and fetch still wait. (#345)
+- The profiler warns about a leftover worktree folder only when it shows worktree evidence, one finding per folder, and the warning ends when the folder is gone. BiBCode records the worktrees it creates, so folders that were never worktrees no longer warn every ten minutes. (#344)
+- Create worktree starts fresh every time it opens, after a creation or a close: the last Name, Create From, mode, base override, Advanced, Create more and agent pick no longer come back. Input stays while the dialog is open, and Create more still keeps the project, mode, agent and base override for its batch. (#346)
+- Source Control has a **Review changes** button that starts a code review of your branch and its uncommitted changes in a chat panel beside the thread or in an AI Terminal. A Claude review starts in Claude's plan mode, a Codex review runs in Codex's read-only sandbox, other chat agents ask before edits or are only told not to edit, and Settings → Agents and the button's tooltip say which applies. A review's findings offer no Implement. The review prompt can be set per user in Settings → Agents and per project from the button's menu, which also starts a review once with another agent or with added instructions. Mod+Alt+Shift+R starts a review from the keyboard. On Windows, a terminal review gets the prompt with its line breaks as spaces. (#293)
+
+### Chats
+
+- A new worktree's or project's chat lists only its own provider's models in the composer, before the first message too; to use another provider, add a chat panel from the chat header `+`. A new chat that has not been sent still lists every ready provider. A chat whose provider is disabled or removed now says so above the composer and keeps Send unavailable, and **Add as project** in Files opens the new Main on your Default Agent instead of Codex. (#347)
+
+### Files
+
+- Markdown opens editable in rendered view, with a formatting group in the Files toolbar (paragraph, headings 1 to 5, bold, italic, strikethrough, bullet, numbered and task lists, block quote, a link dialog, Insert image next to the file, collapsible section). Undo and Redo work in both views and a save changes only what you edited; frontmatter and constructs the editor cannot represent stay as written or read-only. (#342)
+- Images a Markdown file links by a relative path show in the editable rendered view, read from the project relative to the file, in the desktop app, the browser and remote environments; an inserted image shows at once, and a path outside the project is not read. (#345)
+- Ticking a task checkbox in a read-only Markdown or `.mdx` file keeps the file's source Undo history. (#342)
+- Mermaid diagrams in an editable rendered Markdown file are drawn again, as one block that is not edited in place; the file's source is kept as written. (#342)
+- The Linux AppImage now carries its own GStreamer plugins for the audio Files previews in WAV, MP3, Ogg, WebM and FLAC files, instead of relying on the system's plugins, which its bundled GStreamer cannot load on newer distributions. M4A is not covered: its AAC decoder is not bundled.
+- A PDF preview in the Files panel is no longer blocked by the page's content security policy in Chromium-based webviews and browsers: the Windows desktop app, and browser mode in Chrome or Edge.
+
+### Appearance
+
+- Settings > Appearance lists six built-in themes (Tokyo Night, Matrix Dark, Matrix Light, Verdant Light and the existing Light and Dark) and lets you duplicate, edit with a live preview, import, export and delete custom themes, with contrast warnings. New terminal sessions and the pipeline job log can follow the theme's terminal colors, and a theme can also set scrollbars, focus rings, control shadows and selected rows. (#290)
+- Clicking a theme row applies it and, when the theme's base differs from the mode on screen, switches the mode with an Undo that restores the previous mode and theme. (#290)
+- The left panel draws its repository, environment, primary checkout, worktree and chat rows as five visually distinct levels in every built-in theme, and a theme can style each level. (#290)
+- On the desktop app, exporting a theme or plan opens the native Save dialog, and closing, quitting, restarting or installing an update with unsaved Files edits asks first with Save all, Discard and Cancel. (#290, #342)
+
+### Terminals
+
+- When you return to a Claude AI Terminal on a macOS or Linux server after it printed more than BiBCode keeps for a redraw (1 MiB or 5,000 lines, or 512 KiB in the client), BiBCode asks Claude to redraw its screen, the way it does when resumed, so characters missing from the replay come back. This is a best-effort repaint for Claude terminals BiBCode starts, not a rebuild of the terminal's state; other programs and Windows servers still need a panel resize, and a Claude session you suspended in that terminal resumes. (#348)
+- A terminal that kept an old row count, so an AI CLI lost its bottom lines or footer until you changed the panel's width, now corrects itself: the server confirms each size in order with the output, a size it did not apply never resizes the view, and a window whose connection dropped gets the terminal's size back when it reconnects, unless someone typed in the other window meanwhile. (#348)
+- A terminal laid out with no width or height, such as a center terminal behind a maximized right panel, keeps its size instead of resizing its program to 2 columns or 1 row. (#348)
+- A terminal refits when the display scale changes, for example after moving the window to another display, so a bottom row clipped by the new scale's row height no longer stays cut off until a panel is resized. (#348)
+- When resizing a terminal's program fails, BiBCode retries it a few times, so the program no longer keeps its old size, with its bottom rows cut off, until the next panel resize or keypress. (#348)
+- Opening, collapsing or dragging a panel no longer resizes its terminal's program to 2 or 5 columns on the way: a terminal takes a new size once its panel holds still for a moment, and a small panel that holds still still sizes it. (#348)
+- A terminal that was off-screen or hidden while its program kept printing after its rows shrank comes back showing its last lines and footer, instead of a view stuck rows above them until you changed a panel's size; a terminal you had scrolled up to read stays where you left it. (#348)
+- When two windows, for example on two machines, show the same terminal, the window you last used sizes it and the other one shows the whole terminal scaled down to fit instead of cutting off its bottom rows; a window nobody is clicking or typing in, such as an idle server host window, no longer takes the size back when it regains focus, where its web view reports recent input. A click or **Fit to this window** that takes the size leaves the terminal focused, so the first keys you type reach the program. (#348)
+
## [v0.15.3] - 2026-11-08
BiBCode v0.15.3 guards checkpoint restores in shared checkouts, offers Git Credential Manager sign-in on the desktop, keeps named MCP clients across runs, reworks the Pipelines and Files views, loads pipelines for self-hosted GitLab on any port, and hardens load-sensitive tests and secret scanning. This release includes PR #337 since v0.15.2.
diff --git a/Cargo.toml b/Cargo.toml
index 3327148d09..530d4329e7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -83,7 +83,10 @@ serde_json = "1"
sha2 = "0.11.0"
snow = "0.10"
socket2 = "0.6.5"
-sysinfo = "0.39.6"
+# Network and tmpfs mounts are listed so the host collector's own filter can hide and disclose
+# them (R81); listing makes no `statvfs` call. On macOS sysinfo still skips network volumes, so
+# the collector adds them from `getfsstat(MNT_NOWAIT)`.
+sysinfo = { version = "0.39.6", features = ["linux-netdevs", "linux-tmpfs"] }
subtle = "2.6.1"
bibcode-server = { path = "apps/server" }
tauri = { version = "~2.11.6", features = ["unstable", "devtools", "image-png"] }
diff --git a/UI.md b/UI.md
index 974364f22d..bf901d349c 100644
--- a/UI.md
+++ b/UI.md
@@ -170,7 +170,7 @@ Optimize for user control, predictability, and task completion. Every surprise,
### Typography
- The smallest text in the app is `text-xs` (12px). Never write `text-[9px]`, `text-[10px]`, `text-[11px]`, or another arbitrary size below it; badges, key hints, counts, and timestamps use `text-xs`.
-- Navigation rows are 13px medium and thread titles 13px regular, both on `text-foreground/80`; project titles are 13px medium on `text-foreground/90`.
+- Navigation rows are 13px medium on `text-foreground/80`; project titles are 13px medium on `text-foreground/90`. Sidebar chat rows are 12px regular on `sidebar-chat-foreground`, so a chat reads as lighter than the checkout or worktree it belongs to; an unread chat is drawn at `foreground`.
- `text-muted-foreground` is already the secondary color. Do not alpha-reduce it further for text (`text-muted-foreground/70` and similar); badges, subtitles, timestamps, and empty states use the solid token. Alpha is fine on icon tints.
- Do not add global letter-spacing to body text; under hinted rendering the fractional advances become uneven letter gaps.
- A new scrollable reading surface must mark its content wrapper with `data-text-surface`, or its text renders grayscale on Linux (see the architecture overview, Linux webview text rendering).
diff --git a/apps/desktop/THIRD_PARTY_NOTICES.md b/apps/desktop/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000000..f66fd18077
--- /dev/null
+++ b/apps/desktop/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,102 @@
+# Third-Party Notices: Linux AppImage native components
+
+The Linux AppImage carries shared libraries copied unmodified from its Ubuntu
+22.04 build host, except that linuxdeploy and the GStreamer plugin rewrite their
+run paths with `patchelf`. BiBCode and WebKitGTK load them dynamically. Each
+component's corresponding source is the Ubuntu 22.04 (jammy) source package
+named below, available from and from
+the upstream project.
+
+This file lives in the repository; it is not yet shipped inside the AppImage.
+
+This file covers the GStreamer plugins and the libraries bundled for them. It
+does not yet cover the components every AppImage already carried before them
+(WebKitGTK, GTK, GLib, the GStreamer core and base libraries, and their
+dependencies); see [Not yet recorded](#not-yet-recorded).
+
+## GStreamer plugins
+
+The plugin list is `allowed_plugins` in
+`scripts/tauri/linuxdeploy-plugin-gstreamer.sh`. Every plugin comes from one of
+three source packages, and each source package's Ubuntu copyright file is the
+licence source:
+
+| Source package (jammy version) | Plugins | Licence | Copyright file |
+| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------- |
+| `gstreamer1.0` (1.20.3-0ubuntu1.1) | `libgstcoreelements.so` | LGPL-2+ | |
+| `gst-plugins-base1.0` (1.20.1-1ubuntu0.7) | `libgstplayback.so`, `libgsttypefindfunctions.so`, `libgstapp.so`, `libgstaudioconvert.so`, `libgstaudioresample.so`, `libgstvolume.so`, `libgstogg.so`, `libgstvorbis.so`, `libgstopus.so`, `libgstalsa.so` | LGPL-2+ | |
+| `gst-plugins-good1.0` (1.20.3-0ubuntu1.10) | `libgstautodetect.so`, `libgstpulseaudio.so`, `libgstaudiofx.so`, `libgstwavparse.so`, `libgstaudioparsers.so`, `libgstid3demux.so`, `libgstmpg123.so`, `libgstflac.so`, `libgstmatroska.so` | LGPL-2+ | |
+
+In those copyright files the only non-LGPL entries are build tooling
+(`ltmain.sh`, `common/coverage/coverage-report-entry.pl`, GPL-2+) and, in the
+core, the Bison-generated `gst/parse/grammar.tab.h` (GPL-3+), which belongs to
+the core library rather than to `libgstcoreelements.so`. Whether that header
+carries Bison's output exception is unverified; the lab confirms it, and that
+no listed plugin is built from a GPL file, against
+`/usr/share/doc//copyright` on the build host.
+
+## Libraries bundled for the plugins
+
+linuxdeploy copies each plugin's shared-library dependencies that its exclude
+list does not leave to the host. The libraries below were new in the AppImage
+once the plugins were bundled: confirmed from the built AppImage at 34f309e4d in
+the Linux lab, by comparing its bundled libraries with the AppImage built before
+the plugins. Each licence is the one the package's jammy copyright file states.
+
+| Library (binary package) | Needed by | Licence (jammy copyright file) | Copyright file (jammy) |
+| ---------------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
+| `libmpg123.so.0` (`libmpg123-0`) | `libgstmpg123.so` | LGPL-2.1 | |
+| `libFLAC.so.8` (`libflac8`) | `libgstflac.so` | See [FLAC](#flac) | |
+| `libogg.so.0` (`libogg0`) | `libgstogg.so`, `libgstvorbis.so` | BSD-style ("Redistribution and use in source and binary forms, with or without modification") | |
+| `libopus.so.0` (`libopus0`) | `libgstopus.so` | BSD-style ("Redistribution and use in source and binary forms, with or without modification") | |
+| `libvorbis.so.0`, `libvorbisenc.so.2` (`libvorbis0a`, `libvorbisenc2`) | `libgstvorbis.so` | BSD-3-Clause | |
+| `libpulse.so.0`, `libpulsecommon-15.99.so` (`libpulse0`) | `libgstpulseaudio.so` | LGPL-2.1+ | |
+| `libsndfile.so.1` (`libsndfile1`) | `libpulse.so.0` | LGPL-2.1+ | |
+| `libasyncns.so.0` (`libasyncns0`) | `libpulse.so.0` | LGPL-2.1+ | |
+| `libapparmor.so.1` (`libapparmor1`) | `libpulse.so.0` | LGPL-2.1+ (`libraries/libapparmor/*`) | |
+| `libgstriff-1.0.so.0` (`libgstreamer-plugins-base1.0-0`) | `libgstwavparse.so` and other demuxers | LGPL-2+ | |
+
+Other files in some of these source packages carry other licences but are not
+part of the bundled libraries:
+
+- `pulseaudio`: `src/modules/bluetooth/proximity-helper.c` is GPL-2+; it is a
+ PulseAudio module, not part of `libpulse` or `libpulsecommon`.
+- `libsndfile`: besides the LGPL-2.1+ library sources, the package holds the
+ GSM610 code (its own `gsm` licence), the ALAC code (Apache-2.0) and the G72x
+ code (Sun's licence), which are compiled into `libsndfile.so.1`, and a GPL-2+
+ test file (`src/G72x/g72x_test.c`), which is not.
+- `apparmor`: the package's `Files: *` stanza is GPL-2+; `libapparmor` is
+ covered by the `libraries/libapparmor/*` stanza, LGPL-2.1+.
+
+### FLAC
+
+Ubuntu's jammy copyright file for `flac` (1.3.3-2ubuntu0.2) gives `Files: *`
+as "GPL-2+ or LGPL-2.1+", `examples/*` as GPL-2+ and `doc/*` as GFDL-1.1+, and
+has a separate stanza listing `src/libFLAC/*` (with `include/FLAC/*` and
+`src/libFLAC++/*`) as BSD-3-clause. Upstream FLAC 1.3.3's `README` says the
+same: "The libraries (libFLAC, libFLAC++) are licensed under Xiph.org's BSD-like
+license (see COPYING.Xiph)". The lab's reading of the copyright file on its
+build host showed only the `Files: *` stanza; if a host's file really lacks the
+`src/libFLAC/*` stanza, the libFLAC licence rests on the upstream statement.
+
+### Libraries the plugins use from the host
+
+linuxdeploy's exclude list keeps these on the host, so the AppImage does not
+carry them: `libz.so.1`, `libasound.so.2` (the ALSA plugin therefore uses the
+host's `libasound.so.2`), `libpulse-mainloop-glib.so.0` and `libwrap.so.0`.
+
+### Already bundled before the plugins
+
+`liborc-0.4.so.0` (`liborc-0.4-0`) and `libbz2.so.1.0` (`libbz2-1.0`), which
+some plugins use, were already in the AppImage before the plugins were added;
+they fall under [Not yet recorded](#not-yet-recorded).
+
+`libmp3lame` (LAME) is not bundled, as the lab confirmed: its only consumer,
+the `lame` encoder plugin, is not on the allowlist, and its jammy copyright file
+lists a GPL file (`libmp3lame/fft.c`).
+
+## Not yet recorded
+
+The AppImage already bundled WebKitGTK, GTK, GLib, the GStreamer core and base
+libraries, and their dependencies before these plugins were added, and no
+notice records them. That gap predates this file and remains open.
diff --git a/apps/desktop/e2e/specs/appearance-themes.e2e.ts b/apps/desktop/e2e/specs/appearance-themes.e2e.ts
new file mode 100644
index 0000000000..81c5d75874
--- /dev/null
+++ b/apps/desktop/e2e/specs/appearance-themes.e2e.ts
@@ -0,0 +1,275 @@
+import { refreshDesktopUiDocument } from "../support/document-navigation.ts";
+import { desktopUiFixture } from "../support/test-project.ts";
+import { ensureMainSidebarOpen, mockDesktopUiFolderPicker } from "../support/ui-state.ts";
+
+const projectPath = process.env.BIBCODE_E2E_PROJECT_PATH;
+if (!projectPath) {
+ throw new Error("The packaged desktop themes fixture environment was not prepared.");
+}
+const preparedProjectPath: string = projectPath;
+const visibleSurfaceSelector = '[data-center-surface-host][data-visible="true"]';
+const composerEditorSelector = `${visibleSurfaceSelector} [data-chat-composer-form="true"] [data-testid="composer-editor"]`;
+
+type Mode = "System" | "Light" | "Dark";
+
+async function openFixtureWorkspace(): Promise {
+ // Settings shows its own sidebar; the project list is on the app's start route.
+ await browser.execute(() => {
+ if (window.location.hash.startsWith("#/settings")) window.location.hash = "#/";
+ });
+ await ensureMainSidebarOpen();
+ const project = browser.$(
+ `//button[.//span[normalize-space()="${desktopUiFixture.projectName}"]]`,
+ );
+ if (!(await project.isDisplayed())) {
+ const projectDataLoading = browser.$("//*[normalize-space()='Project data is still loading']");
+ if (await projectDataLoading.isExisting()) {
+ await projectDataLoading.waitForDisplayed({ reverse: true });
+ }
+ const addProject = browser.$('[data-testid="sidebar-add-project-trigger"]');
+ await addProject.waitForDisplayed();
+ await addProject.click();
+ const browseFolder = browser.$(
+ "//button[@data-add-project-action='true'][.//span[normalize-space()='Browse folder']]",
+ );
+ await browseFolder.waitForDisplayed();
+ await mockDesktopUiFolderPicker(preparedProjectPath);
+ await browseFolder.click();
+ }
+ await project.waitForDisplayed();
+ const primaryWorkspace = browser.$(
+ '//*[@data-thread-item="true"][.//span[normalize-space()="main"]]',
+ );
+ if ((await project.getAttribute("aria-expanded")) !== "true") {
+ await project.click();
+ }
+ await primaryWorkspace.waitForDisplayed();
+ await primaryWorkspace.click();
+ await browser.$(composerEditorSelector).waitForDisplayed();
+}
+
+async function openAppearance(): Promise {
+ await browser.execute(() => {
+ window.location.hash = "#/settings/appearance";
+ });
+ await browser.$('[role="group"][aria-label="Mode"]').waitForDisplayed();
+}
+
+async function setMode(mode: Mode): Promise {
+ const button = await browser.$(
+ `//*[@role="group" and @aria-label="Mode"]//button[normalize-space()="${mode}"]`,
+ );
+ await button.scrollIntoView();
+ await button.click();
+ await waitForMode(mode);
+}
+
+async function waitForMode(mode: Mode): Promise {
+ await browser.waitUntil(
+ async () =>
+ (await browser
+ .$(`//*[@role="group" and @aria-label="Mode"]//button[normalize-space()="${mode}"]`)
+ .getAttribute("aria-pressed")) === "true",
+ { timeoutMsg: `The Mode group never showed ${mode} as pressed.` },
+ );
+}
+
+function themeRow(name: string): ReturnType {
+ return browser.$(
+ `//li[@data-theme-row]/button[@data-theme-select][.//span[normalize-space()="${name}"]]`,
+ );
+}
+
+async function selectTheme(name: string): Promise {
+ const row = await themeRow(name);
+ await row.scrollIntoView();
+ await row.click();
+ await browser.waitUntil(
+ async () => (await themeRow(name).getAttribute("aria-pressed")) === "true",
+ {
+ timeoutMsg: `The ${name} row never showed as in use.`,
+ },
+ );
+}
+
+async function chooseRowAction(themeName: string, action: string): Promise {
+ const more = await browser.$(`button[aria-label="More actions for ${themeName}"]`);
+ await more.scrollIntoView();
+ await more.click();
+ const item = await browser.$(`//*[@role="menuitem" and normalize-space()="${action}"]`);
+ await item.waitForDisplayed();
+ await item.click();
+}
+
+async function rootToken(name: string): Promise {
+ return browser.execute(
+ (property: string) =>
+ getComputedStyle(document.documentElement).getPropertyValue(property).trim(),
+ name,
+ );
+}
+
+async function waitForRootToken(name: string, value: string): Promise {
+ await browser.waitUntil(async () => (await rootToken(name)) === value, {
+ timeoutMsg: `The root ${name} never became ${value} (last ${await rootToken(name)}).`,
+ });
+}
+
+async function rootIsDark(): Promise {
+ return browser.execute(() => document.documentElement.classList.contains("dark"));
+}
+
+async function themeField(token: string) {
+ const input = await browser.$(`[data-theme-field="ui.${token}"] input:not([type="color"])`);
+ await input.scrollIntoView();
+ return input;
+}
+
+async function editorButton(label: string) {
+ const button = await browser.$(`//*[@data-theme-editor]//button[normalize-space()="${label}"]`);
+ await button.scrollIntoView();
+ return button;
+}
+
+async function sidebarLevels(): Promise<{ repo: boolean; checkout: boolean; chat: boolean }> {
+ return browser.execute(() => ({
+ repo: document.querySelector('[data-sidebar-level="repo"]') !== null,
+ checkout: document.querySelector('[data-sidebar-level="checkout"][data-primary]') !== null,
+ chat: document.querySelector('[data-sidebar-level="chat"]') !== null,
+ }));
+}
+
+/**
+ * A chat level row shows for another chat panel in a checkout ("1 more chat") or a worktree row.
+ * A fresh fixture has neither, so open a chat panel in the primary checkout and send to it.
+ */
+async function ensureChatLevelRow(): Promise {
+ if ((await sidebarLevels()).chat) return;
+ const newPanel = await browser.$('button[aria-label="New panel"]');
+ await newPanel.waitForEnabled();
+ await newPanel.click();
+ // Exist, not displayed: a menu's opening transition ends on an animation frame, and an
+ // occluded macOS webview may run none.
+ const codex = await browser.$('//*[@role="menuitem"][.//span[normalize-space()="Codex"]]');
+ await codex.waitForExist();
+ await browser.execute((item: HTMLElement) => item.click(), codex);
+ await browser.$('button[aria-label="Close Codex"]').waitForExist();
+ const editor = browser.$(composerEditorSelector);
+ await editor.waitForDisplayed();
+ await editor.click();
+ await editor.addValue("Second fixture chat");
+ await expect(editor).toHaveText("Second fixture chat");
+ await browser.keys("Enter");
+ await browser.waitUntil(async () => (await sidebarLevels()).chat, {
+ timeoutMsg: "The sidebar never showed a chat level row.",
+ });
+}
+
+async function expectSidebarLevels(): Promise {
+ await browser.waitUntil(
+ async () => {
+ const levels = await sidebarLevels();
+ return levels.repo && levels.checkout && levels.chat;
+ },
+ {
+ timeoutMsg: `The sidebar levels are not all marked: ${JSON.stringify(await sidebarLevels())}`,
+ },
+ );
+}
+
+describe("packaged appearance themes", () => {
+ it("the left panel marks its levels", async () => {
+ await openAppearance();
+ await setMode("Light");
+ await openFixtureWorkspace();
+ await ensureChatLevelRow();
+ expect(await rootIsDark()).toBe(false);
+ await expectSidebarLevels();
+
+ await openAppearance();
+ await setMode("Dark");
+ await selectTheme("Tokyo Night");
+ await waitForRootToken("--background", "#1a1b26");
+ await openFixtureWorkspace();
+ expect(await rootIsDark()).toBe(true);
+ await expectSidebarLevels();
+ });
+
+ it("a dark theme row clicked in Light mode switches to Dark, and Undo restores Light", async () => {
+ await openAppearance();
+ await setMode("Light");
+ expect(await themeRow("Dark").getAttribute("aria-pressed")).toBe("true");
+
+ await (await themeRow("Matrix Dark")).click();
+ await waitForMode("Dark");
+ expect(await rootIsDark()).toBe(true);
+ const undo = await browser.$(
+ '//*[@role="group" and @aria-label="Dark themes"]//*[@role="status"]//button[normalize-space()="Undo"]',
+ );
+ await undo.waitForDisplayed();
+ await expect(
+ browser.$('//*[@role="group" and @aria-label="Dark themes"]//*[@role="status"]'),
+ ).toHaveText(expect.stringContaining("Switched to dark mode to show Matrix Dark."));
+
+ await undo.click();
+ await waitForMode("Light");
+ expect(await rootIsDark()).toBe(false);
+ expect(await themeRow("Dark").getAttribute("aria-pressed")).toBe("true");
+ expect(await themeRow("Matrix Dark").getAttribute("aria-pressed")).toBe("false");
+ });
+
+ it("Tokyo Night repaints the app and Dark restores it", async () => {
+ await openAppearance();
+ await setMode("Dark");
+ await selectTheme("Tokyo Night");
+ await waitForRootToken("--background", "#1a1b26");
+
+ await selectTheme("Dark");
+ await browser.waitUntil(
+ () =>
+ browser.execute(
+ () => document.documentElement.style.getPropertyValue("--background") === "",
+ ),
+ { timeoutMsg: "Dark left an inline --background on the root element." },
+ );
+ expect(await rootToken("--background")).not.toBe("#1a1b26");
+ });
+
+ it("a duplicated theme can be edited with live preview and cancelled", async () => {
+ await openAppearance();
+ await setMode("Dark");
+ await chooseRowAction("Tokyo Night", "Duplicate");
+ await themeRow("Tokyo Night copy").waitForDisplayed();
+ await selectTheme("Tokyo Night copy");
+ await waitForRootToken("--primary", "#7aa2f7");
+
+ await chooseRowAction("Tokyo Night copy", "Edit");
+ await browser.$('[data-theme-editor][aria-label="Edit Tokyo Night copy"]').waitForDisplayed();
+ const primary = await themeField("primary");
+ await primary.setValue("#ff0000");
+ await waitForRootToken("--primary", "#ff0000");
+
+ await (await editorButton("Cancel")).click();
+ await themeRow("Tokyo Night copy").waitForDisplayed();
+ await waitForRootToken("--primary", "#7aa2f7");
+ });
+
+ it("a saved custom theme is painted again after a reload", async () => {
+ await openAppearance();
+ await setMode("Dark");
+ await (await browser.$("button=New theme")).click();
+ await (await browser.$('input[name="theme-base"][value="dark"]')).click();
+ await (await browser.$('input[name="theme-name"]')).setValue("Fixture Dark");
+ await (await browser.$("button=Create theme")).click();
+ await browser.$('[data-theme-editor][aria-label="Edit Fixture Dark"]').waitForDisplayed();
+ await (await themeField("background")).setValue("#101820");
+ await (await editorButton("Save")).click();
+
+ await selectTheme("Fixture Dark");
+ await waitForRootToken("--background", "#101820");
+
+ await refreshDesktopUiDocument();
+ await waitForRootToken("--background", "#101820");
+ expect(await rootIsDark()).toBe(true);
+ });
+});
diff --git a/apps/desktop/e2e/specs/chat-activity-panel.e2e.ts b/apps/desktop/e2e/specs/chat-activity-panel.e2e.ts
index dbe300a247..e9cc27cb54 100644
--- a/apps/desktop/e2e/specs/chat-activity-panel.e2e.ts
+++ b/apps/desktop/e2e/specs/chat-activity-panel.e2e.ts
@@ -729,13 +729,12 @@ async function selectActualTheme(
threadId: string,
): Promise<{ readonly dock: SurfacePresentation; readonly panel: SurfacePresentation }> {
const origin = await browser.execute(() => window.location.origin);
- await browser.url(`${origin}/#/settings/general`);
- const themePreference = browser.$('[aria-label="Theme preference"]');
- await themePreference.waitForDisplayed();
- await themePreference.click();
- const option = browser.$(`//*[@role="option" and normalize-space()="${theme}"]`);
- await option.waitForDisplayed();
- await option.click();
+ await browser.url(`${origin}/#/settings/appearance`);
+ const modeButton = browser.$(
+ `//*[@role="group" and @aria-label="Mode"]//button[normalize-space()="${theme}"]`,
+ );
+ await modeButton.waitForDisplayed();
+ await modeButton.click();
await browser.waitUntil(
async () => {
const rootClass = (await browser.$("html").getAttribute("class")) ?? "";
@@ -888,9 +887,9 @@ async function restoreRuntimeMotionEmulation(): Promise {
async function remountActivityDockWithRuntimeMotionEmulation(threadId: string): Promise {
await installRuntimeMotionEmulation(false);
await browser.execute(() => {
- window.location.hash = "#/settings/general";
+ window.location.hash = "#/settings/appearance";
});
- await browser.$('[aria-label="Theme preference"]').waitForDisplayed();
+ await browser.$('[role="group"][aria-label="Mode"]').waitForDisplayed();
await expectMissing('[data-testid="activity-dock"]');
await browser.execute((nextThreadId: string) => {
window.location.hash = `#/primary/${encodeURIComponent(nextThreadId)}`;
diff --git a/apps/desktop/e2e/specs/composer-native-triggers.e2e.ts b/apps/desktop/e2e/specs/composer-native-triggers.e2e.ts
index d9c9c850da..7e475e5549 100644
--- a/apps/desktop/e2e/specs/composer-native-triggers.e2e.ts
+++ b/apps/desktop/e2e/specs/composer-native-triggers.e2e.ts
@@ -445,7 +445,7 @@ async function openProviderPanel(displayName: string): Promise {
},
);
const activeProviderTab = browser.$(
- `//*[contains(concat(" ", normalize-space(@class), " "), " bg-accent ") and ` +
+ `//*[@data-active-tab="true" and ` +
`.//button[@aria-label="Close ${displayName}"] and ` +
`.//span[normalize-space()="${displayName}"]]`,
);
diff --git a/apps/desktop/e2e/specs/desktop-save-and-close.e2e.ts b/apps/desktop/e2e/specs/desktop-save-and-close.e2e.ts
new file mode 100644
index 0000000000..feba872713
--- /dev/null
+++ b/apps/desktop/e2e/specs/desktop-save-and-close.e2e.ts
@@ -0,0 +1,383 @@
+// @effect-diagnostics nodeBuiltinImport:off - Packaged UI tests write disposable fixture files and read them back.
+// @effect-diagnostics globalFetch:off - The spec checks that the stopped server no longer answers.
+import * as NodeFS from "node:fs";
+import * as NodePath from "node:path";
+
+import {
+ chooseDesktopUiQuit,
+ desktopUiApplicationPid,
+ desktopUiListeningPorts,
+ desktopUiPlatform,
+ isDesktopUiApplicationRunning,
+ isProcessAlive,
+ relaunchDesktopUiApplication,
+ requestDesktopUiWindowClose,
+ waitForDesktopUiApplicationExit,
+} from "../support/app-lifecycle.ts";
+import { desktopUiFixture } from "../support/test-project.ts";
+import {
+ ensureMainSidebarOpen,
+ mockDesktopUiFolderPicker,
+ setDesktopUiWindowSize,
+} from "../support/ui-state.ts";
+
+const projectPath = process.env.BIBCODE_E2E_PROJECT_PATH;
+const savedTextPath = process.env.BIBCODE_E2E_SAVE_TEXT_PATH;
+if (!projectPath || !savedTextPath) {
+ throw new Error("The packaged desktop save-and-close fixture environment was not prepared.");
+}
+const preparedProjectPath: string = projectPath;
+const preparedSavedTextPath: string = savedTextPath;
+const fileViewSelector = "[data-preview-panel-mode]";
+const richEditorSelector = `${fileViewSelector} .rich-markdown-editor [contenteditable="true"]`;
+const leaveDialogSelector =
+ '//*[@role="alertdialog"][.//*[starts-with(normalize-space(), "Save your changes before")]]';
+const originalContents = "# Close fixture\n\nHello world\n";
+/** The close, quit and exit checks wait at most this long for the app to end. */
+const EXIT_TIMEOUT_MS = 15_000;
+
+function writeFixture(fileName: string): string {
+ const path = NodePath.join(preparedProjectPath, fileName);
+ NodeFS.writeFileSync(path, originalContents);
+ return path;
+}
+
+async function openFixtureWorkspace(): Promise {
+ // Settings shows its own sidebar; the project list is on the app's start route.
+ await browser.execute(() => {
+ if (window.location.hash.startsWith("#/settings")) window.location.hash = "#/";
+ });
+ await ensureMainSidebarOpen();
+ const project = browser.$(
+ `//button[.//span[normalize-space()="${desktopUiFixture.projectName}"]]`,
+ );
+ if (!(await project.isDisplayed())) {
+ const projectDataLoading = browser.$("//*[normalize-space()='Project data is still loading']");
+ if (await projectDataLoading.isExisting()) {
+ await projectDataLoading.waitForDisplayed({ reverse: true });
+ }
+ const addProject = browser.$('[data-testid="sidebar-add-project-trigger"]');
+ await addProject.waitForDisplayed();
+ await addProject.click();
+ const browseFolder = browser.$(
+ "//button[@data-add-project-action='true'][.//span[normalize-space()='Browse folder']]",
+ );
+ await browseFolder.waitForDisplayed();
+ await mockDesktopUiFolderPicker(preparedProjectPath);
+ await browseFolder.click();
+ }
+ await project.waitForDisplayed();
+ const primaryWorkspace = browser.$(
+ '//*[@data-thread-item="true"][.//span[normalize-space()="main"]]',
+ );
+ if ((await project.getAttribute("aria-expanded")) !== "true") {
+ await project.click();
+ }
+ await primaryWorkspace.waitForDisplayed();
+ await primaryWorkspace.click();
+}
+
+async function openFilesSurface(): Promise {
+ const rightPanel = browser.$("[data-right-panel-tabbar]");
+ if (!(await rightPanel.isDisplayed())) {
+ const toggle = browser.$('button[aria-label^="Toggle right panel"]');
+ await toggle.waitForDisplayed();
+ await toggle.click();
+ await rightPanel.waitForDisplayed();
+ }
+ const existingTab = browser.$(
+ '//*[@data-right-panel-tab-list]//button[normalize-space()="Files"]',
+ );
+ if (await existingTab.isDisplayed()) {
+ await existingTab.click();
+ return;
+ }
+ const emptyStateAction = browser.$(
+ '//button[.//span[normalize-space()="Files"] and not(@aria-disabled="true")]',
+ );
+ if (await emptyStateAction.isDisplayed()) {
+ await emptyStateAction.click();
+ return;
+ }
+ const addSurface = browser.$('button[aria-label="Add panel surface"]');
+ await addSurface.waitForDisplayed();
+ await addSurface.click();
+ const menuItem = browser.$('//*[@role="menuitem" and normalize-space()="Files"]');
+ await menuItem.waitForDisplayed();
+ await menuItem.click();
+}
+
+async function openWorkspaceFile(fileName: string): Promise {
+ const treeHostSelector = `${fileViewSelector} file-tree-container`;
+ const itemSelector = `[role="treeitem"][data-item-path="${fileName}"]`;
+ await browser.waitUntil(
+ () =>
+ browser.execute(
+ (hostQuery: string, targetQuery: string) => {
+ const host = [...document.querySelectorAll(hostQuery)].find(
+ (candidate) => candidate.getBoundingClientRect().height > 0,
+ );
+ const target = host?.shadowRoot?.querySelector(targetQuery);
+ if (!target) return false;
+ target.click();
+ return true;
+ },
+ treeHostSelector,
+ itemSelector,
+ ),
+ { timeoutMsg: `The Files tree did not list ${fileName}.` },
+ );
+ await browser.waitUntil(
+ () =>
+ browser.execute(
+ (editorQuery: string) =>
+ [...document.querySelectorAll(editorQuery)].some((editor) =>
+ editor.textContent?.includes("Hello world"),
+ ),
+ richEditorSelector,
+ ),
+ { timeoutMsg: `The rendered Markdown view did not show ${fileName} as editable.` },
+ );
+}
+
+/** Opens `fileName` in the rendered view and types an edit that is not saved. */
+async function leaveUnsavedEdit(fileName: string): Promise {
+ await setDesktopUiWindowSize(1_200, 720);
+ await openFixtureWorkspace();
+ await openFilesSurface();
+ await openWorkspaceFile(fileName);
+ await browser.execute((editorQuery: string) => {
+ const editor = document.querySelector(editorQuery);
+ const paragraph = [...(editor?.querySelectorAll("p") ?? [])].find(
+ (candidate) => candidate.textContent === "Hello world",
+ );
+ if (!editor || !paragraph) throw new Error("The fixture paragraph is missing.");
+ editor.focus();
+ const range = document.createRange();
+ range.selectNodeContents(paragraph);
+ range.collapse(false);
+ window.getSelection()?.removeAllRanges();
+ window.getSelection()?.addRange(range);
+ }, richEditorSelector);
+ const editor = await browser.$(richEditorSelector);
+ await editor.addValue(" edited");
+ await browser.waitUntil(
+ async () =>
+ (await browser.$("[data-file-editor-toolbar] [aria-live='polite']").getText()) ===
+ "Unsaved changes",
+ { timeoutMsg: "The edit never showed as unsaved." },
+ );
+}
+
+async function leaveDialogButton(label: string) {
+ const button = await browser.$(`${leaveDialogSelector}//button[normalize-space()="${label}"]`);
+ await button.waitForDisplayed();
+ return button;
+}
+
+async function waitForLeaveDialog(fileName: string): Promise {
+ const dialog = browser.$(leaveDialogSelector);
+ await dialog.waitForDisplayed({ timeoutMsg: "The close request never showed the leave dialog." });
+ await expect(dialog).toHaveText(expect.stringContaining(fileName));
+}
+
+async function focusedElement(): Promise {
+ return browser.execute(() => {
+ const active = document.activeElement;
+ return `${active?.tagName ?? "none"} "${active?.textContent?.slice(0, 40) ?? ""}" in ${
+ active?.closest("[role]")?.getAttribute("role") ?? "page"
+ }`;
+ });
+}
+
+function skipUnlessMac(context: Mocha.Context): void {
+ // Native close and quit go through the macOS menu bar; other systems have no driver for it.
+ if (desktopUiPlatform() !== "mac") context.skip();
+}
+
+describe("packaged desktop export and the close dialog", () => {
+ afterEach(async () => {
+ // Each closing case runs in its own app session.
+ if (!(await isDesktopUiApplicationRunning())) await relaunchDesktopUiApplication();
+ });
+
+ it("exports a custom theme through the real save command", async () => {
+ NodeFS.rmSync(preparedSavedTextPath, { force: true });
+ await browser.execute(() => {
+ window.location.hash = "#/settings/appearance";
+ });
+ await (await browser.$("button=New theme")).click();
+ await (await browser.$('input[name="theme-base"][value="dark"]')).click();
+ await (await browser.$('input[name="theme-name"]')).setValue("Export Fixture");
+ await (await browser.$("button=Create theme")).click();
+ const save = await browser.$('//*[@data-theme-editor]//button[normalize-space()="Save"]');
+ await save.scrollIntoView();
+ await save.click();
+ const more = await browser.$('button[aria-label="More actions for Export Fixture"]');
+ await more.scrollIntoView();
+ await more.click();
+ const exportItem = await browser.$('//*[@role="menuitem" and normalize-space()="Export"]');
+ await exportItem.waitForDisplayed();
+ await exportItem.click();
+
+ await browser.waitUntil(() => NodeFS.existsSync(preparedSavedTextPath), {
+ timeoutMsg: "Export never wrote the file through saveTextFile.",
+ });
+ const exported = JSON.parse(NodeFS.readFileSync(preparedSavedTextPath, "utf8")) as {
+ readonly version: number;
+ readonly id: string;
+ readonly name: string;
+ readonly base: string;
+ };
+ expect(exported).toMatchObject({
+ version: 1,
+ id: "export-fixture",
+ name: "Export Fixture",
+ base: "dark",
+ });
+ });
+
+ it("the leave dialog takes focus over the update dialog and Cancel returns it", async () => {
+ writeFixture("close-update.md");
+ await leaveUnsavedEdit("close-update.md");
+ // No install may start: the e2e updater is disabled, and the install command is mocked.
+ const install = await browser.tauri.mock("desktop_bridge_install_update");
+ await install.mockImplementation(() => {
+ throw new Error("The packaged test never installs an update.");
+ });
+ await browser.execute(async () => {
+ const tauri = (
+ window as unknown as {
+ __TAURI__: { event: { emit: (event: string, payload: unknown) => Promise } };
+ }
+ ).__TAURI__;
+ await tauri.event.emit("desktop:update-state", {
+ enabled: true,
+ status: "downloaded",
+ currentVersion: "0.0.1",
+ hostArch: "arm64",
+ appArch: "arm64",
+ runningUnderArm64Translation: false,
+ availableVersion: "99.0.0",
+ downloadedVersion: "99.0.0",
+ downloadPercent: null,
+ checkedAt: null,
+ message: null,
+ errorContext: null,
+ canRetry: false,
+ requestedBy: null,
+ });
+ });
+ const pill = await browser.$("button.update-main");
+ await pill.waitForDisplayed({ timeoutMsg: "The injected update state showed no update." });
+ await pill.click();
+ const updateDialog = browser.$(
+ '//*[@role="dialog"][.//*[normalize-space()="Protect projects before updating"]]',
+ );
+ await updateDialog.waitForDisplayed();
+ const installButton = await browser.$(
+ '//*[@role="dialog"]//button[normalize-space()="Protect projects and install"]',
+ );
+ // Focused in the page, not by the driver's click: a driver click need not move focus, and the
+ // question returns focus to the element that had it when it opened.
+ await browser.execute((button: HTMLElement) => {
+ button.focus();
+ button.click();
+ }, installButton);
+
+ await waitForLeaveDialog("close-update.md");
+ await browser
+ .waitUntil(() =>
+ browser.execute(
+ () =>
+ document.activeElement?.closest('[role="alertdialog"]') !== null &&
+ document.activeElement?.textContent === "Save all",
+ ),
+ )
+ .catch(async () => {
+ throw new Error(
+ `The leave dialog did not take focus over the update dialog (focus: ${await focusedElement()}).`,
+ );
+ });
+ await (await leaveDialogButton("Cancel")).click();
+ await browser.$(leaveDialogSelector).waitForDisplayed({ reverse: true });
+ await browser.waitUntil(
+ () =>
+ browser.execute((button: HTMLElement) => document.activeElement === button, installButton),
+ { timeoutMsg: "Cancel did not return focus to the button that asked." },
+ );
+ await expect(updateDialog).toBeDisplayed();
+ await expect(browser.$("[data-file-editor-toolbar] [aria-live='polite']")).toHaveText(
+ "Unsaved changes",
+ );
+ });
+
+ it("Cancel keeps the window, the server and the edit, and Save all saves and exits", async function () {
+ skipUnlessMac(this);
+ const path = writeFixture("close-save.md");
+ await leaveUnsavedEdit("close-save.md");
+ const pid = desktopUiApplicationPid();
+ const ports = desktopUiListeningPorts(pid);
+
+ await requestDesktopUiWindowClose();
+ await waitForLeaveDialog("close-save.md");
+ await (await leaveDialogButton("Cancel")).click();
+ await browser.$(leaveDialogSelector).waitForDisplayed({ reverse: true });
+ expect(isProcessAlive(pid)).toBe(true);
+ expect(desktopUiListeningPorts(pid)).toEqual(ports);
+ await expect(browser.$(richEditorSelector)).toHaveText(
+ expect.stringContaining("Hello world edited"),
+ );
+ await expect(browser.$("[data-file-editor-toolbar] [aria-live='polite']")).toHaveText(
+ "Unsaved changes",
+ );
+ expect(NodeFS.readFileSync(path, "utf8")).toBe(originalContents);
+
+ await requestDesktopUiWindowClose();
+ await waitForLeaveDialog("close-save.md");
+ await (await leaveDialogButton("Save all")).click();
+ await waitForDesktopUiApplicationExit(pid, EXIT_TIMEOUT_MS);
+ expect(NodeFS.readFileSync(path, "utf8")).toBe("# Close fixture\n\nHello world edited\n");
+ });
+
+ it("Discard exits and leaves the file on disk unchanged", async function () {
+ skipUnlessMac(this);
+ const path = writeFixture("close-discard.md");
+ await leaveUnsavedEdit("close-discard.md");
+ const pid = desktopUiApplicationPid();
+
+ await requestDesktopUiWindowClose();
+ await waitForLeaveDialog("close-discard.md");
+ await (await leaveDialogButton("Discard")).click();
+ await waitForDesktopUiApplicationExit(pid, EXIT_TIMEOUT_MS);
+ expect(NodeFS.readFileSync(path, "utf8")).toBe(originalContents);
+ });
+
+ it("Quit from the menu with nothing unsaved exits promptly and stops the server", async function () {
+ skipUnlessMac(this);
+ await openFixtureWorkspace();
+ const pid = desktopUiApplicationPid();
+ const ports = desktopUiListeningPorts(pid);
+ expect(ports.length).toBeGreaterThan(1);
+
+ await chooseDesktopUiQuit();
+ const elapsed = await waitForDesktopUiApplicationExit(pid, EXIT_TIMEOUT_MS);
+ expect(elapsed).toBeLessThan(5_000);
+ expect(desktopUiListeningPorts(pid)).toEqual([]);
+ for (const port of ports) {
+ await expect(
+ fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(2_000) }),
+ ).rejects.toThrow();
+ }
+ });
+
+ it("with nothing unsaved, a close exits at once", async function () {
+ skipUnlessMac(this);
+ await openFixtureWorkspace();
+ const pid = desktopUiApplicationPid();
+
+ await requestDesktopUiWindowClose();
+ const elapsed = await waitForDesktopUiApplicationExit(pid, EXIT_TIMEOUT_MS);
+ expect(elapsed).toBeLessThan(5_000);
+ });
+});
diff --git a/apps/desktop/e2e/specs/files-rich-markdown.e2e.ts b/apps/desktop/e2e/specs/files-rich-markdown.e2e.ts
new file mode 100644
index 0000000000..f7792ae946
--- /dev/null
+++ b/apps/desktop/e2e/specs/files-rich-markdown.e2e.ts
@@ -0,0 +1,387 @@
+// @effect-diagnostics nodeBuiltinImport:off - Packaged UI tests write disposable fixture files and read them back.
+// @effect-diagnostics globalTimers:off - The spec waits out the rendered view's edit delay.
+import * as NodeFS from "node:fs";
+import * as NodePath from "node:path";
+
+import { desktopUiFixture } from "../support/test-project.ts";
+import {
+ ensureMainSidebarOpen,
+ mockDesktopUiFolderPicker,
+ setDesktopUiWindowSize,
+} from "../support/ui-state.ts";
+
+const projectPath = process.env.BIBCODE_E2E_PROJECT_PATH;
+if (!projectPath) {
+ throw new Error("The packaged desktop rich Markdown fixture environment was not prepared.");
+}
+const preparedProjectPath: string = projectPath;
+const fileViewSelector = "[data-preview-panel-mode]";
+const richEditorSelector = `${fileViewSelector} .rich-markdown-editor [contenteditable="true"]`;
+const sourceEditorHostSelector = `${fileViewSelector} diffs-container`;
+
+const frontmatter = "---\ntitle: Fixture notes\ntags: [e2e]\n---\n";
+const fence = "```ts\nconst answer = 42; // spacing kept\n```\n";
+/** Frontmatter, the paragraph "Hello world" and a fenced code block (R25, R31). */
+const notesContents = `${frontmatter}\nHello world\n\n${fence}`;
+
+const untouchedContents = [
+ "---",
+ "title: Untouched fixture",
+ "---",
+ "",
+ "Hello world",
+ "",
+ "| Name | Value |",
+ "|:-----|------:|",
+ "| one | 1 |",
+ "",
+ "```ts",
+ "const answer = 42;",
+ "```",
+ "",
+ "* star item",
+ "- dash item",
+ "+ plus item",
+].join("\n");
+
+/** Records, from now on, whether the Files toolbar ever says "Unsaved changes". */
+async function watchForUnsavedIndicator(): Promise {
+ await browser.execute(() => {
+ const record = window as unknown as { __bibcodeUnsavedSeen?: boolean };
+ record.__bibcodeUnsavedSeen = false;
+ const check = () => {
+ const status = document.querySelector("[data-file-editor-toolbar] [aria-live='polite']");
+ if (status?.textContent === "Unsaved changes") record.__bibcodeUnsavedSeen = true;
+ };
+ new MutationObserver(check).observe(document.body, {
+ childList: true,
+ subtree: true,
+ characterData: true,
+ });
+ check();
+ });
+}
+
+async function unsavedIndicatorAppeared(): Promise {
+ return browser.execute(
+ () => (window as unknown as { __bibcodeUnsavedSeen?: boolean }).__bibcodeUnsavedSeen === true,
+ );
+}
+
+function writeNotes(fileName: string): string {
+ const path = NodePath.join(preparedProjectPath, fileName);
+ NodeFS.writeFileSync(path, notesContents);
+ return path;
+}
+
+async function openFixtureWorkspace(): Promise {
+ await ensureMainSidebarOpen();
+ const project = browser.$(
+ `//button[.//span[normalize-space()="${desktopUiFixture.projectName}"]]`,
+ );
+ if (!(await project.isDisplayed())) {
+ const projectDataLoading = browser.$("//*[normalize-space()='Project data is still loading']");
+ if (await projectDataLoading.isExisting()) {
+ await projectDataLoading.waitForDisplayed({ reverse: true });
+ }
+ const addProject = browser.$('[data-testid="sidebar-add-project-trigger"]');
+ await addProject.waitForDisplayed();
+ await addProject.click();
+ const browseFolder = browser.$(
+ "//button[@data-add-project-action='true'][.//span[normalize-space()='Browse folder']]",
+ );
+ await browseFolder.waitForDisplayed();
+ await mockDesktopUiFolderPicker(preparedProjectPath);
+ await browseFolder.click();
+ }
+ await project.waitForDisplayed();
+ const primaryWorkspace = browser.$(
+ '//*[@data-thread-item="true"][.//span[normalize-space()="main"]]',
+ );
+ if ((await project.getAttribute("aria-expanded")) !== "true") {
+ await project.click();
+ }
+ await primaryWorkspace.waitForDisplayed();
+ await primaryWorkspace.click();
+}
+
+async function openFilesSurface(): Promise {
+ const rightPanel = browser.$("[data-right-panel-tabbar]");
+ if (!(await rightPanel.isDisplayed())) {
+ const toggle = browser.$('button[aria-label^="Toggle right panel"]');
+ await toggle.waitForDisplayed();
+ await toggle.click();
+ await rightPanel.waitForDisplayed();
+ }
+ const existingTab = browser.$(
+ '//*[@data-right-panel-tab-list]//button[normalize-space()="Files"]',
+ );
+ if (await existingTab.isDisplayed()) {
+ await existingTab.click();
+ return;
+ }
+ const emptyStateAction = browser.$(
+ '//button[.//span[normalize-space()="Files"] and not(@aria-disabled="true")]',
+ );
+ if (await emptyStateAction.isDisplayed()) {
+ await emptyStateAction.click();
+ return;
+ }
+ const addSurface = browser.$('button[aria-label="Add panel surface"]');
+ await addSurface.waitForDisplayed();
+ await addSurface.click();
+ const menuItem = browser.$('//*[@role="menuitem" and normalize-space()="Files"]');
+ await menuItem.waitForDisplayed();
+ await menuItem.click();
+}
+
+/** Opens a workspace file from the Files tree, which renders inside a shadow root. */
+async function openWorkspaceFile(fileName: string): Promise {
+ const treeHostSelector = `${fileViewSelector} file-tree-container`;
+ const itemSelector = `[role="treeitem"][data-item-path="${fileName}"]`;
+ await browser.waitUntil(
+ () =>
+ browser.execute(
+ (hostQuery: string, targetQuery: string) => {
+ const host = [...document.querySelectorAll(hostQuery)].find(
+ (candidate) => candidate.getBoundingClientRect().height > 0,
+ );
+ const target = host?.shadowRoot?.querySelector(targetQuery);
+ if (!target) return false;
+ target.click();
+ return true;
+ },
+ treeHostSelector,
+ itemSelector,
+ ),
+ { timeoutMsg: `The Files tree did not list ${fileName}.` },
+ );
+ // The editable rendered view, not the read-only view shown while the editor loads.
+ await browser.waitUntil(
+ () =>
+ browser.execute(
+ (editorQuery: string) =>
+ [...document.querySelectorAll(editorQuery)].some((editor) =>
+ editor.textContent?.includes("Hello world"),
+ ),
+ richEditorSelector,
+ ),
+ { timeoutMsg: `The rendered Markdown view did not show ${fileName} as editable.` },
+ );
+}
+
+async function openNotes(fileName: string): Promise {
+ await setDesktopUiWindowSize(1_200, 720);
+ await openFixtureWorkspace();
+ await openFilesSurface();
+ await openWorkspaceFile(fileName);
+}
+
+/** Puts the caret at the end of the paragraph that holds `text`. */
+async function placeCaretAfter(text: string): Promise {
+ const placed = await browser.execute(
+ (editorQuery: string, needle: string) => {
+ const editor = document.querySelector(editorQuery);
+ const block = [...(editor?.querySelectorAll("p, h1, h2, h3") ?? [])].find(
+ (candidate) => candidate.textContent?.includes(needle),
+ );
+ if (!editor || !block) return false;
+ editor.focus();
+ const range = document.createRange();
+ range.selectNodeContents(block);
+ range.collapse(false);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ return true;
+ },
+ richEditorSelector,
+ text,
+ );
+ if (!placed) throw new Error(`No rendered block holds "${text}".`);
+}
+
+async function typeInRichEditor(text: string): Promise {
+ const editor = await browser.$(richEditorSelector);
+ await editor.addValue(text);
+}
+
+async function toolbarButton(label: string) {
+ const button = await browser.$(`[data-file-editor-toolbar] button[aria-label="${label}"]`);
+ await button.waitForDisplayed();
+ return button;
+}
+
+async function clickToolbar(label: string): Promise {
+ const button = await toolbarButton(label);
+ await button.waitForEnabled();
+ await button.click();
+}
+
+async function waitForStatus(status: string): Promise {
+ await browser.waitUntil(
+ async () =>
+ (await browser.$("[data-file-editor-toolbar] [aria-live='polite']").getText()) === status,
+ { timeoutMsg: `The Files toolbar never said "${status}".` },
+ );
+}
+
+async function renderedBlocks(): Promise {
+ return browser.execute((editorQuery: string) => {
+ const editor = document.querySelector(editorQuery);
+ return [...(editor?.children ?? [])].map(
+ (block) => `${block.tagName.toLowerCase()}:${block.textContent ?? ""}`,
+ );
+ }, richEditorSelector);
+}
+
+async function sourceEditorText(fileName: string): Promise {
+ return browser.execute(
+ (hostQuery: string, targetQuery: string) => {
+ const host = [...document.querySelectorAll(hostQuery)].find(
+ (candidate) => candidate.getBoundingClientRect().height > 0,
+ );
+ const root = host?.shadowRoot;
+ if (!root) return null;
+ const textbox = root.querySelector(targetQuery);
+ return (textbox ?? root.querySelector("pre"))?.textContent ?? null;
+ },
+ sourceEditorHostSelector,
+ `[role="textbox"][aria-label="${fileName}"]`,
+ );
+}
+
+async function waitForSourceEditor(fileName: string): Promise {
+ await browser.waitUntil(async () => (await sourceEditorText(fileName)) !== null, {
+ timeoutMsg: `The source view of ${fileName} did not open.`,
+ });
+}
+
+async function switchView(label: "Show markdown source" | "Show rendered markdown") {
+ const toggle = await browser.$(`[data-file-editor-toolbar] button[aria-label="${label}"]`);
+ await toggle.waitForDisplayed();
+ await toggle.click();
+}
+
+describe("packaged rich Markdown editing", () => {
+ it("edits in rendered view, saves and keeps the rest of the file", async () => {
+ const path = writeNotes("notes.md");
+ await openNotes("notes.md");
+
+ await placeCaretAfter("Hello world");
+ await typeInRichEditor(" again");
+ await placeCaretAfter("Hello world again");
+ await clickToolbar("Heading 2");
+ await waitForStatus("Unsaved changes");
+ expect(NodeFS.readFileSync(path, "utf8")).toBe(notesContents);
+
+ await clickToolbar("Save file");
+ await browser.waitUntil(() => NodeFS.readFileSync(path, "utf8") !== notesContents, {
+ timeoutMsg: "Save file never wrote notes.md.",
+ });
+ const saved = NodeFS.readFileSync(path, "utf8");
+ expect(saved.startsWith(frontmatter)).toBe(true);
+ expect(saved.endsWith(fence)).toBe(true);
+ expect(saved).toBe(`${frontmatter}\n## Hello world again\n\n${fence}`);
+ });
+
+ it("undo in rendered view and one-step undo in source view", async () => {
+ writeNotes("notes-undo.md");
+ await openNotes("notes-undo.md");
+
+ await placeCaretAfter("Hello world");
+ await typeInRichEditor(" again");
+ // The editor's history joins changes made within half a second into one undo step.
+ await new Promise((resolve) => setTimeout(resolve, 800));
+ await placeCaretAfter("Hello world again");
+ await clickToolbar("Heading 2");
+ await browser.waitUntil(async () => (await renderedBlocks())[0] === "h2:Hello world again", {
+ timeoutMsg: "Heading 2 did not apply to the paragraph.",
+ });
+ await clickToolbar("Undo");
+ await browser.waitUntil(async () => (await renderedBlocks())[0] === "p:Hello world again", {
+ timeoutMsg: "Undo in rendered view did not remove the heading.",
+ });
+
+ // The source editor exists from here on, so the next rendered visit reaches it as one step.
+ await switchView("Show markdown source");
+ await waitForSourceEditor("notes-undo.md");
+ expect(await sourceEditorText("notes-undo.md")).toContain("Hello world again");
+ await switchView("Show rendered markdown");
+ await browser.$(richEditorSelector).waitForDisplayed();
+
+ await placeCaretAfter("Hello world again");
+ await typeInRichEditor(" twice");
+ await placeCaretAfter("Hello world again twice");
+ await clickToolbar("Heading 2");
+ await browser.waitUntil(
+ async () => (await renderedBlocks())[0] === "h2:Hello world again twice",
+ { timeoutMsg: "The second visit's edits did not apply." },
+ );
+
+ await switchView("Show markdown source");
+ await browser.waitUntil(
+ async () => (await sourceEditorText("notes-undo.md"))?.includes("## Hello world again twice"),
+ { timeoutMsg: "The source view did not show the rendered visit's edits." },
+ );
+ await clickToolbar("Undo");
+ await browser.waitUntil(
+ async () => {
+ const text = (await sourceEditorText("notes-undo.md")) ?? "";
+ return (
+ text.includes("Hello world again") && !text.includes("twice") && !text.includes("##")
+ );
+ },
+ { timeoutMsg: "One Undo in source view did not remove both rendered edits." },
+ );
+ });
+
+ it("shows an image the file links by a relative path (#345)", async () => {
+ // A 1x1 placeholder PNG next to the Markdown file's own folder.
+ const imagesPath = NodePath.join(preparedProjectPath, "images");
+ NodeFS.mkdirSync(imagesPath, { recursive: true });
+ NodeFS.writeFileSync(
+ NodePath.join(imagesPath, "pixel.png"),
+ Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
+ "base64",
+ ),
+ );
+ NodeFS.writeFileSync(
+ NodePath.join(preparedProjectPath, "notes-image.md"),
+ "Hello world\n\n\n",
+ );
+ await openNotes("notes-image.md");
+
+ await browser.waitUntil(
+ () =>
+ browser.execute((editorQuery: string) => {
+ const image = document.querySelector(`${editorQuery} img`);
+ return image !== null && image.complete && image.naturalWidth > 0;
+ }, richEditorSelector),
+ { timeoutMsg: "The relative image in notes-image.md never loaded." },
+ );
+ });
+
+ it("opening and saving without editing leaves the file byte-identical", async () => {
+ // R31: frontmatter, a table, a fenced code block, mixed list markers, no final newline.
+ const path = NodePath.join(preparedProjectPath, "notes-untouched.md");
+ NodeFS.writeFileSync(path, untouchedContents);
+ const before = NodeFS.readFileSync(path);
+ await openNotes("notes-untouched.md");
+ await watchForUnsavedIndicator();
+
+ await switchView("Show markdown source");
+ await waitForSourceEditor("notes-untouched.md");
+ await switchView("Show rendered markdown");
+ await browser.$(richEditorSelector).waitForDisplayed();
+ await placeCaretAfter("Hello world");
+ // A clean file's Save file is disabled, so save through the Files shortcut.
+ await browser.keys(["Meta", "s"]);
+ // Longer than the rendered view's 300 ms edit delay and a save round trip.
+ await new Promise((resolve) => setTimeout(resolve, 1_500));
+
+ expect(NodeFS.readFileSync(path).equals(before)).toBe(true);
+ expect(await unsavedIndicatorAppeared()).toBe(false);
+ });
+});
diff --git a/apps/desktop/e2e/specs/host-dashboard.e2e.ts b/apps/desktop/e2e/specs/host-dashboard.e2e.ts
new file mode 100644
index 0000000000..e5db1ece19
--- /dev/null
+++ b/apps/desktop/e2e/specs/host-dashboard.e2e.ts
@@ -0,0 +1,91 @@
+// Shapes only: no value of the test machine (a size, a mount point, a host name) is asserted.
+const sheetSelector = '[data-testid="host-dashboard"]';
+const cpuSelector = `${sheetSelector} [data-testid="host-dashboard-cpu"]`;
+const sinceSelector = `${sheetSelector} [data-testid="host-dashboard-since"]`;
+const hiddenReason =
+ /(Network volume|Container layer|File mount|Empty|System mount|Same storage as .+|Hidden by the volume filter)$/;
+
+async function openFromResourceManager(): Promise {
+ // The status bar sits on every route; leave Settings so no full-page surface covers it.
+ await browser.execute(() => {
+ if (window.location.hash.startsWith("#/settings")) window.location.hash = "#/";
+ });
+ const trigger = browser.$('button[aria-label^="Combined monitored resources"]');
+ await trigger.waitForDisplayed();
+ await trigger.click();
+ const open = browser.$('//button[normalize-space()="Host dashboard"]');
+ await open.waitForDisplayed();
+ await open.click();
+ await browser.$(sheetSelector).waitForDisplayed();
+}
+
+async function text(selector: string): Promise {
+ return (await browser.$(selector).getText()).trim();
+}
+
+async function texts(selector: string): Promise {
+ return browser.execute(
+ (query: string) =>
+ [...document.querySelectorAll(query)].map((node) => (node.textContent ?? "").trim()),
+ selector,
+ );
+}
+
+describe("packaged Host dashboard", () => {
+ it("opens for the local computer, updates live, discloses hidden volumes and closes", async () => {
+ await openFromResourceManager();
+
+ const title = await text(`${sheetSelector} [data-slot="sheet-title"]`);
+ // The fallback title means the local environment was not resolved.
+ expect(title.length).toBeGreaterThan(0);
+ expect(title).not.toBe("Host dashboard");
+
+ await browser.waitUntil(async () => /^\d+(\.\d)?%$/.test(await text(cpuSelector)), {
+ timeoutMsg: "The CPU figure never showed a percentage.",
+ });
+ expect(await text(`${sheetSelector} [data-testid="host-dashboard-memory"]`)).toMatch(
+ / of .*, \d+%$/,
+ );
+ await browser.waitUntil(
+ async () =>
+ (await texts(`${sheetSelector} [data-testid="host-dashboard-volume"]`)).length > 0,
+ { timeoutMsg: "No volume was listed." },
+ );
+ expect(await text(`${sinceSelector} h3`)).toMatch(/^Since/);
+ // A shown volume gets its own statistics row once a volume reading has been recorded.
+ await browser.waitUntil(
+ async () =>
+ (await texts(`${sinceSelector} tbody th`)).some((label) => label.endsWith(" used")),
+ { timeoutMsg: "No per-volume statistics row appeared." },
+ );
+
+ const firstSampledAt = await browser.$(cpuSelector).getAttribute("data-sampled-at");
+ expect(firstSampledAt).toBeTruthy();
+ await browser.pause(3_000);
+ expect(await browser.$(cpuSelector).getAttribute("data-sampled-at")).not.toBe(firstSampledAt);
+
+ // One case only: every case starts from a reset connection and route, which closes the sheet.
+ const disclosure = browser.$(
+ '//*[@data-testid="host-dashboard"]//button[starts-with(normalize-space(), "Hidden volumes (")]',
+ );
+ // Whether a host hides a volume depends on its mounts (a container's file mounts, the macOS
+ // data volume); a host that hides none shows no disclosure.
+ if (await disclosure.isExisting()) {
+ expect(await disclosure.getAttribute("aria-expanded")).toBe("false");
+ await disclosure.click();
+ await browser.waitUntil(
+ async () => (await disclosure.getAttribute("aria-expanded")) === "true",
+ { timeoutMsg: "The hidden volumes disclosure never opened." },
+ );
+ const rows = await texts(`${sheetSelector} [data-testid="host-dashboard-hidden-volume"]`);
+ expect(rows.length).toBeGreaterThan(0);
+ for (const row of rows) {
+ expect(row).toMatch(hiddenReason);
+ expect(row).not.toMatch(/\d\s?(B|KB|MB|GB|TB)\b/);
+ }
+ }
+
+ await browser.keys("Escape");
+ await browser.$(sheetSelector).waitForExist({ reverse: true });
+ });
+});
diff --git a/apps/desktop/e2e/specs/platform-capabilities.e2e.ts b/apps/desktop/e2e/specs/platform-capabilities.e2e.ts
index 8674999ad0..a96e48c839 100644
--- a/apps/desktop/e2e/specs/platform-capabilities.e2e.ts
+++ b/apps/desktop/e2e/specs/platform-capabilities.e2e.ts
@@ -28,14 +28,13 @@ describe("packaged preferences, native integrations, and platform capabilities",
await expect(checkForUpdates).toBeDisplayed();
await expect(checkForUpdates).toBeDisabled();
- await browser.url(`${appOrigin}/#/settings/general`);
- const themePreference = browser.$('[aria-label="Theme preference"]');
- await themePreference.scrollIntoView();
- await expect(themePreference).toBeDisplayed();
- await themePreference.click();
- const darkTheme = browser.$('//*[@role="option" and normalize-space()="Dark"]');
- await darkTheme.waitForDisplayed();
- await darkTheme.click();
+ await browser.url(`${appOrigin}/#/settings/appearance`);
+ const darkMode = browser.$(
+ '//*[@role="group" and @aria-label="Mode"]//button[normalize-space()="Dark"]',
+ );
+ await darkMode.scrollIntoView();
+ await expect(darkMode).toBeDisplayed();
+ await darkMode.click();
await expect(browser.$("html")).toHaveElementClass(expect.stringContaining("dark"));
const providers = browser.$(
diff --git a/apps/desktop/e2e/specs/source-control-review.e2e.ts b/apps/desktop/e2e/specs/source-control-review.e2e.ts
new file mode 100644
index 0000000000..7b84ea4f99
--- /dev/null
+++ b/apps/desktop/e2e/specs/source-control-review.e2e.ts
@@ -0,0 +1,311 @@
+// @effect-diagnostics nodeBuiltinImport:off - Packaged review tests edit the fixture checkout, read fixture logs and save screenshots.
+// @effect-diagnostics globalDate:off - The standalone WDIO spec waits on a host-side fixture file.
+import * as NodeFS from "node:fs";
+import * as NodePath from "node:path";
+
+import { refreshDesktopUiDocument } from "../support/document-navigation.ts";
+import { readProviderInputLog } from "../support/provider-input-log.ts";
+import { desktopUiFixture } from "../support/test-project.ts";
+import {
+ ensureMainSidebarOpen,
+ mockDesktopUiFolderPicker,
+ setDesktopUiWindowSize,
+} from "../support/ui-state.ts";
+
+/**
+ * Review changes in Source Control (#293, R101): a review opens as a chat panel of the current
+ * thread whose first message is the review prompt, and a review that ends in a plan offers no
+ * Implement, before and after a reload. The second case is the one the `codeReview` flag
+ * decides: without it the same plan offers Implement and an empty Enter implements it.
+ */
+
+const artifactDirectory = process.env.BIBCODE_E2E_ARTIFACT_DIR;
+const projectPath = process.env.BIBCODE_E2E_PROJECT_PATH;
+const providerInputLogPath = process.env.BIBCODE_E2E_PROVIDER_INPUT_LOG;
+if (!artifactDirectory || !projectPath || !providerInputLogPath) {
+ throw new Error("The packaged desktop review fixture environment was not prepared.");
+}
+const preparedArtifactDirectory: string = artifactDirectory;
+const preparedProjectPath: string = projectPath;
+const preparedProviderInputLogPath: string = providerInputLogPath;
+const visibleSurfaceSelector = '[data-center-surface-host][data-visible="true"]';
+const visibleComposerFormSelector = `${visibleSurfaceSelector} [data-chat-composer-form="true"]`;
+const UNCOMMITTED_SCOPE = "Scope: the staged, unstaged and untracked changes in the working tree";
+const REVIEW_LEAD_IN = "Code review requested from BiBCode.";
+
+function composerForm() {
+ return browser.$(visibleComposerFormSelector);
+}
+function composerEditor() {
+ return composerForm().$('[data-testid="composer-editor"]');
+}
+
+async function waitForComposerDisplayed(): Promise {
+ const form = browser.$(visibleComposerFormSelector);
+ await form.waitForExist();
+ await form.waitForDisplayed();
+ await form.$('[data-testid="composer-editor"]').waitForDisplayed();
+}
+
+async function ensureFixtureProjectImported(): Promise {
+ const appOrigin = await browser.execute(() => window.location.origin);
+ await browser.url(`${appOrigin}/#/`);
+ await ensureMainSidebarOpen();
+ const projectSelector = `//button[@data-sidebar="menu-button"][.//span[normalize-space()="${desktopUiFixture.projectName}"]]`;
+ const hasVisibleProject = async () => {
+ for (const candidate of await browser.$$(projectSelector)) {
+ if (await candidate.isDisplayed()) return true;
+ }
+ return false;
+ };
+ if (await hasVisibleProject()) return;
+
+ const projectDataLoading = browser.$("//*[normalize-space()='Project data is still loading']");
+ if (await projectDataLoading.isExisting()) {
+ await projectDataLoading.waitForDisplayed({
+ reverse: true,
+ timeoutMsg: "The primary project catalog did not become ready for fixture import.",
+ });
+ }
+ const addProject = browser.$('[data-testid="sidebar-add-project-trigger"]');
+ await expect(addProject).toBeDisplayed();
+ await addProject.click();
+ await browser.$('[role="dialog"]').waitForExist();
+ const browseFolder = browser.$(
+ "//button[@data-add-project-action='true'][.//span[normalize-space()='Browse folder']]",
+ );
+ await browseFolder.waitForDisplayed();
+ await mockDesktopUiFolderPicker(preparedProjectPath);
+ await browseFolder.click();
+ await browser.waitUntil(hasVisibleProject, {
+ timeoutMsg: "The imported fixture project did not become visible in the main sidebar.",
+ });
+}
+
+async function openPrimaryThread(): Promise {
+ const projectSelector = `//button[@data-sidebar="menu-button"][.//span[normalize-space()="${desktopUiFixture.projectName}"]]`;
+ const primaryWorkspace = browser.$(
+ '//*[@data-thread-item="true"][.//span[normalize-space()="main"]]',
+ );
+ if (!(await primaryWorkspace.isDisplayed())) {
+ for (const project of await browser.$$(projectSelector)) {
+ if (await project.isDisplayed()) {
+ await project.click();
+ break;
+ }
+ }
+ await primaryWorkspace.waitForDisplayed();
+ }
+ await primaryWorkspace.click();
+ await waitForComposerDisplayed();
+}
+
+async function replaceComposerText(text: string): Promise {
+ const editor = composerEditor();
+ await editor.click();
+ await browser.execute(() => {
+ const editor = document.activeElement;
+ if (!(editor instanceof HTMLElement) || editor.dataset.testid !== "composer-editor") {
+ throw new Error("The composer editor did not receive focus.");
+ }
+ const range = document.createRange();
+ range.selectNodeContents(editor);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ });
+ await browser.keys("Backspace");
+ if (text.length > 0) await editor.addValue(text);
+ await expect(composerEditor()).toHaveText(text);
+}
+
+/** A draft has no panel host, so the thread gets a first message and becomes a server thread. */
+async function sendFirstMessage(): Promise {
+ const baseline = readProviderInputLog(preparedProviderInputLogPath).length;
+ const prompt = "get ready for a review";
+ await replaceComposerText(prompt);
+ await browser.keys("Enter");
+ await browser.waitUntil(
+ () =>
+ readProviderInputLog(preparedProviderInputLogPath)
+ .slice(baseline)
+ .some((entry) => entry.kind === "start" && entry.prompt === prompt),
+ { timeoutMsg: "The primary thread's first message did not reach its provider." },
+ );
+ await expect(browser.$(visibleSurfaceSelector)).toHaveText(
+ expect.stringContaining(desktopUiFixture.streamedResponse),
+ );
+}
+
+/** One tracked file differs from HEAD, so the review scope is the uncommitted changes. */
+function modifyTrackedFile(): void {
+ NodeFS.appendFileSync(
+ NodePath.join(preparedProjectPath, "README.md"),
+ `\nReview fixture change ${Date.now()}.\n`,
+ );
+}
+
+async function openSourceControl(): Promise {
+ const rightPanel = browser.$("[data-right-panel-tabbar]");
+ if (!(await rightPanel.isDisplayed())) {
+ const toggle = browser.$('button[aria-label^="Toggle right panel"]');
+ await toggle.waitForDisplayed();
+ await toggle.click();
+ await rightPanel.waitForDisplayed();
+ }
+ const existingTab = browser.$(
+ '//*[@data-right-panel-tab-list]//button[normalize-space()="Source Control"]',
+ );
+ if (await existingTab.isDisplayed()) {
+ await existingTab.click();
+ } else {
+ const emptyStateAction = browser.$(
+ '//button[.//span[normalize-space()="Source Control"] and not(@aria-disabled="true")]',
+ );
+ if (await emptyStateAction.isDisplayed()) {
+ await emptyStateAction.click();
+ } else {
+ const addSurface = browser.$('button[aria-label="Add panel surface"]');
+ await addSurface.waitForDisplayed();
+ await addSurface.click();
+ const menuItem = browser.$('//*[@role="menuitem" and normalize-space()="Source Control"]');
+ await menuItem.waitForExist();
+ await browser.execute((item: HTMLElement) => item.click(), await menuItem);
+ }
+ }
+ // Enabled once the status sees the modified file and the thread can host a panel.
+ await browser.$('button[aria-label="Review changes"][aria-disabled="false"]').waitForDisplayed({
+ timeout: 30_000,
+ timeoutMsg: "Review changes did not become enabled for the modified fixture checkout.",
+ });
+}
+
+function reviewTab(provider: string) {
+ return browser.$(`//button[@role="tab"][.//span[normalize-space()="Code review · ${provider}"]]`);
+}
+
+async function waitForReviewStart(provider: "codex" | "claudeAgent", baseline: number) {
+ let prompt: string | undefined;
+ await browser.waitUntil(
+ () => {
+ prompt = readProviderInputLog(preparedProviderInputLogPath)
+ .slice(baseline)
+ .find(
+ (entry) =>
+ entry.provider === provider &&
+ entry.kind === "start" &&
+ entry.prompt.startsWith(REVIEW_LEAD_IN),
+ )?.prompt;
+ return prompt !== undefined;
+ },
+ { timeoutMsg: `The review's first message did not reach ${provider}.` },
+ );
+ return prompt!;
+}
+
+/** No Implement, Refine or "Implement in a new thread", and an empty Enter sends nothing. */
+async function expectNoImplementActions(): Promise {
+ const surface = () => browser.$(visibleSurfaceSelector);
+ await expect(surface().$('[data-chat-composer-implement-actions="true"]')).not.toExist();
+ await expect(surface().$('button[aria-label^="Implementation actions"]')).not.toExist();
+ await expect(surface().$('//button[normalize-space()="Implement"]')).not.toExist();
+ await expect(surface()).not.toHaveText(expect.stringContaining("Implement in a new thread"));
+
+ // Refine replaces Implement once the composer holds text.
+ await replaceComposerText("tighten the wording");
+ await expect(surface().$('//button[normalize-space()="Refine"]')).not.toExist();
+ await replaceComposerText("");
+
+ const before = readProviderInputLog(preparedProviderInputLogPath).length;
+ await composerEditor().click();
+ await browser.keys("Enter");
+ // Nothing to wait for when nothing is sent: give a send time to reach the provider.
+ await browser.pause(2_000);
+ expect(readProviderInputLog(preparedProviderInputLogPath).length).toBe(before);
+ await expect(composerEditor()).toHaveText("");
+}
+
+async function expectReviewFindings(): Promise {
+ // The plan card's text first, so a missing flag fails at the Implement check below.
+ await expect(browser.$(visibleSurfaceSelector)).toHaveText(
+ expect.stringContaining(desktopUiFixture.codeReviewFinding),
+ { wait: 30_000 },
+ );
+ await expectNoImplementActions();
+ await expect(browser.$(visibleSurfaceSelector)).toHaveText(
+ expect.stringContaining("Review findings"),
+ );
+}
+
+// Each test sets itself up: the harness resets the connection catalog and reloads the document
+// before each one.
+describe("packaged Source Control review", () => {
+ it("opens a Codex review panel whose first message is the review prompt", async () => {
+ await setDesktopUiWindowSize(1_280, 900);
+ await ensureFixtureProjectImported();
+ await openPrimaryThread();
+ await sendFirstMessage();
+ modifyTrackedFile();
+ await openSourceControl();
+
+ const baseline = readProviderInputLog(preparedProviderInputLogPath).length;
+ // The saved reviewer is the default, chat Codex.
+ await browser.$('button[aria-label="Review changes"]').click();
+ const tab = reviewTab("Codex");
+ await tab.waitForDisplayed({ timeoutMsg: "The Codex review tab did not open." });
+ const prompt = await waitForReviewStart("codex", baseline);
+ expect(prompt).toContain(UNCOMMITTED_SCOPE);
+
+ // The review starts supervised (approval required), the Codex read-only sandbox.
+ await waitForComposerDisplayed();
+ await expect(composerForm().$('[aria-label="Supervised"]')).toExist();
+ await browser.saveScreenshot(
+ NodePath.join(preparedArtifactDirectory, "review-codex-panel.png"),
+ );
+ });
+
+ it("shows a Claude review's plan as Review findings with no Implement, also after a reload", async () => {
+ await setDesktopUiWindowSize(1_280, 900);
+ await ensureFixtureProjectImported();
+ await openPrimaryThread();
+ await sendFirstMessage();
+ modifyTrackedFile();
+ await openSourceControl();
+
+ // A one-off Claude chat review from the review menu; the saved reviewer stays chat Codex.
+ const baseline = readProviderInputLog(preparedProviderInputLogPath).length;
+ await browser.$('button[aria-label="Review options"]').click();
+ const claude = browser.$(
+ '//*[@role="menuitem"][.//span[normalize-space()="Claude"]][not(@aria-disabled="true")]',
+ );
+ await claude.waitForExist();
+ await browser.execute((item: HTMLElement) => item.click(), await claude);
+ await reviewTab("Claude").waitForDisplayed({
+ timeoutMsg: "The Claude review tab did not open.",
+ });
+ const prompt = await waitForReviewStart("claudeAgent", baseline);
+ expect(prompt).toContain(UNCOMMITTED_SCOPE);
+ await waitForComposerDisplayed();
+ // Claude reviews start in plan mode.
+ await expect(composerForm().$('button[aria-label="Disable plan mode"]')).toExist();
+
+ await expectReviewFindings();
+ await browser.saveScreenshot(
+ NodePath.join(preparedArtifactDirectory, "review-claude-findings.png"),
+ );
+
+ await refreshDesktopUiDocument();
+ const restoredTab = reviewTab("Claude");
+ await restoredTab.waitForExist({
+ timeoutMsg: "The Claude review tab did not survive a reload.",
+ });
+ if ((await restoredTab.getAttribute("aria-selected")) !== "true") {
+ await browser.execute((tab: HTMLElement) => tab.click(), await reviewTab("Claude"));
+ }
+ await waitForComposerDisplayed();
+ await expectReviewFindings();
+ await browser.saveScreenshot(
+ NodePath.join(preparedArtifactDirectory, "review-claude-findings-after-reload.png"),
+ );
+ });
+});
diff --git a/apps/desktop/e2e/specs/terminal-footer-invariant.e2e.ts b/apps/desktop/e2e/specs/terminal-footer-invariant.e2e.ts
new file mode 100644
index 0000000000..8d8baa8a66
--- /dev/null
+++ b/apps/desktop/e2e/specs/terminal-footer-invariant.e2e.ts
@@ -0,0 +1,667 @@
+// @effect-diagnostics nodeBuiltinImport:off - Packaged UI tests read the fixture TUI's size log and retain failure artifacts.
+// @effect-diagnostics globalDate:off - The seeded loop stamps its own failure report.
+import * as NodeFS from "node:fs";
+import * as NodePath from "node:path";
+
+import { refreshDesktopUiDocument } from "../support/document-navigation.ts";
+import {
+ fixtureTuiLogPath,
+ openFixtureCodexTerminal,
+ openFixtureTerminalThread,
+ readFixtureTuiSize,
+ settleFixtureTui,
+ type FixtureTuiSize,
+} from "../support/fixture-terminal.ts";
+import { installDesktopUiMotionGuard } from "../support/motion-guard.ts";
+import { clearDesktopActivityMarker } from "../support/test-project.ts";
+import { setDesktopUiWindowSize } from "../support/ui-state.ts";
+
+/**
+ * #348: an agent CLI in a center-panel terminal suddenly loses its bottom lines
+ * or its whole footer until a layout change repaints it. This loop drives
+ * seeded layout actions around a Codex Terminal running the fixture TUI, which
+ * draws a footer on its last three rows and logs every PTY size it is given,
+ * and after each step (and again 1.5 s later) checks that:
+ * - the PTY's rows (the TUI's last logged size) equal the rows xterm draws;
+ * - the rows xterm draws equal the rows that fit its mount;
+ * - the last row's bottom edge is inside every clipping ancestor; and
+ * - with the DOM renderer, the footer text is on the last row (or, with
+ * BIBCODE_E2E_FOOTER_LAYOUT=floating, appears exactly once at the current size).
+ * BIBCODE_E2E_FOOTER_LAYOUT=cli runs an Ink-like program that rewrites a bottom frame every
+ * 100 ms above a long transcript, and also checks that the viewport stays at the bottom.
+ * BIBCODE_E2E_FOOTER_RENDERER=dom turns the WebGL renderer off, so every check reads the text.
+ * BIBCODE_E2E_FOOTER_CHAT=1 adds chat sends and a growing composer to the actions.
+ * BIBCODE_E2E_FOOTER_SEED and BIBCODE_E2E_FOOTER_STEPS replay or lengthen a run.
+ */
+
+const artifactDirectory = process.env.BIBCODE_E2E_ARTIFACT_DIR;
+const projectPath = process.env.BIBCODE_E2E_PROJECT_PATH;
+const shimDirectory = process.env.BIBCODE_E2E_SHIM_DIRECTORY;
+if (!artifactDirectory || !projectPath || !shimDirectory) {
+ throw new Error("The packaged desktop UI fixture environment was not prepared.");
+}
+const preparedArtifactDirectory: string = artifactDirectory;
+const preparedProjectPath: string = projectPath;
+const isWindowsE2e = process.env.BIBCODE_E2E_PLATFORM === "win";
+const preparedCodexExecutable = NodePath.join(shimDirectory, isWindowsE2e ? "codex.cmd" : "codex");
+const tuiLogPath = fixtureTuiLogPath(preparedProjectPath);
+
+function readPositiveInteger(name: string, fallback: number): number {
+ const raw = process.env[name]?.trim();
+ if (!raw) return fallback;
+ const value = Number(raw);
+ if (!Number.isSafeInteger(value) || value < 0) {
+ throw new Error(`${name} must be a non-negative integer, got ${raw}.`);
+ }
+ return value;
+}
+const seed = readPositiveInteger("BIBCODE_E2E_FOOTER_SEED", 348);
+const stepCount = readPositiveInteger("BIBCODE_E2E_FOOTER_STEPS", 40);
+/** "floating" runs a frame drawn in the normal buffer (like Claude's CLI), not pinned to the last row. */
+const floatingLayout = process.env.BIBCODE_E2E_FOOTER_LAYOUT === "floating";
+const cliLayout = process.env.BIBCODE_E2E_FOOTER_LAYOUT === "cli";
+const domRenderer = process.env.BIBCODE_E2E_FOOTER_RENDERER === "dom";
+const chatActions = process.env.BIBCODE_E2E_FOOTER_CHAT === "1";
+
+/** mulberry32: small, deterministic, and enough to replay a failing sequence. */
+function createRandom(initial: number): () => number {
+ let state = initial >>> 0;
+ return () => {
+ state = (state + 0x6d2b79f5) >>> 0;
+ let t = state;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+type TuiSize = FixtureTuiSize;
+
+function readTuiSize(): TuiSize | null {
+ return readFixtureTuiSize(tuiLogPath);
+}
+
+interface TerminalGeometry {
+ readonly found: boolean;
+ readonly renderer: "dom" | "webgl" | "none";
+ readonly mountHeight: number;
+ readonly mountPaddingY: number;
+ readonly screenHeight: number;
+ readonly screenBottom: number;
+ readonly domRows: number | null;
+ readonly lastRows: ReadonlyArray;
+ /** Every drawn row that holds the footer's last line, whatever its position. */
+ readonly footerRows: ReadonlyArray;
+ readonly clippedBy: ReadonlyArray<{ readonly element: string; readonly overflow: number }>;
+ readonly notice: boolean;
+ /**
+ * Pixels between the end of xterm's scrollbar slider and the end of its track, or null when
+ * the slider or a track with height is missing (a renamed class must not pass as 0).
+ */
+ readonly scrollGap: number | null;
+ readonly viewport: { readonly width: number; readonly height: number };
+}
+
+function readTerminalGeometry(terminalId: string): Promise {
+ return browser.execute((id: string): TerminalGeometry => {
+ const empty: TerminalGeometry = {
+ found: false,
+ renderer: "none",
+ mountHeight: 0,
+ mountPaddingY: 0,
+ screenHeight: 0,
+ screenBottom: 0,
+ domRows: null,
+ lastRows: [],
+ footerRows: [],
+ clippedBy: [],
+ notice: false,
+ scrollGap: null,
+ viewport: { width: window.innerWidth, height: window.innerHeight },
+ };
+ const mount = document.querySelector(
+ `[data-terminal-xterm-mount="${CSS.escape(id)}"]`,
+ );
+ // A center column collapsed behind a maximized right panel keeps its height but has no
+ // width: nothing is shown, and the terminal deliberately keeps its last size (c192a96c7).
+ const mountRect = mount?.getBoundingClientRect();
+ if (!mount || !mountRect || mountRect.height <= 0 || mountRect.width <= 0) return empty;
+ if (mount.closest('[data-visible="false"]') || getComputedStyle(mount).visibility === "hidden")
+ return empty;
+ // A maximized right panel can also cover a center column that keeps its size: what is
+ // drawn at the mount's corner then belongs to the panel, and the terminal is not shown.
+ const probe = document.elementFromPoint(
+ mountRect.left + Math.min(8, mountRect.width / 2),
+ mountRect.top + Math.min(8, mountRect.height / 2),
+ );
+ if (!probe || !mount.contains(probe)) return empty;
+ const xterm = mount.querySelector(".xterm");
+ const screen = mount.querySelector(".xterm-screen");
+ if (!xterm || !screen) return empty;
+ const rowsElement = mount.querySelector(".xterm-rows");
+ const webglCanvas = [...screen.querySelectorAll("canvas")].some(
+ (canvas) => !canvas.classList.contains("xterm-link-layer") && canvas.style.display !== "none",
+ );
+ const domRows = !webglCanvas && rowsElement ? rowsElement.children.length : null;
+ const xtermStyle = getComputedStyle(xterm);
+ const screenRect = screen.getBoundingClientRect();
+ const clippedBy: Array<{ element: string; overflow: number }> = [];
+ for (
+ let element: HTMLElement | null = mount;
+ element && element !== document.documentElement;
+ element = element.parentElement
+ ) {
+ const overflowY = getComputedStyle(element).overflowY;
+ if (element !== mount && !/(hidden|clip|auto|scroll)/.test(overflowY)) continue;
+ const overflow = screenRect.bottom - element.getBoundingClientRect().bottom;
+ if (overflow > 0.5) {
+ clippedBy.push({
+ element: `${element.tagName.toLowerCase()}.${String(element.className).slice(0, 80)}`,
+ overflow,
+ });
+ }
+ }
+ if (screenRect.bottom - window.innerHeight > 0.5) {
+ clippedBy.push({ element: "viewport", overflow: screenRect.bottom - window.innerHeight });
+ }
+ return {
+ found: true,
+ renderer: webglCanvas ? "webgl" : rowsElement ? "dom" : "none",
+ mountHeight: mount.clientHeight,
+ mountPaddingY:
+ Number.parseFloat(xtermStyle.paddingTop) + Number.parseFloat(xtermStyle.paddingBottom),
+ screenHeight: screenRect.height,
+ screenBottom: screenRect.bottom,
+ domRows,
+ lastRows:
+ domRows === null
+ ? []
+ : [...rowsElement!.children].slice(-3).map((row) => (row.textContent ?? "").trimEnd()),
+ footerRows:
+ domRows === null
+ ? []
+ : [...rowsElement!.children]
+ .map((row) => (row.textContent ?? "").trimEnd())
+ .filter((text) => text.includes("fixture-footer-z")),
+ clippedBy,
+ notice: (mount.parentElement?.textContent ?? "").includes("Sized for another window"),
+ // xterm 6 scrolls a virtual element, not .xterm-viewport: its vertical slider ends at
+ // the bottom of its track exactly when the viewport is at the bottom.
+ // xterm 6.1 renames the classes to `.xterm-scrollbar` and `.xterm-slider`.
+ scrollGap: (() => {
+ const slider = mount.querySelector(
+ ".scrollbar.vertical > .slider, .xterm-scrollbar.vertical > .xterm-slider, .xterm-scrollbar > .xterm-slider",
+ );
+ const track = slider?.parentElement;
+ return slider && track && track.clientHeight > 0
+ ? Math.round(track.clientHeight - slider.offsetTop - slider.offsetHeight)
+ : null;
+ })(),
+ viewport: { width: window.innerWidth, height: window.innerHeight },
+ };
+ }, terminalId);
+}
+
+interface Check {
+ readonly at: string;
+ readonly tui: TuiSize | null;
+ readonly geometry: TerminalGeometry;
+ readonly xtermRows: number | null;
+ readonly fitRows: number | null;
+ readonly violations: ReadonlyArray;
+}
+
+/**
+ * The cell height is fixed for a session (font, size and scale do not change
+ * here), so it is measured once from a settled terminal and then turns screen
+ * and mount heights into row counts for either renderer.
+ */
+let cellHeight: number | null = null;
+
+function evaluate(at: string, tui: TuiSize | null, geometry: TerminalGeometry): Check {
+ const violations: string[] = [];
+ if (!geometry.found) {
+ return { at, tui, geometry, xtermRows: null, fitRows: null, violations };
+ }
+ const xtermRows =
+ geometry.domRows ?? (cellHeight ? Math.round(geometry.screenHeight / cellHeight) : null);
+ const fitRows = cellHeight
+ ? Math.floor((geometry.mountHeight - geometry.mountPaddingY) / cellHeight + 1e-6)
+ : null;
+ if (tui === null) violations.push("the fixture TUI never logged a size");
+ else if (xtermRows !== null && tui.rows !== xtermRows)
+ violations.push(`PTY has ${tui.rows} rows but xterm draws ${xtermRows}`);
+ if (xtermRows !== null && fitRows !== null && xtermRows !== fitRows && !geometry.notice)
+ violations.push(`xterm draws ${xtermRows} rows but ${fitRows} fit its mount`);
+ for (const clip of geometry.clippedBy)
+ violations.push(`the last row is ${clip.overflow.toFixed(1)}px below ${clip.element}`);
+ // A program writing a transcript keeps the viewport pinned to the bottom.
+ if (cliLayout && geometry.scrollGap === null)
+ violations.push("xterm's scroll bar slider or its track was not found");
+ else if (cliLayout && geometry.scrollGap !== null && geometry.scrollGap > 1)
+ violations.push(`the viewport is scrolled up (slider ${geometry.scrollGap}px above its end)`);
+ if (floatingLayout) {
+ // A frame drawn in the normal buffer sits wherever the cursor was, so only its count is
+ // known: the footer must appear exactly once at the current size, never stale or doubled.
+ if (geometry.renderer === "dom" && tui !== null) {
+ const expected = `fixture-footer-z ${tui.rows}`;
+ // A narrow terminal truncates the footer; a cut-off prefix still counts as it.
+ const current = geometry.footerRows.filter(
+ (text) => text.startsWith(expected) || expected.startsWith(text),
+ );
+ if (current.length !== 1)
+ violations.push(
+ `the footer appears ${current.length} times at ${tui.rows} rows: ${JSON.stringify(geometry.footerRows)}`,
+ );
+ }
+ } else if (geometry.renderer === "dom" && tui !== null && tui.rows >= 5) {
+ // A narrower terminal wraps the footer's lines (the full-screen TUI; a later reflow then
+ // merges rows) or cuts them at the width less one column (the cli program), so the text is
+ // checked only where its longest line fits.
+ const expected = `fixture-footer-z ${tui.rows}`;
+ const longest = `fixture-footer-a ${tui.cols}x${tui.rows}`;
+ if (tui.cols > longest.length && !(geometry.lastRows.at(-1) ?? "").startsWith(expected))
+ violations.push(`the footer is not on the last row: ${JSON.stringify(geometry.lastRows)}`);
+ }
+ return { at, tui, geometry, xtermRows, fitRows, violations };
+}
+
+async function settle(): Promise {
+ await settleFixtureTui(tuiLogPath);
+}
+
+interface Session {
+ readonly terminalId: string;
+ readonly steps: string[];
+ readonly checks: Check[];
+}
+
+async function failWith(session: Session, check: Check): Promise {
+ const stamp = `${seed}-${session.steps.length}-${Date.now()}`;
+ const screenshot = NodePath.join(preparedArtifactDirectory, `terminal-footer-${stamp}.png`);
+ await browser.saveScreenshot(screenshot).catch(() => undefined);
+ const report = {
+ seed,
+ stepCount,
+ stepsSoFar: session.steps,
+ failing: check,
+ recentChecks: session.checks.slice(-6),
+ cellHeight,
+ screenshot,
+ };
+ NodeFS.writeFileSync(
+ NodePath.join(preparedArtifactDirectory, `terminal-footer-${stamp}.json`),
+ JSON.stringify(report, null, 2),
+ );
+ throw new Error(
+ `#348 footer invariant broke after step ${session.steps.length} (seed ${seed}): ${check.violations.join("; ")}. ` +
+ `Steps: ${session.steps.join(" | ")}. Report: ${JSON.stringify(report)}`,
+ );
+}
+
+/**
+ * A settling check only records: a size change is in flight for a moment after
+ * a reload, a split, or a reconnect. The check 1.5 s later is the one that fails.
+ */
+async function assertInvariant(session: Session, at: string, final = true): Promise {
+ const check = evaluate(at, readTuiSize(), await readTerminalGeometry(session.terminalId));
+ session.checks.push(check);
+ if (final && check.violations.length > 0) await failWith(session, check);
+}
+
+const terminalTab =
+ '//*[@data-center-panel-tab-list]//button[.//span[normalize-space()="Codex Terminal"]]';
+const chatTab = '//*[@data-center-panel-tab-list]//button[.//span[normalize-space()="Codex"]]';
+
+async function clickIfUsable(selector: string): Promise {
+ const element = browser.$(selector);
+ if (!(await element.isExisting()) || !(await element.isDisplayed())) return false;
+ if (!(await element.isEnabled())) return false;
+ await element.click();
+ return true;
+}
+
+async function showTerminal(): Promise {
+ await clickIfUsable(terminalTab);
+}
+
+/** Rewrites the persisted center layout for the fixture thread, then reloads to apply it. */
+async function setSplit(terminalId: string, split: boolean): Promise {
+ await browser.execute(
+ (id: string, wantSplit: boolean) => {
+ const key = "bibcode:center-panel-state:v1";
+ const persisted = JSON.parse(window.localStorage.getItem(key) ?? "{}") as {
+ state?: {
+ byThreadKey?: Record<
+ string,
+ {
+ surfaces: ReadonlyArray<{ id: string }>;
+ groups: unknown;
+ layout: unknown;
+ focusedGroupId: unknown;
+ }
+ >;
+ };
+ };
+ const surfaceId = `terminal:${id}`;
+ for (const thread of Object.values(persisted.state?.byThreadKey ?? {})) {
+ if (!thread.surfaces.some((surface) => surface.id === surfaceId)) continue;
+ Object.assign(
+ thread,
+ wantSplit
+ ? {
+ groups: [
+ { id: "center:root", surfaceIds: ["chat:host"], activeSurfaceId: "chat:host" },
+ {
+ id: "center-group:footer-split",
+ surfaceIds: [surfaceId],
+ activeSurfaceId: surfaceId,
+ },
+ ],
+ layout: {
+ type: "split",
+ direction: "horizontal",
+ ratio: 0.5,
+ first: { type: "leaf", groupId: "center:root" },
+ second: { type: "leaf", groupId: "center-group:footer-split" },
+ },
+ focusedGroupId: "center-group:footer-split",
+ }
+ : {
+ groups: [
+ {
+ id: "center:root",
+ surfaceIds: ["chat:host", surfaceId],
+ activeSurfaceId: surfaceId,
+ },
+ ],
+ layout: { type: "leaf", groupId: "center:root" },
+ focusedGroupId: "center:root",
+ },
+ );
+ }
+ window.localStorage.setItem(key, JSON.stringify(persisted));
+ },
+ terminalId,
+ split,
+ );
+ await reloadDocument();
+}
+
+/** A chat action can leave the chat tab active, and the terminal mounts only when shown. */
+async function showTerminalAfterChat(): Promise {
+ if (!chatActions) return;
+ await browser
+ .$(terminalTab)
+ .waitForExist({ timeout: 15_000 })
+ .catch(() => undefined);
+ await showTerminal();
+}
+
+async function reloadDocument(): Promise {
+ await refreshDesktopUiDocument();
+ await installDesktopUiMotionGuard();
+ await showTerminalAfterChat();
+ await browser.$(`[data-terminal-xterm-mount]`).waitForExist({
+ timeout: 30_000,
+ timeoutMsg: "The terminal did not mount again after a reload.",
+ });
+}
+
+async function dragRightPanelEdge(dx: number): Promise {
+ const handle = browser.$('[role="separator"][aria-orientation="vertical"].cursor-col-resize');
+ if (!(await handle.isExisting()) || !(await handle.isDisplayed())) return false;
+ const location = await handle.getLocation();
+ const size = await handle.getSize();
+ const x = Math.round(location.x + size.width / 2);
+ const y = Math.round(location.y + size.height / 2);
+ try {
+ // A real pointer drag: the handle captures the pointer, which synthetic events cannot.
+ await browser
+ .action("pointer", { parameters: { pointerType: "mouse" } })
+ .move({ x, y })
+ .down()
+ .move({ x: x + Math.round(dx / 2), y, duration: 100 })
+ .move({ x: x + dx, y, duration: 100 })
+ .up()
+ .perform();
+ return true;
+ } catch {
+ // The embedded driver may not implement pointer actions; the step is skipped.
+ return false;
+ }
+}
+
+type Action = (session: Session, random: () => number) => Promise;
+
+const actions: ReadonlyArray = [
+ [
+ "right panel toggle",
+ async () =>
+ (await clickIfUsable('button[aria-label="Toggle right panel"]'))
+ ? "toggle right panel"
+ : null,
+ ],
+ [
+ "right panel maximize",
+ async () => {
+ if (await clickIfUsable('button[aria-label="Maximize panel"]')) return "maximize right panel";
+ if (await clickIfUsable('button[aria-label="Restore panel size"]'))
+ return "restore right panel";
+ return null;
+ },
+ ],
+ [
+ "right panel edge drag",
+ async (_session, random) => {
+ const dx = Math.round((random() - 0.5) * 400);
+ return (await dragRightPanelEdge(dx)) ? `drag right panel edge ${dx}px` : null;
+ },
+ ],
+ [
+ "activity panel",
+ async (session) => {
+ const button = `[data-provider-terminal-activity-host="${session.terminalId}"] [data-testid="activity-dock"] button`;
+ return (await clickIfUsable(button)) ? "toggle terminal Activity panel" : null;
+ },
+ ],
+ [
+ "center split",
+ async (session, random) => {
+ const split = random() < 0.5;
+ await setSplit(session.terminalId, split);
+ return split ? "split center (reload)" : "unsplit center (reload)";
+ },
+ ],
+ [
+ "tab switch",
+ async () => {
+ if (!(await clickIfUsable(chatTab))) return null;
+ await browser.pause(300);
+ await showTerminal();
+ return "switch to chat tab and back";
+ },
+ ],
+ [
+ "route switch",
+ async () => {
+ const current = await browser.getUrl();
+ const origin = await browser.execute(() => window.location.origin);
+ await browser.url(`${origin}/#/settings/terminal`);
+ await browser.pause(500);
+ await browser.url(current);
+ await showTerminalAfterChat();
+ await browser.$(`[data-terminal-xterm-mount]`).waitForExist({ timeout: 15_000 });
+ await showTerminal();
+ return "open settings and come back";
+ },
+ ],
+ [
+ "sidebar",
+ async () =>
+ (await clickIfUsable('button[aria-label="Toggle main sidebar"]'))
+ ? "toggle main sidebar"
+ : null,
+ ],
+ [
+ "window resize",
+ async (session, random) => {
+ const viewport = session.checks.at(-1)?.geometry.viewport ?? { width: 1_200, height: 720 };
+ const width = 960 + Math.round(random() * 440);
+ const height = 560 + Math.round(random() * 300);
+ const mode = random();
+ const next =
+ mode < 1 / 3
+ ? { width: viewport.width, height }
+ : mode < 2 / 3
+ ? { width, height: viewport.height }
+ : { width, height };
+ try {
+ await setDesktopUiWindowSize(next.width, next.height);
+ } catch {
+ // Some window managers (GTK under Xvfb) settle near the size but never on it; the
+ // invariant holds at whatever size the window reached, so the step records that.
+ const reached = await browser.execute(() => `${window.innerWidth}x${window.innerHeight}`);
+ return `resize window toward ${next.width}x${next.height} (reached ${reached})`;
+ }
+ return `resize window to ${next.width}x${next.height}`;
+ },
+ ],
+ [
+ "reload",
+ async () => {
+ await reloadDocument();
+ await showTerminal();
+ return "reload and reattach";
+ },
+ ],
+];
+
+let chatMessages = 0;
+const visibleComposer =
+ '[data-center-surface-host][data-visible="true"] [data-testid="composer-editor"]';
+
+/** Uses the chat beside (or instead of) the terminal: a send, or a composer that grows and empties. */
+function chatAction(kind: "send" | "grow"): Action {
+ return async (_session, random) => {
+ let composer = browser.$(visibleComposer);
+ let switched = false;
+ if (!(await composer.isExisting()) || !(await composer.isDisplayed())) {
+ if (!(await clickIfUsable(chatTab))) return null;
+ switched = true;
+ composer = browser.$(visibleComposer);
+ if (!(await composer.isDisplayed().catch(() => false))) return null;
+ }
+ await composer.click();
+ if (kind === "send") {
+ chatMessages += 1;
+ await composer.addValue(`fixture message ${chatMessages}`);
+ await browser.keys("Enter");
+ await browser.pause(400 + Math.round(random() * 1_200));
+ } else {
+ for (let line = 0; line < 6; line += 1) {
+ await composer.addValue(`grow line ${line}`);
+ await browser.keys(["Shift", "Enter"]);
+ }
+ await browser.pause(600);
+ // Select the draft without a platform shortcut (Control+A is not select-all on macOS).
+ await browser.execute(() => {
+ const editor = document.activeElement;
+ if (!(editor instanceof HTMLElement)) return;
+ const range = document.createRange();
+ range.selectNodeContents(editor);
+ window.getSelection()?.removeAllRanges();
+ window.getSelection()?.addRange(range);
+ });
+ await browser.keys("Backspace");
+ }
+ if (switched && random() < 0.7) await showTerminal();
+ return `chat ${kind}${switched ? " via the chat tab" : " beside the terminal"}`;
+ };
+}
+
+const actionList: ReadonlyArray = chatActions
+ ? [
+ ...actions,
+ ["chat send", chatAction("send")],
+ ["chat send", chatAction("send")],
+ // The center split already puts the chat beside the terminal.
+ ["composer grow", chatAction("grow")],
+ ]
+ : actions;
+
+async function openFixtureThread(): Promise {
+ await openFixtureTerminalThread(preparedProjectPath, preparedCodexExecutable, {
+ webglEnabled: !domRenderer,
+ });
+}
+
+async function openCodexTerminal(): Promise {
+ return await openFixtureCodexTerminal();
+}
+
+describe("packaged AI terminal footer invariant (#348)", () => {
+ it("keeps the PTY, xterm and the visible mount in agreement across seeded layout changes", async function () {
+ // Each step takes about 3 s; the configured case timeout alone ends the loop near step 40.
+ // Setup and the chat mode's turns add up to a minute on a loaded host before the first step.
+ this.timeout(Math.max(this.timeout(), 120_000 + stepCount * 20_000));
+ clearDesktopActivityMarker(preparedProjectPath);
+ await setDesktopUiWindowSize(1_200, 720);
+ await openFixtureThread();
+ const logStart = readTuiSize()?.bytes ?? 0;
+ const terminalId = await openCodexTerminal();
+ await browser.waitUntil(async () => (readTuiSize()?.bytes ?? 0) > logStart, {
+ timeout: 30_000,
+ timeoutMsg: "The fixture TUI never drew in the Codex Terminal.",
+ });
+ await settle();
+ // The Activity dock mounts 0.4 s or so after the terminal and shrinks it, and the refit
+ // lands about 100 ms later: the baseline waits for the dock, then for that size to hold.
+ const dockMounted = await browser
+ .$(`[data-provider-terminal-activity-host="${terminalId}"] [data-testid="activity-dock"]`)
+ .waitForExist({ timeout: 5_000 })
+ .then(
+ () => true,
+ () => false,
+ );
+ if (dockMounted) await settle();
+
+ const baseline = await readTerminalGeometry(terminalId);
+ const baselineTui = readTuiSize();
+ if (!baseline.found || baselineTui === null) {
+ throw new Error(`The terminal did not settle: ${JSON.stringify({ baseline, baselineTui })}`);
+ }
+ // The first settled frame defines the cell height every later check uses.
+ cellHeight = baseline.screenHeight / (baseline.domRows ?? baselineTui.rows);
+
+ const session: Session = { terminalId, steps: [], checks: [] };
+ await assertInvariant(session, "baseline");
+
+ const random = createRandom(seed);
+ for (let index = 0; index < stepCount; index += 1) {
+ const [name, run] = actionList[Math.floor(random() * actionList.length)]!;
+ const description = await run(session, random);
+ session.steps.push(description ?? `${name} (unavailable, skipped)`);
+ await settle();
+ await assertInvariant(session, `step ${index + 1}`, false);
+ await browser.pause(1_500);
+ await assertInvariant(session, `step ${index + 1} +1.5s`);
+ }
+ NodeFS.writeFileSync(
+ NodePath.join(preparedArtifactDirectory, `terminal-footer-${seed}-passed.json`),
+ JSON.stringify(
+ {
+ seed,
+ stepCount,
+ steps: session.steps,
+ cellHeight,
+ // Settling checks that disagreed and cleared within 1.5 s, and the renderers seen.
+ transients: session.checks.filter((check) => check.violations.length > 0),
+ renderers: [...new Set(session.checks.map((check) => check.geometry.renderer))],
+ },
+ null,
+ 2,
+ ),
+ );
+ });
+});
diff --git a/apps/desktop/e2e/support/activity-session.test.ts b/apps/desktop/e2e/support/activity-session.test.ts
index f964307a83..b091c02c38 100644
--- a/apps/desktop/e2e/support/activity-session.test.ts
+++ b/apps/desktop/e2e/support/activity-session.test.ts
@@ -378,6 +378,22 @@ describe("materializeDesktopActivitySession", () => {
);
});
+ it("turns the WebGL terminal renderer off in the same settings update when asked", async () => {
+ installHarness();
+
+ await configureDesktopActivityCodexExecutable("/fixture/provider-shims/codex", {
+ webglEnabled: false,
+ });
+
+ expect(sentRequests).toHaveLength(1);
+ expect(sentRequests[0]?.payload).toEqual({
+ patch: expect.objectContaining({
+ enableTerminalAgentActivity: true,
+ terminal: { webglEnabled: false },
+ }),
+ });
+ });
+
it("synchronizes the activity terminal with the exact configured Codex executable", async () => {
installHarness();
diff --git a/apps/desktop/e2e/support/activity-session.ts b/apps/desktop/e2e/support/activity-session.ts
index 371fc86373..33bb3a6c2c 100644
--- a/apps/desktop/e2e/support/activity-session.ts
+++ b/apps/desktop/e2e/support/activity-session.ts
@@ -347,13 +347,19 @@ export async function stopDesktopActivitySession(): Promise {
* Refreshes the server config stream with the same absolute Codex executable
* that the disposable provider inventory authorizes for the terminal launch.
*/
-export async function configureDesktopActivityCodexExecutable(executable: string): Promise {
+export async function configureDesktopActivityCodexExecutable(
+ executable: string,
+ options: { readonly webglEnabled?: boolean } = {},
+): Promise {
await dispatchDesktopActivityCommands([], null, [
{
tag: "server.updateSettings",
payload: {
patch: {
enableTerminalAgentActivity: true,
+ ...(options.webglEnabled === undefined
+ ? {}
+ : { terminal: { webglEnabled: options.webglEnabled } }),
providers: {
codex: {
enabled: true,
diff --git a/apps/desktop/e2e/support/app-lifecycle.test.ts b/apps/desktop/e2e/support/app-lifecycle.test.ts
index ddc5b50be6..605b8791b9 100644
--- a/apps/desktop/e2e/support/app-lifecycle.test.ts
+++ b/apps/desktop/e2e/support/app-lifecycle.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it, vi } from "vite-plus/test";
-import { dispatchDesktopUiApplicationExit, isFinalDesktopUiSpec } from "./app-lifecycle.ts";
+import {
+ dispatchDesktopUiApplicationExit,
+ isDeadDesktopUiWebviewError,
+ isFinalDesktopUiSpec,
+ recoverDeadDesktopUiWebview,
+} from "./app-lifecycle.ts";
describe("dispatchDesktopUiApplicationExit", () => {
it("waits for native runtime shutdown before closing the main Tauri window", async () => {
@@ -51,3 +56,66 @@ describe("isFinalDesktopUiSpec", () => {
).toBe(true);
});
});
+
+describe("isDeadDesktopUiWebviewError", () => {
+ it.each([
+ "invalid session id",
+ "no such window: target window already closed",
+ "Error: connect ECONNREFUSED 127.0.0.1:4445",
+ "socket hang up",
+ // WebKitGTK after its web process was killed (Linux lab, 2026-11-12).
+ 'javascript error: WebDriverError: Unsupported result type when running "execute/sync" with method "POST"',
+ ])("recognises %s as a dead webview", (message) => {
+ expect(isDeadDesktopUiWebviewError(new Error(message))).toBe(true);
+ });
+
+ it("leaves an ordinary script failure alone", () => {
+ expect(isDeadDesktopUiWebviewError(new Error("TypeError: x is not a function"))).toBe(false);
+ expect(isDeadDesktopUiWebviewError("invalid session id")).toBe(false);
+ });
+});
+
+describe("recoverDeadDesktopUiWebview", () => {
+ const deps = (probe: () => Promise, running: boolean) => ({
+ probe: vi.fn(probe),
+ isRunning: vi.fn(async () => running),
+ stopApplication: vi.fn(async () => undefined),
+ relaunch: vi.fn(async () => undefined),
+ });
+
+ it("does nothing when the probe answers", async () => {
+ const live = deps(async () => true, true);
+ await expect(recoverDeadDesktopUiWebview(live)).resolves.toBe(false);
+ expect(live.relaunch).not.toHaveBeenCalled();
+ expect(live.stopApplication).not.toHaveBeenCalled();
+ });
+
+ it("relaunches an app whose process is gone", async () => {
+ const dead = deps(() => Promise.reject(new Error("invalid session id")), false);
+ await expect(recoverDeadDesktopUiWebview(dead)).resolves.toBe(true);
+ expect(dead.stopApplication).not.toHaveBeenCalled();
+ expect(dead.relaunch).toHaveBeenCalledOnce();
+ });
+
+ it("stops an app that still runs behind a dead webview before relaunching it", async () => {
+ const order: string[] = [];
+ const dead = deps(() => Promise.reject(new Error("no such window")), true);
+ dead.stopApplication.mockImplementation(async () => void order.push("stop"));
+ dead.relaunch.mockImplementation(async () => void order.push("relaunch"));
+ await expect(recoverDeadDesktopUiWebview(dead)).resolves.toBe(true);
+ expect(order).toEqual(["stop", "relaunch"]);
+ });
+
+ it("rethrows a probe failure that is not a dead webview, without relaunching", async () => {
+ const odd = deps(() => Promise.reject(new Error("TypeError: boom")), true);
+ await expect(recoverDeadDesktopUiWebview(odd)).rejects.toThrow("boom");
+ expect(odd.relaunch).not.toHaveBeenCalled();
+ });
+
+ it("relaunches once: a relaunch that fails is reported, not retried", async () => {
+ const dead = deps(() => Promise.reject(new Error("invalid session id")), false);
+ dead.relaunch.mockRejectedValue(new Error("never served WebDriver"));
+ await expect(recoverDeadDesktopUiWebview(dead)).rejects.toThrow("never served WebDriver");
+ expect(dead.relaunch).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/desktop/e2e/support/app-lifecycle.ts b/apps/desktop/e2e/support/app-lifecycle.ts
index eb6642be9f..0a69ee070e 100644
--- a/apps/desktop/e2e/support/app-lifecycle.ts
+++ b/apps/desktop/e2e/support/app-lifecycle.ts
@@ -1,3 +1,13 @@
+// @effect-diagnostics nodeBuiltinImport:off - The lifecycle helper relaunches and observes the packaged app.
+// @effect-diagnostics globalTimers:off - The helper polls the native process between WebDriver sessions.
+// @effect-diagnostics globalDate:off - The standalone helper bounds its native-process waits.
+// @effect-diagnostics globalFetch:off - The helper probes the embedded WebDriver server's status.
+import * as NodeChildProcess from "node:child_process";
+import * as NodeFS from "node:fs";
+import * as NodePath from "node:path";
+
+import { resolveDesktopAppPath, type DesktopUiPlatform } from "./app-path.ts";
+
interface DesktopUiApplicationHost {
readonly __TAURI__?: {
readonly core?: {
@@ -6,6 +16,9 @@ interface DesktopUiApplicationHost {
};
}
+/** The bundle identifier of `tauri.e2e.conf.json`, never the user's own BiBCode. */
+export const DESKTOP_UI_BUNDLE_IDENTIFIER = "com.bibcode.desktop.e2e";
+
function desktopUiSpecFileName(path: string): string | undefined {
return path.replaceAll("\\", "/").split(/[?#]/, 1)[0]?.split("/").at(-1);
}
@@ -26,16 +39,315 @@ export async function dispatchDesktopUiApplicationExit(
const invoke = host.__TAURI__?.core?.invoke;
if (invoke === undefined) return false;
+ // Disarms the close question first, so an edit a spec left unsaved cannot hold the exit.
await invoke("desktop_e2e_prepare_for_exit");
void invoke("plugin:window|close", { label: "main" }).catch(() => undefined);
return true;
}
+function webDriverPort(environment: NodeJS.ProcessEnv = process.env): number {
+ return Number(environment.BIBCODE_E2E_WEBDRIVER_PORT ?? 4_445);
+}
+
+/** Whether the packaged app's embedded WebDriver server answers, so the app is running. */
+export async function isDesktopUiApplicationRunning(
+ port: number = webDriverPort(),
+): Promise {
+ try {
+ const response = await fetch(`http://127.0.0.1:${port}/status`, {
+ signal: AbortSignal.timeout(2_000),
+ });
+ return response.ok;
+ } catch {
+ return false;
+ }
+}
+
+/** Ends the app at teardown. An app a closing case already ended is accepted as done. */
export async function requestDesktopUiApplicationExit(): Promise {
- const requested = await browser.execute(
- dispatchDesktopUiApplicationExit as () => Promise,
- );
+ if (!(await isDesktopUiApplicationRunning())) return;
+ let requested: boolean;
+ try {
+ requested = await browser.execute(dispatchDesktopUiApplicationExit as () => Promise);
+ } catch (error) {
+ // The exit can tear the page down under the request.
+ if (!(await isDesktopUiApplicationRunning())) return;
+ throw error;
+ }
if (!requested) {
throw new Error("The Tauri bridge was unavailable during desktop UI application shutdown.");
}
}
+
+/** The process that owns the WebDriver port: the running packaged app. */
+export function desktopUiApplicationPid(port: number = webDriverPort()): number {
+ const output = NodeChildProcess.execFileSync(
+ "lsof",
+ ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"],
+ { encoding: "utf8" },
+ ).trim();
+ const pid = Number(output.split("\n")[0]);
+ if (!Number.isInteger(pid) || pid <= 0) {
+ throw new Error(`No packaged app listens on WebDriver port ${port}.`);
+ }
+ return pid;
+}
+
+/** Every TCP port `pid` listens on, the embedded BiBCode server's among them. */
+export function desktopUiListeningPorts(pid: number): number[] {
+ let output: string;
+ try {
+ output = NodeChildProcess.execFileSync(
+ "lsof",
+ ["-nP", "-a", "-p", String(pid), "-iTCP", "-sTCP:LISTEN", "-Fn"],
+ { encoding: "utf8" },
+ );
+ } catch {
+ return [];
+ }
+ return [...output.matchAll(/^n.*:(\d+)$/gm)].map((match) => Number(match[1]));
+}
+
+/** Processes running exactly this packaged binary: only the isolated e2e install. */
+function runningBinaryPids(appBinaryPath: string): number[] {
+ try {
+ return NodeChildProcess.execFileSync("pgrep", ["-f", "-x", appBinaryPath], {
+ encoding: "utf8",
+ })
+ .trim()
+ .split("\n")
+ .map(Number)
+ .filter((pid) => Number.isInteger(pid) && pid > 0);
+ } catch {
+ // pgrep exits 1 when nothing matches.
+ return [];
+ }
+}
+
+export function isProcessAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (error) {
+ return (error as NodeJS.ErrnoException).code === "EPERM";
+ }
+}
+
+/** Waits until the process is gone; returns how long that took. */
+export async function waitForDesktopUiApplicationExit(
+ pid: number,
+ timeoutMs: number,
+): Promise {
+ const started = Date.now();
+ while (isProcessAlive(pid)) {
+ if (Date.now() - started > timeoutMs) {
+ throw new Error(`The packaged app (pid ${pid}) was still running after ${timeoutMs} ms.`);
+ }
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ return Date.now() - started;
+}
+
+function runSystemEvents(script: string): string {
+ return NodeChildProcess.execFileSync("osascript", ["-e", script], { encoding: "utf8" }).trim();
+}
+
+/**
+ * Chooses a native menu item of the packaged app, the way a person does: the native close and
+ * quit protocol, with the backend still running. The process is picked by its bundle identifier,
+ * so the user's own BiBCode is never touched.
+ */
+export async function chooseDesktopUiMenuItem(itemPrefix: string): Promise {
+ if (desktopUiPlatform() !== "mac") {
+ throw new Error("Native menu items are only driven on macOS.");
+ }
+ // The service updates every registered mock after each command; once the item ends the app,
+ // that update fails the case, so drop the mocks first.
+ await browser.tauri.restoreAllMocks();
+ // A freshly launched app publishes its menu bar to accessibility a moment after it serves.
+ for (let attempt = 1; ; attempt += 1) {
+ try {
+ clickDesktopUiMenuItem(itemPrefix);
+ return;
+ } catch (error) {
+ if (attempt >= 20) throw error;
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+ }
+}
+
+function clickDesktopUiMenuItem(itemPrefix: string): void {
+ runSystemEvents(
+ [
+ 'tell application "System Events"',
+ ` tell (first application process whose bundle identifier is "${DESKTOP_UI_BUNDLE_IDENTIFIER}")`,
+ // The menu bar belongs to the active app; a relaunched copy starts in the background.
+ " set frontmost to true",
+ // macOS shows the first submenu (BiBCode's File menu) as the app menu, under the app's
+ // name, so the bar item is found by the item it holds rather than by its title.
+ " repeat with barItem in menu bar items of menu bar 1",
+ ` if exists (first menu item of menu 1 of barItem whose name starts with "${itemPrefix}") then`,
+ ` click (first menu item of menu 1 of barItem whose name starts with "${itemPrefix}")`,
+ " return",
+ " end if",
+ " end repeat",
+ ` error "No menu item starts with ${itemPrefix}."`,
+ " end tell",
+ "end tell",
+ ].join("\n"),
+ );
+}
+
+/** Asks the main window to close through its native Close Window item, with the backend up. */
+export function requestDesktopUiWindowClose(): Promise {
+ return chooseDesktopUiMenuItem("Close Window");
+}
+
+export function chooseDesktopUiQuit(): Promise {
+ return chooseDesktopUiMenuItem("Quit");
+}
+
+export function desktopUiPlatform(environment: NodeJS.ProcessEnv = process.env): DesktopUiPlatform {
+ const configured = environment.BIBCODE_E2E_PLATFORM;
+ if (configured === "mac" || configured === "linux" || configured === "win") return configured;
+ // oxlint-disable-next-line bibcode/no-global-process-runtime -- The worker follows WDIO's host fallback when no platform was supplied.
+ const host = process.platform;
+ return host === "darwin" ? "mac" : host === "win32" ? "win" : "linux";
+}
+
+let relaunched: NodeChildProcess.ChildProcess | null = null;
+let relaunchCount = 0;
+
+/**
+ * Starts the packaged app again after a closing case ended it, with the launcher's environment
+ * and port, and opens a new WebDriver session on it. The WDIO service only restarts the app
+ * between spec files, so each closing case of one spec gets its own app session here.
+ */
+export async function relaunchDesktopUiApplication(): Promise {
+ const port = webDriverPort();
+ if (await isDesktopUiApplicationRunning(port)) {
+ throw new Error("The packaged app is still running; it cannot be relaunched.");
+ }
+ const appBinaryPath = resolveDesktopAppPath({
+ platform: desktopUiPlatform(),
+ environment: process.env,
+ });
+ // The single-instance plugin hands a new launch to a copy that is still exiting, which then
+ // ends both, so wait until no process runs this packaged binary.
+ const started = Date.now();
+ while (runningBinaryPids(appBinaryPath).length > 0) {
+ if (Date.now() - started > 30_000) {
+ throw new Error("The previous packaged app was still exiting after 30 s.");
+ }
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ }
+ const artifactDirectory = process.env.BIBCODE_E2E_ARTIFACT_DIR;
+ if (!artifactDirectory) throw new Error("BIBCODE_E2E_ARTIFACT_DIR is required.");
+ relaunchCount += 1;
+ // A file, not a pipe: an unread pipe would stall the app's debug logging.
+ const log = NodeFS.openSync(
+ NodePath.join(artifactDirectory, `relaunched-app-${relaunchCount}.log`),
+ "a",
+ );
+ try {
+ relaunched = NodeChildProcess.spawn(appBinaryPath, [], {
+ env: { ...process.env, TAURI_WEBDRIVER_PORT: String(port), WDIO_EMBEDDED_SERVER: "true" },
+ stdio: ["ignore", log, log],
+ detached: false,
+ });
+ } finally {
+ NodeFS.closeSync(log);
+ }
+ const launched = Date.now();
+ while (!(await isDesktopUiApplicationRunning(port))) {
+ if (relaunched.exitCode !== null) {
+ throw new Error(`The relaunched packaged app exited with ${relaunched.exitCode}.`);
+ }
+ if (Date.now() - launched > 90_000) {
+ throw new Error("The relaunched packaged app never served WebDriver.");
+ }
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ }
+ try {
+ await browser.reloadSession();
+ } catch (error) {
+ // The service copies the old session's mocks onto the new one after it opens; the new app
+ // never had them, and each spec installs the mocks it needs again.
+ if (!(error instanceof Error) || !error.message.startsWith("Mock updates failed")) throw error;
+ }
+ // Drop the old app's mocks, or every later mock call retries them against the new app.
+ await browser.tauri.restoreAllMocks();
+ await browser.waitUntil(
+ () =>
+ browser.execute(() => {
+ const host = window as Window & { readonly wdioTauri?: { readonly execute?: unknown } };
+ return typeof host.wdioTauri?.execute === "function";
+ }),
+ { timeoutMsg: "The relaunched app's Tauri WebDriver plugin did not initialize." },
+ );
+}
+
+/** Ends an app this helper relaunched, if it still runs: only a process this helper started. */
+export async function stopRelaunchedDesktopUiApplication(): Promise {
+ const child = relaunched;
+ relaunched = null;
+ if (child?.pid === undefined || child.exitCode !== null) return;
+ // The service ends the app it started the same way: teardown already stopped the backend, and
+ // the webview may not close its own window.
+ child.kill("SIGTERM");
+ await waitForDesktopUiApplicationExit(child.pid, 10_000).catch(() => child.kill("SIGKILL"));
+}
+
+/**
+ * Driver errors that mean the webview or its session is gone, not that a script misbehaved.
+ * WebKitGTK answers every script with "Unsupported result type" once its web process has died;
+ * the probe's `() => true` cannot produce that from a live page.
+ */
+export function isDeadDesktopUiWebviewError(error: unknown): boolean {
+ return (
+ error instanceof Error &&
+ /invalid session id|no such (window|session)|session (deleted|not created)|window (was )?already closed|web ?view (not found|crashed|terminated)|unsupported result type|ECONNREFUSED|ECONNRESET|socket hang up/i.test(
+ error.message,
+ )
+ );
+}
+
+export interface DeadWebviewRecovery {
+ readonly probe: () => Promise;
+ readonly isRunning: () => Promise;
+ readonly stopApplication: () => Promise;
+ readonly relaunch: () => Promise;
+}
+
+/**
+ * One trivial script before a spec file. When it fails with a dead-session error, the app is
+ * relaunched once (after ending a process that survived its webview), so a webview crash fails
+ * only the spec that hit it. Returns whether it relaunched.
+ */
+export async function recoverDeadDesktopUiWebview(deps: DeadWebviewRecovery): Promise {
+ try {
+ await deps.probe();
+ return false;
+ } catch (error) {
+ if (!isDeadDesktopUiWebviewError(error)) throw error;
+ }
+ if (await deps.isRunning()) await deps.stopApplication();
+ await deps.relaunch();
+ return true;
+}
+
+async function stopRunningDesktopUiApplication(): Promise {
+ const pid = desktopUiApplicationPid();
+ process.kill(pid, "SIGTERM");
+ await waitForDesktopUiApplicationExit(pid, 10_000).catch(() => process.kill(pid, "SIGKILL"));
+}
+
+/** The per-spec-file probe of the shared app; see {@link recoverDeadDesktopUiWebview}. */
+export function ensureLiveDesktopUiWebview(): Promise {
+ return recoverDeadDesktopUiWebview({
+ probe: () => browser.execute(() => true),
+ isRunning: () => isDesktopUiApplicationRunning(),
+ stopApplication: stopRunningDesktopUiApplication,
+ relaunch: relaunchDesktopUiApplication,
+ });
+}
diff --git a/apps/desktop/e2e/support/fixture-terminal.ts b/apps/desktop/e2e/support/fixture-terminal.ts
new file mode 100644
index 0000000000..0b9fa28b17
--- /dev/null
+++ b/apps/desktop/e2e/support/fixture-terminal.ts
@@ -0,0 +1,93 @@
+// @effect-diagnostics nodeBuiltinImport:off - Packaged UI tests read the fixture TUI's size log.
+// @effect-diagnostics globalDate:off - The size log is polled against a wall-clock deadline.
+import * as NodeFS from "node:fs";
+import * as NodePath from "node:path";
+
+import {
+ configureDesktopActivityCodexExecutable,
+ materializeDesktopActivitySession,
+} from "./activity-session.ts";
+import { desktopActivityFixture } from "./activity-events.ts";
+import { refreshDesktopUiDocument } from "./document-navigation.ts";
+import { installDesktopUiMotionGuard } from "./motion-guard.ts";
+import { ensureMainSidebarOpen } from "./ui-state.ts";
+
+export interface FixtureTuiSize {
+ readonly cols: number;
+ readonly rows: number;
+ readonly bytes: number;
+}
+
+/** The fixture TUI appends every PTY size it is given inside `.git`, out of the worktree. */
+export function fixtureTuiLogPath(projectPath: string): string {
+ return NodePath.join(projectPath, ".git", "bibcode-fixture-tui-sizes.log");
+}
+
+export function readFixtureTuiSize(logPath: string): FixtureTuiSize | null {
+ if (!NodeFS.existsSync(logPath)) return null;
+ const log = NodeFS.readFileSync(logPath);
+ const last = log.toString("utf8").trim().split("\n").at(-1);
+ const match = last ? /^(\d+)x(\d+)$/.exec(last) : null;
+ return match ? { cols: Number(match[1]), rows: Number(match[2]), bytes: log.length } : null;
+}
+
+/** The TUI redraws on every SIGWINCH; a size that holds for 400 ms is final. */
+export async function settleFixtureTui(logPath: string): Promise {
+ const deadline = Date.now() + 5_000;
+ let previous = readFixtureTuiSize(logPath)?.bytes ?? 0;
+ while (Date.now() < deadline) {
+ await browser.pause(400);
+ const current = readFixtureTuiSize(logPath)?.bytes ?? 0;
+ if (current === previous) return;
+ previous = current;
+ }
+}
+
+/** Opens the seeded fixture thread whose Codex executable is the fixture TUI. */
+export async function openFixtureTerminalThread(
+ projectPath: string,
+ codexExecutable: string,
+ options: { readonly webglEnabled?: boolean } = {},
+): Promise {
+ await ensureMainSidebarOpen();
+ await materializeDesktopActivitySession(projectPath);
+ await configureDesktopActivityCodexExecutable(
+ codexExecutable,
+ options.webglEnabled === false ? { webglEnabled: false } : {},
+ );
+ await refreshDesktopUiDocument();
+ await installDesktopUiMotionGuard();
+ const threadRow = `//*[starts-with(@data-testid, "thread-row-") and .//*[normalize-space()="${desktopActivityFixture.thread.title}"]]`;
+ if (!(await browser.$(threadRow).isDisplayed())) {
+ const project = browser.$(
+ `//button[@data-sidebar="menu-button" and .//*[normalize-space()="${desktopActivityFixture.project.title}"]]`,
+ );
+ await project.waitForDisplayed({ timeoutMsg: "The fixture project did not appear." });
+ if ((await project.getAttribute("aria-expanded")) !== "true") await project.click();
+ }
+ for (const candidate of await browser.$$(threadRow)) {
+ if (await candidate.isDisplayed()) {
+ await candidate.click();
+ break;
+ }
+ }
+ await browser.$('[data-testid="composer-editor"]').waitForDisplayed({
+ timeoutMsg: "The fixture thread did not open.",
+ });
+}
+
+/** Opens a Codex Terminal in a new center panel and returns its terminal id. */
+export async function openFixtureCodexTerminal(): Promise {
+ const newPanel = browser.$('button[aria-label="New panel"]');
+ await newPanel.waitForDisplayed();
+ await newPanel.click();
+ const item = browser.$('//*[@role="menuitem"][.//span[normalize-space()="Codex Terminal"]]');
+ await item.waitForDisplayed();
+ await item.waitForEnabled();
+ await item.click();
+ const mount = browser.$("[data-terminal-xterm-mount]");
+ await mount.waitForExist({ timeoutMsg: "The Codex Terminal did not mount." });
+ const terminalId = await mount.getAttribute("data-terminal-xterm-mount");
+ if (!terminalId) throw new Error("The Codex Terminal mount has no terminal id.");
+ return terminalId;
+}
diff --git a/apps/desktop/e2e/support/provider-shims.test.ts b/apps/desktop/e2e/support/provider-shims.test.ts
index e05ec6a45a..b0f9ed014a 100644
--- a/apps/desktop/e2e/support/provider-shims.test.ts
+++ b/apps/desktop/e2e/support/provider-shims.test.ts
@@ -648,6 +648,44 @@ describe("generated provider shims", () => {
]);
});
+ it("answers a code review prompt with an ExitPlanMode plan (#293)", () => {
+ const fixture = prepareProviderFixture();
+ const prompt = "Code review requested from BiBCode. Do not edit files. Scope: x.\n\nReview.";
+ const responses = exchangeJsonLines(
+ fixture,
+ "claudeAgent",
+ ["--print", "--input-format", "stream-json", "--output-format", "stream-json"],
+ [
+ {
+ type: "user",
+ session_id: "claude-fixture",
+ message: { content: [{ type: "text", text: prompt }] },
+ },
+ ],
+ );
+ expect(responses).toContainEqual(
+ expect.objectContaining({
+ type: "assistant",
+ message: expect.objectContaining({
+ content: [
+ expect.objectContaining({
+ type: "tool_use",
+ name: "ExitPlanMode",
+ input: { plan: "## Review findings\n\n- Low: example finding" },
+ }),
+ ],
+ }),
+ }),
+ );
+ expect(responses).not.toContainEqual(expect.objectContaining({ type: "stream_event" }));
+ expect(responses).toContainEqual(
+ expect.objectContaining({ type: "result", subtype: "success", is_error: false }),
+ );
+ expect(readProviderInputLog(fixture.context.providerInputLogPath)).toMatchObject([
+ { provider: "claudeAgent", prompt },
+ ]);
+ });
+
it("streams a Claude thinking block, a tool call and an answer for a reasoning prompt", () => {
const fixture = prepareProviderFixture();
const responses = exchangeJsonLines(
diff --git a/apps/desktop/e2e/support/tauri-service-compat.test.ts b/apps/desktop/e2e/support/tauri-service-compat.test.ts
index 0210554bf7..8957f2b77d 100644
--- a/apps/desktop/e2e/support/tauri-service-compat.test.ts
+++ b/apps/desktop/e2e/support/tauri-service-compat.test.ts
@@ -62,7 +62,9 @@ describe("@wdio/tauri-service compatibility", () => {
const wdioConfig = NodeFS.readFileSync(new URL("../wdio.conf.ts", import.meta.url), "utf8");
expect(wdioConfig).toContain("const desktopUiSpecFiles =");
- expect(wdioConfig).toContain("[desktopUiSpecFiles]");
+ expect(wdioConfig).toContain(
+ "[desktopUiSpecFiles.filter((spec) => spec !== closingSpec), closingSpec]",
+ );
expect(wdioConfig).toContain("connectionRetryCount: 0");
expect(wdioConfig).toContain("commandTimeout: 60_000");
expect(wdioConfig).toContain("beforeTest: async () =>");
diff --git a/apps/desktop/e2e/support/test-project.ts b/apps/desktop/e2e/support/test-project.ts
index 3b93d57669..b79a6ae805 100644
--- a/apps/desktop/e2e/support/test-project.ts
+++ b/apps/desktop/e2e/support/test-project.ts
@@ -35,6 +35,13 @@ const LONG_REPLY_TURN_MARKER = "[[long-reply]]";
const LONG_REPLY_LINES = 1_000;
/** Matches the line the Claude shim writes. */
const longReplyLine = (line: number) => `Fixture long reply line ${line} fills the timeline.`;
+/**
+ * A Claude prompt that starts with the code review lead-in (#293) is answered, as Claude in plan
+ * mode answers, with an `ExitPlanMode` call whose plan holds the findings.
+ */
+const CODE_REVIEW_LEAD_IN = "Code review requested from BiBCode.";
+const CODE_REVIEW_FINDING = "Low: example finding";
+const CODE_REVIEW_PLAN = `## Review findings\n\n- ${CODE_REVIEW_FINDING}`;
/** An answer line names its prompt without the marker, so each turn's answer is its own. */
const reasoningAnswerLine = (variant: string, line: number, label: string) =>
`Fixture ${variant} answer line ${line} (${label}).`;
@@ -358,9 +365,71 @@ if (process.argv.includes("app-server") && listenArgumentIndex >= 0) {
// Each size is also appended inside .git, out of the worktree's status, where
// the packaged scenario reads it: a WebGL terminal has no text in the DOM.
if (process.stdout.isTTY) {
+ // BIBCODE_E2E_FOOTER_LAYOUT=floating draws a short frame in the normal buffer and redraws it
+ // in place, as Claude's CLI does, instead of pinning a footer to the last row.
+ const floatingFrame = process.env.BIBCODE_E2E_FOOTER_LAYOUT === "floating";
+ let floatingLines = 0;
+ // BIBCODE_E2E_FOOTER_LAYOUT=cli behaves like an Ink CLI such as Claude's: a long transcript in
+ // the normal buffer, a six-row frame on the bottom rows erased line by line (never a full clear)
+ // and rewritten every 80-120 ms, transcript lines streamed above it, lines cut to the width.
+ const cliFrame = process.env.BIBCODE_E2E_FOOTER_LAYOUT === "cli";
+ let cliRows = 0;
+ let cliTick = 0;
+ let cliLine = 0;
+ const drawCliFrame = (transcript) => {
+ const rows = process.stdout.rows;
+ const cols = process.stdout.columns;
+ const size = cols + "x" + rows;
+ const frame = [
+ "|/-\\"[cliTick % 4] + " fixture working " + cliTick,
+ "> " + "draft".padEnd(Math.max(0, Math.min(cols - 3, 40)), "."),
+ "",
+ "fixture-footer-a " + size,
+ "fixture-footer-b",
+ "fixture-footer-z " + rows,
+ ].map((text) => text.slice(0, Math.max(1, cols - 1)));
+ let erase = "";
+ for (let index = 0; index < cliRows; index += 1) erase += "\x1b[2K" + (index < cliRows - 1 ? "\x1b[1A" : "");
+ process.stdout.write(erase + "\r" + transcript.map((text) => text + "\r\n").join("") + frame.join("\r\n"));
+ cliRows = frame.length;
+ };
+ if (cliFrame) {
+ let intro = "";
+ for (; cliLine < 200; cliLine += 1) intro += "transcript " + cliLine + " " + "lorem ipsum ".repeat(4) + "\r\n";
+ process.stdout.write(intro);
+ const redraw = () => {
+ cliTick += 1;
+ drawCliFrame(Math.random() < 0.3 ? ["transcript " + cliLine++ + " streamed while the frame redraws"] : []);
+ setTimeout(redraw, 80 + Math.floor(Math.random() * 41));
+ };
+ setTimeout(redraw, 100);
+ }
const drawTerminalSize = () => {
- const size = process.stdout.columns + "x" + process.stdout.rows;
- process.stdout.write("\x1b[2J\x1b[Hfixture-tui " + size + "\r\n");
+ const rows = process.stdout.rows;
+ const size = process.stdout.columns + "x" + rows;
+ if (cliFrame) {
+ drawCliFrame([]);
+ try {
+ fs.appendFileSync(fixtureProjectPath + "/.git/bibcode-fixture-tui-sizes.log", size + "\n");
+ } catch {}
+ return;
+ }
+ if (floatingFrame) {
+ const frame = ["fixture-tui " + size, "fixture-footer-a " + size, "fixture-footer-b", "fixture-footer-z " + rows];
+ process.stdout.write((floatingLines > 0 ? "\x1b[" + floatingLines + "A\r\x1b[J" : "") + frame.join("\r\n") + "\r\n");
+ floatingLines = frame.length;
+ try {
+ fs.appendFileSync(fixtureProjectPath + "/.git/bibcode-fixture-tui-sizes.log", size + "\n");
+ } catch {}
+ return;
+ }
+ // A footer on the last three rows, as an agent CLI draws one, shows a clipped bottom (#348).
+ const footer = rows >= 5
+ ? "\x1b[" + (rows - 2) + ";1Hfixture-footer-a " + size +
+ "\x1b[" + (rows - 1) + ";1Hfixture-footer-b" +
+ "\x1b[" + rows + ";1Hfixture-footer-z " + rows + "\x1b[2;1H"
+ : "";
+ process.stdout.write("\x1b[2J\x1b[Hfixture-tui " + size + "\r\n" + footer);
try {
fs.appendFileSync(fixtureProjectPath + "/.git/bibcode-fixture-tui-sizes.log", size + "\n");
} catch {}
@@ -773,6 +842,33 @@ reader.on("line", (line) => {
const prompt = promptTextFromParts(message.message?.content);
appendProviderInput("claudeAgent", prompt);
send(message);
+ if (prompt.startsWith(${JSON.stringify(CODE_REVIEW_LEAD_IN)})) {
+ send({
+ type: "assistant",
+ session_id: sessionId,
+ uuid: "bibcode-ui-claude-review-plan-" + randomUUID(),
+ parent_tool_use_id: null,
+ message: {
+ id: "bibcode-ui-review-" + randomUUID(),
+ content: [{
+ type: "tool_use",
+ id: "bibcode-ui-review-exit-plan-" + randomUUID(),
+ name: "ExitPlanMode",
+ input: { plan: ${JSON.stringify(CODE_REVIEW_PLAN)} }
+ }]
+ }
+ });
+ send({
+ type: "result",
+ subtype: "success",
+ is_error: false,
+ errors: [],
+ stop_reason: "end_turn",
+ session_id: sessionId,
+ uuid: "bibcode-ui-claude-review-result"
+ });
+ return;
+ }
const slowReasoning = prompt.includes(${JSON.stringify(SLOW_REASONING_TURN_MARKER)});
if (slowReasoning || prompt.includes(${JSON.stringify(REASONING_TURN_MARKER)})) {
void streamReasoningTurn(sessionId, slowReasoning, prompt);
@@ -1464,6 +1560,7 @@ export const desktopUiFixture = {
reasoningTurnMarker: REASONING_TURN_MARKER,
slowReasoningTurnMarker: SLOW_REASONING_TURN_MARKER,
longReplyTurnMarker: LONG_REPLY_TURN_MARKER,
+ codeReviewFinding: CODE_REVIEW_FINDING,
longReplyLastLine: longReplyLine(LONG_REPLY_LINES),
reasoningThoughtStart: "Fixture thinking step 1 weighs the parser.",
/** The last line of the answer to a reasoning prompt whose text, markers aside, is `label`. */
diff --git a/apps/desktop/e2e/wdio.conf.ts b/apps/desktop/e2e/wdio.conf.ts
index 5285f45651..e3cd53dd1d 100644
--- a/apps/desktop/e2e/wdio.conf.ts
+++ b/apps/desktop/e2e/wdio.conf.ts
@@ -8,7 +8,13 @@ import { installDesktopUiMotionGuard } from "./support/motion-guard.ts";
import { refreshDesktopUiDocument } from "./support/document-navigation.ts";
import { resolveDesktopAppPath, type DesktopUiPlatform } from "./support/app-path.ts";
-import { isFinalDesktopUiSpec, requestDesktopUiApplicationExit } from "./support/app-lifecycle.ts";
+import {
+ ensureLiveDesktopUiWebview,
+ isDesktopUiApplicationRunning,
+ isFinalDesktopUiSpec,
+ requestDesktopUiApplicationExit,
+ stopRelaunchedDesktopUiApplication,
+} from "./support/app-lifecycle.ts";
import {
deferDesktopUiTestContextCleanupUntilExit,
prepareDesktopUiTestContext,
@@ -28,11 +34,18 @@ const platform: DesktopUiPlatform =
? "win"
: "linux";
const testContext = prepareDesktopUiTestContext();
+// End-to-end builds write an exported file here instead of opening the Save dialog. The app reads
+// it at launch, so it is set before the launcher starts the app.
+process.env.BIBCODE_E2E_SAVE_TEXT_PATH ??= NodePath.join(
+ testContext.artifactDirectory,
+ "saved-text-file.txt",
+);
const appBinaryPath = resolveDesktopAppPath({
platform,
environment: process.env,
});
const requestedSpec = process.env.BIBCODE_E2E_SPEC?.trim();
+const closingSpec = "./specs/desktop-save-and-close.e2e.ts";
const desktopUiSpecFiles =
requestedSpec && requestedSpec.length > 0
? [requestedSpec]
@@ -51,8 +64,17 @@ const desktopUiSpecFiles =
"./specs/pierre-diffs.e2e.ts",
"./specs/mermaid-diagrams.e2e.ts",
"./specs/files-binary-preview.e2e.ts",
+ "./specs/files-rich-markdown.e2e.ts",
+ "./specs/appearance-themes.e2e.ts",
+ "./specs/source-control-review.e2e.ts",
+ "./specs/host-dashboard.e2e.ts",
+ closingSpec,
];
-const desktopUiSpecs = requestedSpec ? desktopUiSpecFiles : [desktopUiSpecFiles];
+// The default specs share one app session. The closing spec ends the app, so it runs last in a
+// worker of its own, and the service starts the app again for it if a shared spec ended it.
+const desktopUiSpecs = requestedSpec
+ ? desktopUiSpecFiles
+ : [desktopUiSpecFiles.filter((spec) => spec !== closingSpec), closingSpec];
if (!NodeFS.existsSync(appBinaryPath)) {
throw new Error(`Packaged BiBCode application does not exist: ${appBinaryPath}`);
@@ -150,6 +172,8 @@ export const config = {
timeout: 120_000,
},
before: async () => {
+ // A webview that crashed in an earlier spec would fail this one with the same dead session.
+ await ensureLiveDesktopUiWebview();
const pluginReady = await browser.execute(() => {
const host = window as Window & {
readonly wdioTauri?: {
@@ -183,7 +207,8 @@ export const config = {
_context: unknown,
result: { readonly passed: boolean },
) => {
- if (!result.passed) {
+ // A closing case may have ended the app; there is no window left to record.
+ if (!result.passed && (await isDesktopUiApplicationRunning())) {
await browser.saveScreenshot(screenshotPath(`failure-${test.title}`));
NodeFS.writeFileSync(
screenshotPath(`failure-source-${test.title}`).replace(/\.png$/, ".html"),
@@ -196,6 +221,8 @@ export const config = {
// Ask Tauri to run its normal ExitRequested path so the in-process backend and provider
// children release Windows filesystem handles before the launcher service stops the driver.
await requestDesktopUiApplicationExit();
+ // The service stops only the app it started; an app a closing case relaunched is stopped here.
+ await stopRelaunchedDesktopUiApplication();
},
onComplete: () => {
// Keep the app's own logs (native page loads, webview process failures, server errors)
diff --git a/apps/desktop/src-tauri/permissions/desktop-bridge.toml b/apps/desktop/src-tauri/permissions/desktop-bridge.toml
index 829b41683f..d63d61aeae 100644
--- a/apps/desktop/src-tauri/permissions/desktop-bridge.toml
+++ b/apps/desktop/src-tauri/permissions/desktop-bridge.toml
@@ -45,6 +45,8 @@ commands.allow = [
"desktop_bridge_install_update",
"desktop_bridge_pick_folder",
"desktop_bridge_save_diagnostic_logs",
+ "desktop_bridge_save_text_file",
+ "desktop_bridge_reply_close_request",
"desktop_bridge_pick_files",
"desktop_bridge_download_to_folder",
"desktop_bridge_upload_file",
diff --git a/apps/desktop/src-tauri/src/bridge.rs b/apps/desktop/src-tauri/src/bridge.rs
index f2cda752f7..283ce6de4e 100644
--- a/apps/desktop/src-tauri/src/bridge.rs
+++ b/apps/desktop/src-tauri/src/bridge.rs
@@ -48,6 +48,7 @@ use crate::tailscale::{
read_tailscale_status,
};
use crate::updates::{DesktopUpdateInstallInput, DesktopUpdateManager};
+use crate::window::{CloseGuard, CloseReply};
#[cfg(test)]
pub(crate) type DesktopRuntime = tauri::test::MockRuntime;
@@ -1654,6 +1655,92 @@ pub async fn desktop_bridge_save_diagnostic_logs(
Ok(Some(path.to_string_lossy().into_owned()))
}
+/// The largest text `desktop_bridge_save_text_file` writes.
+const MAX_SAVED_TEXT_BYTES: usize = 8 * 1024 * 1024;
+
+/// Checks the web's suggested name and contents, and returns the name's extension for the
+/// dialog filter. The name cannot carry a directory, so only the dialog picks where it goes.
+fn validate_saved_text(suggested_name: &str, contents: &str) -> Result {
+ let plain = !suggested_name.is_empty()
+ && suggested_name.len() <= 255
+ && !suggested_name.contains(['/', '\\'])
+ && !suggested_name.chars().any(char::is_control);
+ let extension = Path::new(suggested_name)
+ .extension()
+ .and_then(|extension| extension.to_str())
+ .filter(|extension| !extension.is_empty());
+ let Some(extension) = extension.filter(|_| plain) else {
+ return Err("The file name must be a plain name with an extension.".to_owned());
+ };
+ if contents.len() > MAX_SAVED_TEXT_BYTES {
+ return Err("The file is larger than the desktop save limit.".to_owned());
+ }
+ Ok(extension.to_owned())
+}
+
+/// Writes `contents` where `choose` says. `None` from `choose` means the person cancelled.
+async fn save_text_to_chosen_path(
+ choose: impl std::future::Future