Compare commits

...
13 Commits
Author SHA1 Message Date
jknapp b21a568bf5 Merge pull request 'Install from the lockfile, so CI cannot be broken by someone else's release' (#53) from fix/ci-npm-lockfile into main
Build App / compute-version (push) Successful in 4s
Secret Scan / scan (push) Successful in 3s
Build App / build-macos (push) Successful in 2m42s
Build App / build-windows (push) Successful in 4m53s
Build App / build-linux (push) Successful in 5m0s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 13s
2026-09-03 16:41:15 +00:00
shadowdaoandClaude Opus 5 f41b1d9054 Install from the lockfile, so CI cannot be broken by someone else's release
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-windows (pull_request) Successful in 4m52s
Build App (Preview) / build-linux (pull_request) Successful in 5m0s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
`build-linux` fails before `tauri build` runs, on every workflow, at "Install
frontend dependencies":

    npm error Cannot read properties of null (reading 'edgesOut')

Reproduced exactly on the first attempt by running the step's own commands
locally on the same Node 22.23.2 the runner installs. The debug log gives the
frame the CI output omits:

    at #loadPeerSet (.../@npmcli/arborist/lib/arborist/build-ideal-tree.js:1289:38)

It is a null dereference in npm 10.9.8's peer-set resolver, reached through
vite → @vitejs/devtools → @vitejs/devtools-vitest → vitest@* →
@vitest/browser-playwright → vitest@4.1.11 → jsdom@* → canvas.

**Nothing in this repo changed to cause it.** The step deleted
`package-lock.json` before installing, so every build re-resolved the entire
tree against the registry against ranges like `vitest@*`. A dependency
published a version that produces a peer graph npm cannot resolve, and our CI
broke — the same command succeeded fifteen hours earlier for 0.4.21. That is
the real defect: the build was never reproducible, and the crash is only how we
found out.

So Linux installs with `npm ci`, from the committed lockfile, like Windows
already did. macOS moves too — it kept the lockfile but still ran `npm
install`, which is free to re-resolve; all three platforms now install
identically and none can re-resolve mid-release.

**The reason the lockfile was being deleted is obsolete, not ignored.** 2d4fce9
removed it "to ensure correct platform-specific bindings", which was a real
problem once. The committed lockfile now records 25 rollup platform variants,
and `npm ci` on Linux installs precisely rollup-linux-x64-{gnu,musl} and
@esbuild/linux-x64 — checked directly. A comment on the step says so, and says
not to reach for deleting the lockfile again: if `npm ci` refuses, package.json
and the lockfile have genuinely diverged and the fix is to commit an updated
lockfile.

Verified from the resulting tree: `tsc --noEmit` clean, `npm run build`
successful, 752 tests across 62 files passing. The `npx tauri --version ||
npm install @tauri-apps/cli` fallback in the next step cannot reintroduce a
fresh resolution — the CLI is a pinned devDependency that `npm ci` installs, so
the fallback is unreachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-03 09:28:09 -07:00
jknapp 670450ccfd Merge pull request 'Make the AppImage updatable, and drop the deb and rpm' (#51) from feat/appimage-update-metadata into main
Build App / compute-version (push) Successful in 2s
Secret Scan / scan (push) Successful in 3s
Build App / build-macos (push) Successful in 2m48s
Build App / build-windows (push) Successful in 4m53s
Build App / build-linux (push) Successful in 5m11s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 11s
2026-09-03 00:28:10 +00:00
jknapp a0b9f1e19b Merge pull request 'Let the host's libwayland-client win in the AppImage' (#50) from fix/appimage-wayland-client into main
Build App / compute-version (push) Successful in 3s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 2m42s
Build App / build-windows (push) Successful in 4m54s
Build App / build-linux (push) Successful in 5m32s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 12s
Reviewed-on: #50
2026-09-03 00:22:03 +00:00
shadowdaoandClaude Opus 5 9fadfbc37a Make the AppImage updatable, and drop the deb and rpm
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m43s
Build App (Preview) / build-windows (pull_request) Successful in 4m51s
Build App (Preview) / build-linux (pull_request) Successful in 5m19s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
An AppImage manager can adopt the current build but never update it: the image
carries no update information, which is the string that tells such a tool where
to look for a newer one. It also carries no AppStream metadata, so a manager
has nothing to show but a filename — appimagetool has been warning about that
on every build — and linuxdeploy leaves `Categories=` empty, which files the
app nowhere in a desktop menu.

All three are fixed while the image is already unpacked for the wayland fix, so
the cost is a few lines rather than a second pass. `unbundle-wayland-client.sh`
is now `finalize-appimage.sh`, since it does more than unbundle.

The update URL is a **fixed** `linux-latest` tag on the GitHub mirror, which is
where updates are pulled from — deliberately not `releases/latest`. `latest`
follows whichever release is newest, and the Gitea-to-GitHub backfill creates
one GitHub release per Gitea tag, including the `-win` and `-mac` tags that
carry no AppImage. A URL that can resolve to a release with no AppImage in it
fails on users' machines and nowhere else.

The output is named for that tag too, and that is not cosmetic: zsync records
a *relative* filename which a client resolves against the .zsync URL it
fetched, so a versioned name would send every client after the build it already
has. Verified by reading the generated header — `Filename: Triple-C_x86_64
.AppImage` — and the image's own `.upd_info` section, which is where the tag
actually lives. My first guard checked the .zsync for the tag and failed
correctly, which is how that distinction got found rather than shipped.

Range requests were confirmed against the mirror before building on them: 206
with a correct content-range, so updates are real deltas rather than an 85 MB
re-download.

The .deb and .rpm go. They are two more artifacts to build, publish and keep
working for an audience already served by the one file that runs on every
distribution, and neither could ever self-update — which is now the difference
that matters. Older releases keep theirs. The Linux job passes
`--bundles appimage` rather than changing `tauri.conf.json`, so macOS and
Windows are untouched.

Verified against the real 0.4.19 artifact: it repacks, the AppStream file and
filled-in Categories land inside the image, the update string resolves to the
fixed tag, and the wayland fallback still holds. Both publisher failure paths
refuse rather than half-publishing — no token, and missing artifacts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-02 17:10:44 -07:00
shadowdaoandClaude Opus 5 a3bdf6f4da Let the host's libwayland-client win in the AppImage
Secret Scan / scan (push) Successful in 3s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m52s
Build App (Preview) / build-linux (pull_request) Successful in 5m20s
Build App (Preview) / build-windows (pull_request) Successful in 5m21s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
The AppImage came up blank on CachyOS with `Could not create default EGL
display: EGL_BAD_PARAMETER. Aborting...`, and the DMA-BUF workaround already
in `main.rs` did not help — verified by finding the flag compiled into the
shipped 0.4.19 binary, where it runs unconditionally on Linux.

It is a different fault with the same error text. linuxdeploy bundles
`libwayland-client.so.0` as a GTK dependency and `AppRun.wrapped` puts the
bundled directory ahead of the host's, so the host's Mesa resolves against our
copy. `libEGL_mesa.so.0` — the driver libglvnd's `libEGL.so.1` dlopens — has a
hard DT_NEEDED on that library, so when its symbols will not resolve the
driver never loads, glvnd is left with none, and `eglGetDisplay` reports no
display. That is why `GDK_BACKEND=x11` does not dodge it, and why the symptom
is a bad-parameter error rather than a link failure.

Bisected on the reporter's machine against the released artifact — removing
`libwayland-client.so.0` from the AppDir cleared the abort, while removing
`libwayland-egl` or `libepoxy` did not. The bundled copy (Ubuntu 22.04,
wayland 1.20) is missing eleven symbols their wayland 1.26 exports, including
`wl_proxy_get_display`, `wl_proxy_get_queue`,
`wl_display_create_queue_with_name` and `wl_fixes_interface`.

Bundling a newer wayland would defer this, not fix it: the floor is set by the
host's Mesa, which updates independently of our releases, so any version we
pick is one release away from being too old again. This is a host-coupled
library like libGL and libdrm — the only correct version is the host's.

So the copy is demoted rather than deleted. It moves off the loader path into
`usr/lib/wayland-fallback`, and a hook adds that directory back only when the
host has no libwayland-client of its own — so a host without one still starts.
The ordering is safe because `AppRun.wrapped` appends the inherited
LD_LIBRARY_PATH after its own entries, making the hook a fallback and never an
override. AppRun sources hooks by name rather than globbing, so it is patched
to source this one.

X11-only hosts are unaffected: libwayland-client is a package dependency of
Mesa and GTK, so it is present even on a machine with no display server at all
— confirmed on a headless container with neither DISPLAY nor WAYLAND_DISPLAY
set. And the AppImage already runs as an X11 client everywhere, since
linuxdeploy's own hook forces GDK_BACKEND=x11.

Verified against the real 0.4.19 artifact rather than a synthetic AppDir: it
repacks, the binary and AppRun survive, and both hook branches were exercised
— host-has-it leaves LD_LIBRARY_PATH untouched, and a debian:12-slim container
with no wayland at all engages the fallback.

The comment in `main.rs` quoted this exact error as one the DMA-BUF flag
fixes. That claim sent this investigation down the wrong path first, so it is
corrected rather than left to do it again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-02 16:57:51 -07:00
jknapp dc9cdd1760 Merge pull request 'Add a per-project Notes tab with a send-to-agent action' (#48) from feat/project-notes into main
Build App / compute-version (push) Successful in 4s
Secret Scan / scan (push) Successful in 5s
Build App / build-macos (push) Successful in 2m44s
Build App / build-windows (push) Successful in 4m58s
Build App / build-linux (push) Successful in 5m13s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s
2026-09-02 20:42:07 +00:00
shadowdaoandClaude Opus 5 c16f0d5b70 Put the cursor in the terminal after sending a note
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-windows (pull_request) Successful in 5m3s
Build App (Preview) / build-linux (pull_request) Successful in 5m21s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Sending already switched to the target terminal's tab, which looks like it
should be enough: `TerminalView` focuses xterm whenever a terminal becomes
active. But that effect keys off `active`, so it only fires on a *change* —
and the dock's ordinary case is sending to the terminal already on screen.
`setActiveTabKey` writes the key that is already set, nothing changes, no
effect re-runs, and focus stays on the Send button. The note is sitting in the
prompt and the user still has to click the terminal before pressing Enter.

So the send now asks for focus explicitly, through a one-shot request in the
store that `TerminalView` consumes and clears — the shape `pendingHomeTab`
already uses. Clearing is not tidiness: hold the id and the second send to the
same terminal writes a value that is already there, which is precisely the
no-op this exists to fix.

Focus is requested only on success. A failed send toasts and leaves the user
where they are, because there is nothing in the prompt to press Enter on.

The three `TerminalView` tests give focus away after mounting before making
any assertion, so what they observe is the request landing and never the focus
that `active` already grants on mount — which would pass with the feature
absent.

752 tests pass, 62 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-02 13:28:31 -07:00
shadowdaoandClaude Opus 5 3239057f8f Give the dock its own compact notes layout
Secret Scan / scan (push) Successful in 5s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-windows (pull_request) Successful in 4m55s
Build App (Preview) / build-linux (pull_request) Successful in 5m29s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
The dock was showing `NotesPanel`, which is a master/detail layout: a column
of titles beside an editor. The previous commit made that survive dock width;
it did not make it right. At 352px the layout still spends roughly 356px of
height on chrome — dock header, panel header, title strip, a button row that
wraps, and a paragraph of help — before the body gets a pixel.

So the dock now shows one note. The title field names what is open and the
chevron beside it switches; New and Delete move into the overflow menu; the
help text goes. Chrome drops to about 112px and the body takes the rest.

The two surfaces are now different components, which contradicts a docstring
I wrote — "shared so the two cannot drift into different behaviour". That
claim was about behaviour, and behaviour was never in the layout: it is in
`useNotes` for the cache and its write ordering, and now in `useNoteDraft`,
extracted here so when a keystroke becomes a save is defined in exactly one
place. Only the layout diverges. `NotesPanel.shared.test.tsx` gets stronger
for it — it now mounts the dock panel and the tab panel together, which is
what the app actually does, instead of the same component twice.

`NoteSwitcher` is not `OverflowMenu` despite the shape being close: that keys
items by label, and notes are addressed by id, so two untitled notes — the
ordinary case — would collapse into one row. It is also not a `combobox`; an
input plus a listbox button is two honest controls, where the role would owe
active-descendant tracking and filtering that nothing here needs.

`SendToAgentButton` picks up `useUnavailable` from #49, which is what its
`disabled` plus explanatory `title` was already asking for. Four tests moved
from `toBeDisabled()` to the new contract, and one of them — "does nothing for
an empty note" — turned out never to have asserted that it does nothing. It
does now, for click and for Enter, which is the guard the swap needs.

It also gains `dropUp`, and that is load-bearing rather than cosmetic: the
dock clips its own overflow, so a session menu opening downward from a button
on the bottom edge is drawn outside the panel and never seen.

744 tests pass, 62 files. As before, jsdom has no layout engine: that the dock
now reads as compact is not something the suite can tell you.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-02 12:10:00 -07:00
shadowdao 23364f412e Merge remote-tracking branch 'origin/main' into feat/project-notes 2026-09-02 12:04:17 -07:00
jknapp b24807bd5f Merge pull request 'Keep disabled controls in the accessibility tree so their reason is announced' (#49) from fix/disabled-control-accessibility into main
Build App / compute-version (push) Successful in 5s
Secret Scan / scan (push) Successful in 3s
Build App / build-macos (push) Successful in 2m44s
Build App / build-windows (push) Successful in 4m55s
Build App / build-linux (push) Successful in 5m20s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s
Reviewed-on: #49
2026-09-02 18:53:20 +00:00
shadowdaoandClaude Opus 5 1eb91a35eb Give the terminal and Add Project buttons a reason a screen reader can hear
Secret Scan / scan (push) Successful in 10s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 10s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m56s
Build App (Preview) / build-linux (pull_request) Successful in 6m47s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Both were the defect the new hook exists for. The sidebar's Claude terminal
button is disabled whenever the container is not running and never said so —
its `title` names the action, so the precondition appeared nowhere in the
accessibility tree at all. Add Project's submit button is disabled while an
add is in flight, and its only signal is the label swapping to "Adding…" on
an element a screen reader can no longer reach.

The submit button needs a second guard the hook cannot supply: Enter inside
a text field submits a form without touching the submit button, so
`handleSubmit` now returns early while loading. Without it, swapping
`disabled` for `aria-disabled` would have turned an accessibility fix into a
double-submit bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-02 09:08:57 -07:00
shadowdaoandClaude Opus 5 aa0a574091 Announce unavailable controls instead of hiding them behind disabled
Native `disabled` removes an element from the tab order and from the
accessibility tree, so any explanation of why a control cannot be used is
delivered only to a sighted user with a mouse. `useUnavailable` is the way
out: `aria-disabled` keeps the control focusable and announced,
`aria-describedby` carries the reason, and — because `aria-disabled` is
advisory and blocks nothing — the hook hands back the click and Enter/Space
guards along with the attributes, so a call site cannot take the
announcement without the guard.

`Button` gets it as an opt-in `unavailable` / `unavailableReason` pair.
Opt-in matters: 37 files render this button and none of them change. The
`aria-disabled:` class mirrors exist because Tailwind's `disabled:` variant
only matches the native attribute, which this pattern deliberately omits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-02 09:08:50 -07:00
30 changed files with 1763 additions and 126 deletions
+44 -7
View File
@@ -299,8 +299,34 @@ jobs:
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./app working-directory: ./app
run: | run: |
rm -rf node_modules package-lock.json # `npm ci` — from the lockfile, never resolving afresh.
npm install #
# This used to be `rm -rf node_modules package-lock.json && npm
# install`, which deleted the lockfile "to ensure correct
# platform-specific bindings" (2d4fce9). That made every build
# re-resolve the whole tree against the registry, so a dependency
# publishing a new version could break CI with no change to this
# repo — and one did. Deleting the lockfile then hit a null
# dereference in npm 10.9.8's arborist peer-set resolver:
#
# npm error Cannot read properties of null (reading 'edgesOut')
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
#
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
# Reproduced exactly by removing the lockfile locally on the same
# Node 22.23.2 the runner installs.
#
# The binding worry is obsolete: the committed lockfile records 25
# rollup platform variants, and `npm ci` on Linux installs precisely
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
# with a clean tsc, a successful build and 752 passing tests from the
# resulting tree.
#
# Do not "fix" a future dependency error by deleting the lockfile
# again. If `npm ci` refuses, package.json and the lockfile have
# genuinely diverged, and the fix is to commit an updated lockfile.
npm ci
- name: Install Tauri CLI - name: Install Tauri CLI
working-directory: ./app working-directory: ./app
@@ -319,14 +345,23 @@ jobs:
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }} TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
run: | run: |
export PATH="$HOME/.cargo/bin:$PATH" export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build # AppImage only: the .deb and .rpm were dropped in favour of the one
# artifact that runs everywhere, and building them is pure cost.
# Left as "all" in tauri.conf.json so macOS and Windows are unaffected.
npx tauri build --bundles appimage
# linuxdeploy bundles a libwayland-client.so.0 that shadows the host's
# and breaks Mesa's EGL on systems newer than the build runner, so the
# window comes up blank. It has to come from the host; see the script
# header for the evidence and the trade.
- name: Finalize the AppImage
run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage
- name: Collect artifacts - name: Collect artifacts
run: | run: |
mkdir -p artifacts mkdir -p artifacts
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true cp app/src-tauri/target/release/bundle/appimage/*.zsync artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
ls -la artifacts/ ls -la artifacts/
# Assets, not workflow artifacts — see the note at the top of this file. # Assets, not workflow artifacts — see the note at the top of this file.
@@ -418,8 +453,10 @@ jobs:
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./app working-directory: ./app
run: | run: |
rm -rf node_modules # `npm ci` here too, so all three platforms install identically and
npm install # none of them can re-resolve the tree mid-release. Windows already
# did. See the Linux job for what a fresh resolution cost us.
npm ci
- name: Install Tauri CLI - name: Install Tauri CLI
working-directory: ./app working-directory: ./app
+53 -7
View File
@@ -172,8 +172,34 @@ jobs:
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./app working-directory: ./app
run: | run: |
rm -rf node_modules package-lock.json # `npm ci` — from the lockfile, never resolving afresh.
npm install #
# This used to be `rm -rf node_modules package-lock.json && npm
# install`, which deleted the lockfile "to ensure correct
# platform-specific bindings" (2d4fce9). That made every build
# re-resolve the whole tree against the registry, so a dependency
# publishing a new version could break CI with no change to this
# repo — and one did. Deleting the lockfile then hit a null
# dereference in npm 10.9.8's arborist peer-set resolver:
#
# npm error Cannot read properties of null (reading 'edgesOut')
# at #loadPeerSet (.../build-ideal-tree.js:1289:38)
#
# reached through vite → @vitejs/devtools → @vitejs/devtools-vitest
# → vitest@* → @vitest/browser-playwright → jsdom@* → canvas.
# Reproduced exactly by removing the lockfile locally on the same
# Node 22.23.2 the runner installs.
#
# The binding worry is obsolete: the committed lockfile records 25
# rollup platform variants, and `npm ci` on Linux installs precisely
# rollup-linux-x64-{gnu,musl} and @esbuild/linux-x64. Verified, along
# with a clean tsc, a successful build and 752 passing tests from the
# resulting tree.
#
# Do not "fix" a future dependency error by deleting the lockfile
# again. If `npm ci` refuses, package.json and the lockfile have
# genuinely diverged, and the fix is to commit an updated lockfile.
npm ci
- name: Install Tauri CLI - name: Install Tauri CLI
working-directory: ./app working-directory: ./app
@@ -185,14 +211,23 @@ jobs:
working-directory: ./app working-directory: ./app
run: | run: |
export PATH="$HOME/.cargo/bin:$PATH" export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build # AppImage only: the .deb and .rpm were dropped in favour of the one
# artifact that runs everywhere, and building them is pure cost.
# Left as "all" in tauri.conf.json so macOS and Windows are unaffected.
npx tauri build --bundles appimage
# linuxdeploy bundles a libwayland-client.so.0 that shadows the host's
# and breaks Mesa's EGL on systems newer than the build runner, so the
# window comes up blank. It has to come from the host; see the script
# header for the evidence and the trade.
- name: Finalize the AppImage
run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage
- name: Collect artifacts - name: Collect artifacts
run: | run: |
mkdir -p artifacts mkdir -p artifacts
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true cp app/src-tauri/target/release/bundle/appimage/*.zsync artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
ls -la artifacts/ ls -la artifacts/
- name: Upload to Gitea release - name: Upload to Gitea release
@@ -270,6 +305,15 @@ jobs:
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
done done
# The fixed tag every installed AppImage checks for updates. Separate
# from the versioned release above because the updater's URL must never
# move, and `releases/latest` does.
- name: Publish the Linux update channel
if: gitea.event_name == 'push'
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: bash scripts/publish-update-channel.sh artifacts
build-macos: build-macos:
runs-on: macos-latest runs-on: macos-latest
needs: [compute-version] needs: [compute-version]
@@ -325,8 +369,10 @@ jobs:
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./app working-directory: ./app
run: | run: |
rm -rf node_modules # `npm ci` here too, so all three platforms install identically and
npm install # none of them can re-resolve the tree mid-release. Windows already
# did. See the Linux job for what a fresh resolution cost us.
npm ci
- name: Install Tauri CLI - name: Install Tauri CLI
working-directory: ./app working-directory: ./app
+20 -2
View File
@@ -679,8 +679,26 @@ deliberately out of scope — this is not a project backup.
## Packaging ## Packaging
Linux ships as `.deb`, `.rpm` and AppImage, all three built by `build-app.yml` (releases) and Linux ships as **AppImage only**, built by `build-app.yml` (releases) and
`build-app-preview.yml` (the PR check). **There is deliberately no Arch package.** A `build-app-preview.yml` (the PR check). The `.deb` and `.rpm` were dropped: two more artifacts to
build and publish for an audience the AppImage already serves, and neither could self-update. The
Linux job passes `--bundles appimage`; `tauri.conf.json` still says `"targets": "all"` so macOS and
Windows are untouched.
`scripts/finalize-appimage.sh` post-processes every AppImage, and both things it does are
load-bearing. **It demotes the bundled `libwayland-client.so.0`** off the loader path, keeping it as
a fallback for a host that has none: `libEGL_mesa.so.0` has a hard `DT_NEEDED` on that library, so a
bundled copy older than the host's Mesa stops the EGL driver loading at all and the window comes up
blank — measured on wayland 1.26 / Mesa 26.2.1 against a 22.04-built image. Do not "fix" this by
bundling a newer wayland: the floor is set by the user's Mesa, which moves independently of our
releases, so this is a host-coupled library like libGL and libdrm. **It also embeds AppStream
metadata and update information**, without which an AppImage manager can adopt the app but never
update it. The update URL points at a fixed `linux-latest` tag on the GitHub mirror
(`scripts/publish-update-channel.sh`), never `releases/latest` — that follows whichever release is
newest, and the backfill creates a GitHub release per Gitea tag including the `-win` and `-mac` ones
that carry no AppImage. The script's post-repack assertions are the only test any of this has.
**There is deliberately no Arch package.** A
`triple-c-bin` `PKGBUILD` and a `publish-arch-package.yml` existed and were removed; they live on `triple-c-bin` `PKGBUILD` and a `publish-arch-package.yml` existed and were removed; they live on
`hold/arch-packaging`. Do not re-add them without the piece that was always missing: the package `hold/arch-packaging`. Do not re-add them without the piece that was always missing: the package
was never on the AUR, so it was a manual `pacman -U` of a downloaded file — the same gesture as was never on the AUR, so it was a manual `pacman -U` of a downloaded file — the same gesture as
+5 -3
View File
@@ -41,14 +41,16 @@ Download the build for your platform from [GitHub Releases](https://github.com/s
|----------|------|---------| |----------|------|---------|
| **Windows** | `Triple-C_<version>_x64-setup.exe` or `.msi` | Run the installer. | | **Windows** | `Triple-C_<version>_x64-setup.exe` or `.msi` | Run the installer. |
| **macOS** | `Triple-C_<version>_universal.dmg` | Open the `.dmg` and drag Triple-C to Applications. | | **macOS** | `Triple-C_<version>_universal.dmg` | Open the `.dmg` and drag Triple-C to Applications. |
| **Debian / Ubuntu** | `Triple-C_<version>_amd64.deb` | `sudo apt install ./Triple-C_<version>_amd64.deb` | | **Linux (all distributions)** | `Triple-C_<version>_amd64.AppImage` | `chmod +x` it, then run it directly. See the AppImage notes below. |
| **Fedora / RHEL** | `Triple-C-<version>-1.x86_64.rpm` | `sudo dnf install ./Triple-C-<version>-1.x86_64.rpm` |
| **Arch / CachyOS / other Linux** | `Triple-C_<version>_amd64.AppImage` | `chmod +x` it, then run it directly. See the AppImage notes below. |
> **macOS note:** The app is not signed or notarized. On first launch, macOS Gatekeeper may block it — right-click the app and select "Open" to bypass, or remove the quarantine attribute: `xattr -cr /Applications/Triple-C.app`. > **macOS note:** The app is not signed or notarized. On first launch, macOS Gatekeeper may block it — right-click the app and select "Open" to bypass, or remove the quarantine attribute: `xattr -cr /Applications/Triple-C.app`.
> **AppImage note:** Two things are worth knowing. Running an AppImage needs FUSE 2, which Arch and CachyOS do not install by default — `sudo pacman -S fuse2` once, or run it with `--appimage-extract-and-run` to sidestep FUSE entirely. And an AppImage is just an executable file: nothing registers it with the desktop, so it will not appear in your app launcher on its own. Run [`scripts/install-appimage.sh`](scripts/install-appimage.sh) to add a launcher entry and icons — see [Adding an AppImage to the app launcher](#adding-an-appimage-to-the-app-launcher). > **AppImage note:** Two things are worth knowing. Running an AppImage needs FUSE 2, which Arch and CachyOS do not install by default — `sudo pacman -S fuse2` once, or run it with `--appimage-extract-and-run` to sidestep FUSE entirely. And an AppImage is just an executable file: nothing registers it with the desktop, so it will not appear in your app launcher on its own. Run [`scripts/install-appimage.sh`](scripts/install-appimage.sh) to add a launcher entry and icons — see [Adding an AppImage to the app launcher](#adding-an-appimage-to-the-app-launcher).
> **Linux is AppImage only.** The `.deb` and `.rpm` were dropped. They were a second and third artifact to build, test and publish for an audience already served by the one file that runs on every distribution — and unlike the AppImage they could not be kept up to date automatically. Older releases still carry them if you need one.
> **Updates.** The AppImage carries update information, so an AppImage manager (Gear Lever, AppImageLauncher and similar) can adopt it and update it in place — pulling only the changed blocks rather than re-downloading 85 MB. It reads a fixed `linux-latest` tag on GitHub, so the URL never moves between versions.
> **No Arch package.** There was a `triple-c-bin` `.pkg.tar.zst` attached to some releases, built by a maintainer-triggered workflow. It was never on the AUR, so installing it meant downloading a file and running `pacman -U` — no better than the AppImage — and being manual-only it reached 1 release in 28, which made the promise of it worse than not making it. The `PKGBUILD` and its workflow are preserved on the `hold/arch-packaging` branch if an AUR package is ever worth doing properly. > **No Arch package.** There was a `triple-c-bin` `.pkg.tar.zst` attached to some releases, built by a maintainer-triggered workflow. It was never on the AUR, so installing it meant downloading a file and running `pacman -U` — no better than the AppImage — and being manual-only it reached 1 release in 28, which made the promise of it worse than not making it. The `PKGBUILD` and its workflow are preserved on the `hold/arch-packaging` branch if an AUR package is ever worth doing properly.
### Adding an AppImage to the app launcher ### Adding an AppImage to the app launcher
+13 -4
View File
@@ -3,10 +3,19 @@
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path /// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
/// since 2.42) fails outright on some Mesa/driver/compositor combinations /// since 2.42) fails outright on some Mesa/driver/compositor combinations
/// under Wayland, printing `Could not create default EGL display: /// under Wayland, killing the webview and leaving a blank window — see
/// EGL_BAD_PARAMETER. Aborting.` straight to stderr from WebKitGTK's own C /// triple-c#34, reported on CachyOS/Arch with Wayland.
/// code and killing the webview before Triple-C's own logging even starts — ///
/// see triple-c#34, reported on CachyOS/Arch with Wayland. /// **This is not the only cause of a blank window, and the error text alone
/// does not tell them apart.** An earlier version of this comment quoted
/// `Could not create default EGL display: EGL_BAD_PARAMETER. Aborting.` as
/// the error this fixes. The AppImage produces that same string for an
/// entirely unrelated reason: it bundled a `libwayland-client.so.0` that
/// shadowed the host's, and the host's `libEGL_mesa.so.0` has a hard
/// DT_NEEDED on that library, so the EGL driver failed to load before any
/// renderer choice was reachable. This flag was set, and correctly, and made no difference —
/// which cost a round of debugging that started from the comment rather than
/// from the evidence. See `scripts/unbundle-wayland-client.sh`.
/// ///
/// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that /// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that
/// variable is exported into an XWayland client's environment too, so a /// variable is exported into an XWayland client's environment too, so a
+1 -1
View File
@@ -3,7 +3,7 @@ import { render, screen, fireEvent } from "@testing-library/react";
import NotesDock from "./NotesDock"; import NotesDock from "./NotesDock";
import type { Project, TerminalSession } from "../../lib/types"; import type { Project, TerminalSession } from "../../lib/types";
vi.mock("../notes/NotesPanel", () => ({ vi.mock("../notes/NotesDockPanel", () => ({
default: ({ projectId }: { projectId: string }) => ( default: ({ projectId }: { projectId: string }) => (
<div data-testid="panel">{`panel:${projectId}`}</div> <div data-testid="panel">{`panel:${projectId}`}</div>
), ),
+2 -2
View File
@@ -7,7 +7,7 @@ import {
NOTES_DOCK_MIN_WIDTH, NOTES_DOCK_MIN_WIDTH,
NOTES_DOCK_MAX_WIDTH, NOTES_DOCK_MAX_WIDTH,
} from "../../store/appState"; } from "../../store/appState";
import NotesPanel from "../notes/NotesPanel"; import NotesDockPanel from "../notes/NotesDockPanel";
import Button from "../ui/Button"; import Button from "../ui/Button";
/** /**
@@ -116,7 +116,7 @@ export default function NotesDock() {
</div> </div>
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
{projectId ? ( {projectId ? (
<NotesPanel projectId={projectId} /> <NotesDockPanel projectId={projectId} />
) : ( ) : (
<p className="p-4 text-[13px] text-[var(--text-secondary)]"> <p className="p-4 text-[13px] text-[var(--text-secondary)]">
Open a project or a terminal to see its notes. Open a project or a terminal to see its notes.
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import NoteSwitcher from "./NoteSwitcher";
import type { Note } from "../../lib/types";
const onTitleChange = vi.fn();
const onCommit = vi.fn();
const onSelect = vi.fn();
const note = (over: Partial<Note> = {}): Note => ({
id: "n1",
title: "Deploy steps",
body: "",
pinned: false,
created_at: "2026-09-01T00:00:00Z",
updated_at: "2026-09-01T00:00:00Z",
...over,
});
const setup = (notes: Note[], selectedId = notes[0]?.id ?? "", title = notes[0]?.title ?? "") =>
render(
<NoteSwitcher
notes={notes}
selectedId={selectedId}
title={title}
onTitleChange={onTitleChange}
onCommit={onCommit}
onSelect={onSelect}
/>,
);
beforeEach(() => vi.clearAllMocks());
describe("NoteSwitcher", () => {
it("edits the title in place, committing on blur", () => {
setup([note()]);
const field = screen.getByLabelText("Note title");
expect(field).toHaveValue("Deploy steps");
fireEvent.change(field, { target: { value: "Deploy steps v2" } });
expect(onTitleChange).toHaveBeenCalledWith("Deploy steps v2");
expect(onCommit).not.toHaveBeenCalled();
fireEvent.blur(field);
expect(onCommit).toHaveBeenCalledTimes(1);
});
it("keeps the other notes out of the way until asked for", () => {
setup([note(), note({ id: "n2", title: "Gotchas" })]);
expect(screen.queryByText("Gotchas")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
expect(screen.getByRole("option", { name: "Gotchas" })).toBeInTheDocument();
});
it("reports whether the list is open", () => {
setup([note()]);
const trigger = screen.getByRole("button", { name: /switch note/i });
expect(trigger).toHaveAttribute("aria-expanded", "false");
fireEvent.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "true");
});
it("marks the current note as the selected option", () => {
setup([note(), note({ id: "n2", title: "Gotchas" })], "n2", "Gotchas");
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
expect(screen.getByRole("option", { name: "Gotchas" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByRole("option", { name: "Deploy steps" })).toHaveAttribute(
"aria-selected",
"false",
);
});
it("selects a note and closes", () => {
setup([note(), note({ id: "n2", title: "Gotchas" })]);
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
fireEvent.click(screen.getByRole("option", { name: "Gotchas" }));
expect(onSelect).toHaveBeenCalledWith("n2");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
it("names an untitled note rather than showing an empty row", () => {
setup([note({ title: " " })]);
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
expect(screen.getByRole("option", { name: "Untitled note" })).toBeInTheDocument();
});
// Notes are addressed by id, never by title. Two untitled notes are the
// ordinary case, and a title-keyed list would collapse them into one row.
it("lists two notes that share a title as two options", () => {
setup([note({ id: "n1", title: "" }), note({ id: "n2", title: "" })]);
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
const options = screen.getAllByRole("option", { name: "Untitled note" });
expect(options).toHaveLength(2);
fireEvent.click(options[1]);
expect(onSelect).toHaveBeenCalledWith("n2");
});
it("closes on Escape without selecting anything", () => {
setup([note(), note({ id: "n2", title: "Gotchas" })]);
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
expect(onSelect).not.toHaveBeenCalled();
});
});
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from "react";
import type { Note } from "../../lib/types";
export const UNTITLED = "Untitled note";
interface Props {
notes: Note[];
selectedId: string;
title: string;
onTitleChange: (value: string) => void;
onCommit: () => void;
onSelect: (id: string) => void;
}
/**
* One row that both names the current note and switches to another.
*
* The dock has no room for a permanent list of titles, so the title field
* doubles as the label of what is open and the chevron beside it holds the
* rest. Renaming therefore needs no separate affordance.
*
* Two honest controls rather than one `role="combobox"`: a text field and a
* button that opens a listbox. A real combobox owes its listbox keyboard
* navigation, active-descendant tracking and an input that filters none of
* which this needs, and half of which is worse than not claiming the role.
*
* `OverflowMenu` is deliberately not reused here despite the shape being
* close. It keys its items by label, and notes are addressed by id: two
* untitled notes are the ordinary case and would collapse into one row.
*/
export default function NoteSwitcher({
notes,
selectedId,
title,
onTitleChange,
onCommit,
onSelect,
}: Props) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
// Same dismissal contract as `OverflowMenu`, so the two feel identical.
useEffect(() => {
if (!open) return;
const onDocClick = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDocClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div ref={rootRef} className="relative flex items-center gap-1 min-w-0">
<input
value={title}
onChange={(e) => onTitleChange(e.target.value)}
onBlur={onCommit}
placeholder="Note title"
aria-label="Note title"
className="flex-1 min-w-0 px-2 h-7 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] transition-colors"
/>
<button
type="button"
aria-label="Switch note"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
className="inline-flex items-center justify-center h-7 w-6 flex-shrink-0 rounded-[var(--radius-control)] border border-[var(--border-color)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--border-color)] transition-colors"
>
<span aria-hidden="true" className="leading-none text-[10px]"></span>
</button>
{open && (
<div
role="listbox"
aria-label="Notes"
className="absolute right-0 top-full mt-1 z-40 w-full max-h-64 overflow-y-auto py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)]"
style={{ boxShadow: "var(--shadow-overlay)" }}
>
{/* Buttons directly inside the listbox: wrapping each in an `<li>`
would put an implicit `listitem` between the listbox and its
options, which is not a child role a listbox owns. */}
{notes.map((n) => (
<button
key={n.id}
type="button"
role="option"
aria-selected={n.id === selectedId}
onClick={() => {
onSelect(n.id);
setOpen(false);
}}
className={`block w-full text-left px-3 py-1.5 text-xs truncate transition-colors hover:bg-[var(--bg-tertiary)] ${
n.id === selectedId
? "text-[var(--text-primary)] bg-[var(--bg-tertiary)]"
: "text-[var(--text-secondary)]"
}`}
>
{n.title.trim() || UNTITLED}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import NotesDockPanel from "./NotesDockPanel";
import type { Note } from "../../lib/types";
const saveNote = vi.fn(async () => true);
const deleteNote = vi.fn(async () => true);
const createNote = vi.fn();
let notes: Note[] = [];
let loading = false;
vi.mock("../../hooks/useNotes", () => ({
useNotes: () => ({
notes,
loading,
saveState: { status: "idle", error: null },
createNote,
saveNote,
deleteNote,
}),
}));
const sendProps: Record<string, unknown>[] = [];
vi.mock("./SendToAgentButton", () => ({
default: (props: Record<string, unknown>) => {
sendProps.push(props);
return <button type="button">Send to agent</button>;
},
}));
const note = (over: Partial<Note> = {}): Note => ({
id: "n1",
title: "Deploy steps",
body: "one\ntwo",
pinned: false,
created_at: "2026-09-01T00:00:00Z",
updated_at: "2026-09-01T00:00:00Z",
...over,
});
beforeEach(() => {
vi.clearAllMocks();
sendProps.length = 0;
notes = [];
loading = false;
});
describe("NotesDockPanel", () => {
it("says it is loading rather than flashing an empty state", () => {
loading = true;
render(<NotesDockPanel projectId="p1" />);
expect(screen.getByText(/loading notes/i)).toBeInTheDocument();
});
it("offers a first note when the project has none", async () => {
render(<NotesDockPanel projectId="p1" />);
fireEvent.click(screen.getByRole("button", { name: /new note/i }));
await waitFor(() => expect(createNote).toHaveBeenCalled());
});
// The point of the redesign: the dock spends its height on the note being
// written, not on a permanent list of the ones that are not.
it("shows one note at a time, the rest behind the switcher", () => {
notes = [note(), note({ id: "n2", title: "Gotchas" })];
render(<NotesDockPanel projectId="p1" />);
expect(screen.getByLabelText("Note title")).toHaveValue("Deploy steps");
expect(screen.queryByText("Gotchas")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
expect(screen.getByRole("option", { name: "Gotchas" })).toBeInTheDocument();
});
it("switches to the note picked from the list", () => {
notes = [note(), note({ id: "n2", title: "Gotchas", body: "careful" })];
render(<NotesDockPanel projectId="p1" />);
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
fireEvent.click(screen.getByRole("option", { name: "Gotchas" }));
expect(screen.getByLabelText("Note title")).toHaveValue("Gotchas");
expect(screen.getByLabelText("Note body")).toHaveValue("careful");
});
it("saves the body when it loses focus, and not before", () => {
notes = [note()];
render(<NotesDockPanel projectId="p1" />);
const body = screen.getByLabelText("Note body");
fireEvent.change(body, { target: { value: "one\ntwo\nthree" } });
expect(saveNote).not.toHaveBeenCalled();
fireEvent.blur(body);
expect(saveNote).toHaveBeenCalledWith(
expect.objectContaining({ id: "n1", body: "one\ntwo\nthree" }),
);
});
it("keeps New and Delete in the overflow menu, out of the writing area", async () => {
notes = [note()];
render(<NotesDockPanel projectId="p1" />);
fireEvent.click(screen.getByRole("button", { name: /note actions/i }));
fireEvent.click(screen.getByRole("menuitem", { name: /delete note/i }));
await waitFor(() => expect(deleteNote).toHaveBeenCalledWith("n1"));
});
it("opens the note it just created", async () => {
notes = [note()];
createNote.mockResolvedValueOnce(note({ id: "n9", title: "" }));
const view = render(<NotesDockPanel projectId="p1" />);
fireEvent.click(screen.getByRole("button", { name: /note actions/i }));
fireEvent.click(screen.getByRole("menuitem", { name: /new note/i }));
await waitFor(() => expect(createNote).toHaveBeenCalled());
notes = [note(), note({ id: "n9", title: "" })];
view.rerender(<NotesDockPanel projectId="p1" />);
await waitFor(() =>
expect(screen.getByLabelText("Note title")).toHaveValue(""),
);
});
// The send bar sits on the dock's bottom edge, inside an `overflow-hidden`
// panel, so both of these are load-bearing rather than cosmetic.
it("sends from a full-width bar whose menu opens upward", () => {
notes = [note()];
render(<NotesDockPanel projectId="p1" />);
expect(screen.getByRole("button", { name: /send to agent/i })).toBeInTheDocument();
expect(sendProps.at(-1)).toMatchObject({ fullWidth: true, dropUp: true });
});
it("sends what is on screen, not what was last saved", () => {
notes = [note()];
render(<NotesDockPanel projectId="p1" />);
fireEvent.change(screen.getByLabelText("Note body"), {
target: { value: "edited but not blurred" },
});
expect(sendProps.at(-1)).toMatchObject({ body: "edited but not blurred" });
});
});
+119
View File
@@ -0,0 +1,119 @@
import { useMemo, useState } from "react";
import { useNotes } from "../../hooks/useNotes";
import { useNoteDraft } from "./useNoteDraft";
import NoteSwitcher from "./NoteSwitcher";
import SendToAgentButton from "./SendToAgentButton";
import Button from "../ui/Button";
import OverflowMenu from "../ui/OverflowMenu";
import SaveIndicator from "../ui/SaveIndicator";
interface Props {
projectId: string;
}
/**
* Notes at dock width.
*
* Deliberately not `NotesPanel` in a narrower box. The tab can afford a column
* of titles beside the editor; the dock cannot, and shrinking that layout
* spends its height on chrome a title strip, a wrapped button row and a
* paragraph of help for a body that ends up a few words wide.
*
* So the dock shows exactly one note. The title row names it and switches to
* another, the actions that are not writing live in the overflow menu, and
* everything left over is the body. Roughly 240px of height comes back.
*
* What the two surfaces share is the part that must not drift: `useNotes` for
* the cache and its write ordering, and `useNoteDraft` for when a keystroke
* becomes a save. Only the layout is different.
*/
export default function NotesDockPanel({ projectId }: Props) {
const { notes, loading, saveState, createNote, saveNote, deleteNote } =
useNotes(projectId);
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = useMemo(
() => notes.find((n) => n.id === selectedId) ?? notes[0] ?? null,
[notes, selectedId],
);
const { title, body, setTitle, setBody, commit } = useNoteDraft(
selected,
saveNote,
);
const onCreate = async () => {
const note = await createNote();
if (note) setSelectedId(note.id);
};
if (loading) {
return (
<p className="p-4 text-xs text-[var(--text-secondary)]">Loading notes</p>
);
}
if (!selected) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-4">
<p className="text-[13px] text-[var(--text-secondary)] text-center">
Keep reminders here, and send any of them straight to a running Claude
session.
</p>
<Button variant="primary" onClick={onCreate}>
New note
</Button>
</div>
);
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-1 px-2 py-1.5 flex-shrink-0 border-b border-[var(--border-color)]">
<div className="flex-1 min-w-0">
<NoteSwitcher
notes={notes}
selectedId={selected.id}
title={title}
onTitleChange={setTitle}
onCommit={commit}
onSelect={setSelectedId}
/>
</div>
{/* Renders nothing while idle, so it costs no width until it matters. */}
<SaveIndicator state={saveState} />
<OverflowMenu
label="Note actions"
items={[
{ label: "New note", onSelect: () => void onCreate() },
{
label: "Delete note",
danger: true,
onSelect: () => void deleteNote(selected.id),
},
]}
/>
</div>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
onBlur={commit}
placeholder="Reminders, gotchas, a prompt worth keeping…"
aria-label="Note body"
className="flex-1 min-h-0 w-full px-3 py-2 bg-transparent text-[13px] text-[var(--text-primary)] resize-none font-mono"
/>
<div className="px-2 py-2 flex-shrink-0 border-t border-[var(--border-color)]">
{/* The live draft, not `selected.body` what is on screen is what gets
sent. `dropUp` because the dock clips its own overflow. */}
<SendToAgentButton
projectId={projectId}
body={body}
fullWidth
dropUp
/>
</div>
</div>
);
}
@@ -1,19 +1,24 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within, fireEvent, waitFor } from "@testing-library/react"; import { render, screen, within, fireEvent, waitFor } from "@testing-library/react";
import NotesPanel from "./NotesPanel"; import NotesPanel from "./NotesPanel";
import NotesDockPanel from "./NotesDockPanel";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import type { Note } from "../../lib/types"; import type { Note } from "../../lib/types";
/** /**
* Two panels, one project the configuration the app actually runs in. * Two panels, one project the configuration the app actually runs in.
* *
* `NotesTab` and `NotesDock` both mount a `NotesPanel`, and the dock follows * `NotesTab` mounts a `NotesPanel` and `NotesDock` mounts a `NotesDockPanel`,
* the active tab's project, so opening the dock over a Project Home tab mounts * and the dock follows the active tab's project, so opening the dock over a
* two panels for the *same* project. Every other notes test mounts exactly * Project Home tab mounts both for the *same* project. Every other notes test
* one, which is precisely the configuration in which a per-panel cache looks * mounts exactly one, which is precisely the configuration in which a
* correct: it is only with two that an edit made in one is seen or lost by * per-panel cache looks correct: it is only with two that an edit made in one
* the other. `useNotes` is deliberately **not** mocked here; the cache is what * is seen or lost by the other. `useNotes` is deliberately **not** mocked
* is under test. * here; the cache is what is under test.
*
* The two are different components on purpose, which is exactly why this test
* pairs them rather than mounting the same one twice: the layouts diverged,
* and the cache and draft rules they share are what must not.
*/ */
const files: Record<string, Note[]> = {}; const files: Record<string, Note[]> = {};
@@ -54,7 +59,7 @@ function renderBothSurfaces() {
<NotesPanel projectId="p1" /> <NotesPanel projectId="p1" />
</div> </div>
<div data-testid="dock"> <div data-testid="dock">
<NotesPanel projectId="p1" /> <NotesDockPanel projectId="p1" />
</div> </div>
</>, </>,
); );
@@ -70,7 +75,7 @@ beforeEach(() => {
useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] }); useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] });
}); });
describe("NotesPanel with the tab and the dock both open", () => { describe("the tab and the dock both open on one project", () => {
it("shows an edit made in one surface in the other", async () => { it("shows an edit made in one surface in the other", async () => {
const { tab, dock } = renderBothSurfaces(); const { tab, dock } = renderBothSurfaces();
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one")); await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
@@ -157,7 +162,10 @@ describe("NotesPanel with the tab and the dock both open", () => {
const { tab, dock } = renderBothSurfaces(); const { tab, dock } = renderBothSurfaces();
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one")); await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
fireEvent.click(dock().getByRole("button", { name: /new note/i })); // The dock keeps New behind its overflow menu — its height belongs to the
// note being written, not to a button row.
fireEvent.click(dock().getByRole("button", { name: /note actions/i }));
fireEvent.click(dock().getByRole("menuitem", { name: /new note/i }));
await waitFor(() => await waitFor(() =>
expect(tab().getAllByRole("button", { name: /untitled note/i })).toHaveLength(1), expect(tab().getAllByRole("button", { name: /untitled note/i })).toHaveLength(1),
+6 -43
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useMemo, useState } from "react";
import { useNotes } from "../../hooks/useNotes"; import { useNotes } from "../../hooks/useNotes";
import { useNoteDraft } from "./useNoteDraft";
import NoteEditor from "./NoteEditor"; import NoteEditor from "./NoteEditor";
import Button from "../ui/Button"; import Button from "../ui/Button";
import SaveIndicator from "../ui/SaveIndicator"; import SaveIndicator from "../ui/SaveIndicator";
@@ -34,54 +35,16 @@ export default function NotesPanel({ projectId }: Props) {
const { notes, loading, saveState, createNote, saveNote, deleteNote } = const { notes, loading, saveState, createNote, saveNote, deleteNote } =
useNotes(projectId); useNotes(projectId);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const selected = useMemo( const selected = useMemo(
() => notes.find((n) => n.id === selectedId) ?? notes[0] ?? null, () => notes.find((n) => n.id === selectedId) ?? notes[0] ?? null,
[notes, selectedId], [notes, selectedId],
); );
// What was last copied out of the store into the draft fields. The draft is const { title, body, setTitle, setBody, commit } = useNoteDraft(
// "untouched" exactly while it still matches this, which is how an edit made selected,
// somewhere else can be shown without ever discarding something half-typed. saveNote,
const seeded = useRef<{ id: string | null; title: string; body: string }>({ );
id: null,
title: "",
body: "",
});
// Load the selected note's stored text into the draft — on a change of note,
// and on a change to the *stored* text of the note already selected. The
// second case is the dock and the tab showing one project at once: an edit
// committed in one surface has to reach the other's editor, not just its
// list. It never overwrites text the user is part-way through typing; that
// blurs into a last-writer-wins save, as any blur-commit editor does.
useEffect(() => {
if (!selected) {
seeded.current = { id: null, title: "", body: "" };
setTitle("");
setBody("");
return;
}
const untouched =
title === seeded.current.title && body === seeded.current.body;
if (seeded.current.id !== selected.id || untouched) {
seeded.current = { id: selected.id, title: selected.title, body: selected.body };
setTitle(selected.title);
setBody(selected.body);
}
}, [selected?.id, selected?.title, selected?.body]); // eslint-disable-line react-hooks/exhaustive-deps
const commit = () => {
if (!selected) return;
// Reading is not editing: clicking through notes must not rewrite the file.
if (title === selected.title && body === selected.body) return;
// Mark the draft as matching what was just committed, so the store update
// this save produces reads as "no change" rather than as a stale re-seed.
seeded.current = { id: selected.id, title, body };
void saveNote({ ...selected, title, body });
};
const onCreate = async () => { const onCreate = async () => {
const note = await createNote(); const note = await createNote();
@@ -11,14 +11,22 @@ vi.mock("../../hooks/useTerminal", () => ({
})); }));
const setActiveTabKey = vi.fn(); const setActiveTabKey = vi.fn();
const requestTerminalFocus = vi.fn();
const pushToast = vi.fn(); const pushToast = vi.fn();
let projects: Project[] = []; let projects: Project[] = [];
vi.mock("../../store/appState", () => ({ vi.mock("../../store/appState", () => ({
useAppState: Object.assign( useAppState: Object.assign(
(selector: (s: unknown) => unknown) => (selector: (s: unknown) => unknown) =>
selector({ projects, setActiveTabKey, pushToast }), selector({ projects, setActiveTabKey, requestTerminalFocus, pushToast }),
{ getState: () => ({ projects, setActiveTabKey, pushToast }) }, {
getState: () => ({
projects,
setActiveTabKey,
requestTerminalFocus,
pushToast,
}),
},
), ),
terminalTabKey: (id: string) => `term:${id}`, terminalTabKey: (id: string) => `term:${id}`,
})); }));
@@ -39,23 +47,55 @@ beforeEach(() => {
}); });
describe("SendToAgentButton", () => { describe("SendToAgentButton", () => {
it("is disabled when the project has no running session", () => { // Unavailable, not `disabled`: the reason a note cannot be sent is the whole
// content of these states, and native `disabled` announces it to nobody.
it("says why it cannot send when the project has no running session", () => {
render(<SendToAgentButton projectId="p1" body="hello" />); render(<SendToAgentButton projectId="p1" body="hello" />);
expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled(); const button = screen.getByRole("button", { name: /send to agent/i });
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAccessibleDescription(
"No running Claude session for this project",
);
}); });
it("is disabled when the only session belongs to another project", () => { it("says why it cannot send an empty note", () => {
sessions = [session()];
render(<SendToAgentButton projectId="p1" body=" " />);
expect(
screen.getByRole("button", { name: /send to agent/i }),
).toHaveAccessibleDescription("Nothing to send — this note is empty");
});
it("is unavailable when the only session belongs to another project", () => {
sessions = [session({ projectId: "other" })]; sessions = [session({ projectId: "other" })];
render(<SendToAgentButton projectId="p1" body="hello" />); render(<SendToAgentButton projectId="p1" body="hello" />);
expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled(); expect(
screen.getByRole("button", { name: /send to agent/i }),
).toHaveAttribute("aria-disabled", "true");
}); });
it("is disabled when the only session is a bash tab", () => { it("is unavailable when the only session is a bash tab", () => {
// `bash -l`'s readline has no binding for ESC+CR and just bells, so a // `bash -l`'s readline has no binding for ESC+CR and just bells, so a
// shell is never a target. // shell is never a target.
sessions = [session({ sessionType: "bash" })]; sessions = [session({ sessionType: "bash" })];
render(<SendToAgentButton projectId="p1" body="hello" />); render(<SendToAgentButton projectId="p1" body="hello" />);
expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled(); expect(
screen.getByRole("button", { name: /send to agent/i }),
).toHaveAttribute("aria-disabled", "true");
});
// `aria-disabled` is advisory — it blocks nothing on its own. Without the
// guard this swap would turn a greyed-out button into a live one.
it("sends nothing when activated while unavailable", () => {
render(<SendToAgentButton projectId="p1" body="hello" />);
const button = screen.getByRole("button", { name: /send to agent/i });
fireEvent.click(button);
fireEvent.keyDown(button, { key: "Enter" });
fireEvent.keyDown(button, { key: " " });
expect(sendInput).not.toHaveBeenCalled();
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
}); });
it("sends straight to the one session, with newlines converted and no terminator", async () => { it("sends straight to the one session, with newlines converted and no terminator", async () => {
@@ -100,6 +140,52 @@ describe("SendToAgentButton", () => {
it("does nothing for an empty note", () => { it("does nothing for an empty note", () => {
sessions = [session()]; sessions = [session()];
render(<SendToAgentButton projectId="p1" body=" " />); render(<SendToAgentButton projectId="p1" body=" " />);
expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled(); const button = screen.getByRole("button", { name: /send to agent/i });
expect(button).toHaveAttribute("aria-disabled", "true");
fireEvent.click(button);
fireEvent.keyDown(button, { key: "Enter" });
expect(sendInput).not.toHaveBeenCalled();
}); });
});
it("opens the session menu upward when it sits at the foot of the dock", async () => {
sessions = [session({ id: "s1" }), session({ id: "s2" })];
render(<SendToAgentButton projectId="p1" body="hello" dropUp />);
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
// Anchored to the button's top edge, not below it: the dock clips its own
// overflow, so a downward menu at the bottom edge is invisible.
await waitFor(() => expect(screen.getByRole("menu")).toHaveClass("bottom-full"));
});
// Switching to the tab is not enough. When the dock is open beside the
// terminal it sends to, that terminal is already the active tab, so
// `setActiveTabKey` changes nothing and no effect re-runs — leaving focus on
// this button, one click short of the Enter the user came to press.
it("hands focus to the terminal so the next keystroke is Enter", async () => {
sessions = [session()];
render(<SendToAgentButton projectId="p1" body="hello" />);
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s1"));
});
it("leaves focus alone when the send failed", async () => {
sessions = [session()];
sendInput.mockRejectedValueOnce(new Error("pty gone"));
render(<SendToAgentButton projectId="p1" body="hello" />);
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
await waitFor(() => expect(pushToast).toHaveBeenCalled());
expect(requestTerminalFocus).not.toHaveBeenCalled();
});
it("focuses the session picked from the menu, not the first one", async () => {
sessions = [session(), session({ id: "s2", sessionName: "review" })];
render(<SendToAgentButton projectId="p1" body="hello" />);
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
fireEvent.click(await screen.findByRole("menuitem", { name: "review" }));
await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s2"));
});
});
+44 -13
View File
@@ -9,6 +9,14 @@ import Button from "../ui/Button";
interface Props { interface Props {
projectId: string; projectId: string;
body: string; body: string;
/**
* Open the session menu above the button instead of below. The dock puts
* this at its foot, and the dock clips its own overflow, so a downward menu
* there is drawn outside the panel and never seen.
*/
dropUp?: boolean;
/** Fill the row. The dock's send bar is the width of the dock. */
fullWidth?: boolean;
} }
/** /**
@@ -21,15 +29,22 @@ interface Props {
* Only `claude` sessions are offered. A bash tab would receive ESC+CR as an * Only `claude` sessions are offered. A bash tab would receive ESC+CR as an
* unbound readline key and answer with a bell (see `lib/claudeInput.ts`). * unbound readline key and answer with a bell (see `lib/claudeInput.ts`).
*/ */
export default function SendToAgentButton({ projectId, body }: Props) { export default function SendToAgentButton({
projectId,
body,
dropUp = false,
fullWidth = false,
}: Props) {
const { sessions, sendInput } = useTerminal(); const { sessions, sendInput } = useTerminal();
const { projects, setActiveTabKey, pushToast } = useAppState( const { projects, setActiveTabKey, requestTerminalFocus, pushToast } =
useShallow((s) => ({ useAppState(
projects: s.projects, useShallow((s) => ({
setActiveTabKey: s.setActiveTabKey, projects: s.projects,
pushToast: s.pushToast, setActiveTabKey: s.setActiveTabKey,
})), requestTerminalFocus: s.requestTerminalFocus,
); pushToast: s.pushToast,
})),
);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
@@ -43,7 +58,7 @@ export default function SendToAgentButton({ projectId, body }: Props) {
const project = projects.find((p) => p.id === projectId); const project = projects.find((p) => p.id === projectId);
const hasBody = body.trim().length > 0; const hasBody = body.trim().length > 0;
const disabled = targets.length === 0 || !hasBody; const unavailable = targets.length === 0 || !hasBody;
// Same dismissal contract as `ui/OverflowMenu` and the tab context menu. // Same dismissal contract as `ui/OverflowMenu` and the tab context menu.
useEffect(() => { useEffect(() => {
@@ -73,6 +88,11 @@ export default function SendToAgentButton({ projectId, body }: Props) {
// A courtesy, not part of the send: if the tab cannot be focused the // A courtesy, not part of the send: if the tab cannot be focused the
// text still went. // text still went.
setActiveTabKey(terminalTabKey(sessionId)); setActiveTabKey(terminalTabKey(sessionId));
// Switching tabs is not the same as taking focus, and when the dock is
// open beside the terminal it just sent to, that tab is already the
// active one — so nothing above moves the caret off this button. The
// note is sitting in the prompt waiting for Enter; put the user there.
requestTerminalFocus(sessionId);
} catch (e) { } catch (e) {
pushToast({ pushToast({
kind: "error", kind: "error",
@@ -81,7 +101,7 @@ export default function SendToAgentButton({ projectId, body }: Props) {
}); });
} }
}, },
[body, sendInput, setActiveTabKey, pushToast], [body, sendInput, setActiveTabKey, requestTerminalFocus, pushToast],
); );
const onClick = useCallback(() => { const onClick = useCallback(() => {
@@ -102,10 +122,19 @@ export default function SendToAgentButton({ projectId, body }: Props) {
: "Put this note into the agent's prompt (you press Enter)"; : "Put this note into the agent's prompt (you press Enter)";
return ( return (
<div ref={rootRef} className="relative inline-block"> <div
ref={rootRef}
className={`relative ${fullWidth ? "block w-full" : "inline-block"}`}
>
<Button <Button
variant="secondary" variant="secondary"
disabled={disabled} size={fullWidth ? "md" : "sm"}
className={fullWidth ? "w-full" : ""}
// Not `disabled`: every one of these reasons is information, and
// `disabled` takes the button — reason and all — out of the
// accessibility tree. `Button` guards the click for us.
unavailable={unavailable}
unavailableReason={title}
onClick={onClick} onClick={onClick}
aria-haspopup={targets.length > 1 ? "menu" : undefined} aria-haspopup={targets.length > 1 ? "menu" : undefined}
aria-expanded={targets.length > 1 ? menuOpen : undefined} aria-expanded={targets.length > 1 ? menuOpen : undefined}
@@ -116,7 +145,9 @@ export default function SendToAgentButton({ projectId, body }: Props) {
{menuOpen && targets.length > 1 && ( {menuOpen && targets.length > 1 && (
<div <div
role="menu" role="menu"
className="absolute right-0 z-40 mt-1 min-w-[12rem] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs" className={`absolute right-0 z-40 min-w-[12rem] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs ${
dropUp ? "bottom-full mb-1" : "mt-1"
}`}
style={{ boxShadow: "var(--shadow-overlay)" }} style={{ boxShadow: "var(--shadow-overlay)" }}
> >
{targets.map((s) => ( {targets.map((s) => (
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useRef, useState } from "react";
import type { Note } from "../../lib/types";
/**
* Draft text for the note being edited, committed when a field loses focus.
*
* This is the half the dock and the tab must never disagree on, so it lives
* here rather than in either layout. The two surfaces differ in how they show
* notes; they must not differ in when a keystroke becomes a save.
*
* The draft is "untouched" exactly while it still matches what was last copied
* out of the store, which is what lets an edit made on the *other* surface
* reach this one's editor without ever discarding half-typed text.
*/
export function useNoteDraft(
selected: Note | null,
saveNote: (note: Note) => Promise<unknown>,
) {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const seeded = useRef<{ id: string | null; title: string; body: string }>({
id: null,
title: "",
body: "",
});
// Re-seed on a change of note, and on a change to the *stored* text of the
// note already open — the second case is the dock and the tab showing one
// project at once.
useEffect(() => {
if (!selected) {
seeded.current = { id: null, title: "", body: "" };
setTitle("");
setBody("");
return;
}
const untouched =
title === seeded.current.title && body === seeded.current.body;
if (seeded.current.id !== selected.id || untouched) {
seeded.current = {
id: selected.id,
title: selected.title,
body: selected.body,
};
setTitle(selected.title);
setBody(selected.body);
}
}, [selected?.id, selected?.title, selected?.body]); // eslint-disable-line react-hooks/exhaustive-deps
const commit = () => {
if (!selected) return;
// Reading is not editing: clicking through notes must not rewrite the file.
if (title === selected.title && body === selected.body) return;
// Mark the draft as matching what was just committed, so the store update
// this save produces reads as "no change" rather than as a stale re-seed.
seeded.current = { id: selected.id, title, body };
void saveNote({ ...selected, title, body });
};
return { title, body, setTitle, setBody, commit };
}
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import AddProjectDialog from "./AddProjectDialog";
const add = vi.fn();
vi.mock("../../hooks/useProjects", () => ({
useProjects: () => ({ add }),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(async () => null),
}));
/** A promise whose resolution this test controls, so `loading` can be held open. */
function deferred() {
let resolve!: (v: unknown) => void;
const promise = new Promise((r) => {
resolve = r;
});
return { promise, resolve };
}
function fillValidForm() {
fireEvent.change(screen.getByLabelText("Project name"), {
target: { value: "my-project" },
});
fireEvent.change(screen.getByLabelText("Folder 1 host path"), {
target: { value: "/home/user/my-project" },
});
}
function submitButton() {
return screen.getByRole("button", { name: /Add Project|Adding/ });
}
describe("AddProjectDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("adds the project with the name and folder entered", async () => {
add.mockResolvedValue({ id: "p1" });
const onClose = vi.fn();
render(<AddProjectDialog onClose={onClose} />);
fillValidForm();
fireEvent.click(submitButton());
await waitFor(() =>
expect(add).toHaveBeenCalledWith("my-project", [
{ host_path: "/home/user/my-project", mount_name: "my-project" },
]),
);
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("keeps the submit button announced, and explains why, while adding", async () => {
const { promise, resolve } = deferred();
add.mockReturnValue(promise);
render(<AddProjectDialog onClose={vi.fn()} />);
fillValidForm();
fireEvent.click(submitButton());
// Native `disabled` would remove the button from the accessibility tree
// exactly when it has something to say.
await waitFor(() =>
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
);
expect(submitButton()).not.toBeDisabled();
expect(submitButton()).toHaveAccessibleDescription(/being added/i);
await act(async () => resolve({ id: "p1" }));
});
it("ignores clicks and Enter/Space on the submit button while adding", async () => {
const { promise, resolve } = deferred();
add.mockReturnValue(promise);
render(<AddProjectDialog onClose={vi.fn()} />);
fillValidForm();
fireEvent.click(submitButton());
await waitFor(() =>
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
);
fireEvent.click(submitButton());
fireEvent.keyDown(submitButton(), { key: "Enter" });
fireEvent.keyDown(submitButton(), { key: " " });
expect(add).toHaveBeenCalledTimes(1);
await act(async () => resolve({ id: "p1" }));
});
it("ignores a form submit raised from elsewhere while adding", async () => {
const { promise, resolve } = deferred();
add.mockReturnValue(promise);
render(<AddProjectDialog onClose={vi.fn()} />);
fillValidForm();
fireEvent.click(submitButton());
await waitFor(() =>
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
);
// Enter in a text field submits a form regardless of the submit button's
// state, so the handler has to guard itself too.
// Modal portals to document.body, so the form is not under `container`.
const form = document.querySelector("form");
expect(form).not.toBeNull();
fireEvent.submit(form!);
expect(add).toHaveBeenCalledTimes(1);
await act(async () => resolve({ id: "p1" }));
});
it("leaves the submit button plainly available when idle", () => {
render(<AddProjectDialog onClose={vi.fn()} />);
expect(submitButton()).not.toHaveAttribute("aria-disabled");
expect(submitButton()).toHaveAccessibleDescription("");
});
});
@@ -55,6 +55,10 @@ export default function AddProjectDialog({ onClose }: Props) {
const handleSubmit = async (e?: React.FormEvent) => { const handleSubmit = async (e?: React.FormEvent) => {
if (e) e.preventDefault(); if (e) e.preventDefault();
// The submit button is `aria-disabled` rather than `disabled` while an add
// is in flight, and Enter inside a text field submits the form without
// touching the button at all. Both routes end here, so the guard does too.
if (loading) return;
if (!name.trim()) { if (!name.trim()) {
setError("Project name is required"); setError("Project name is required");
return; return;
@@ -97,7 +101,19 @@ export default function AddProjectDialog({ onClose }: Props) {
<Button size="md" variant="ghost" onClick={onClose}> <Button size="md" variant="ghost" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button size="md" variant="primary" type="submit" form={formId} disabled={loading}> <Button
size="md"
variant="primary"
type="submit"
form={formId}
unavailable={loading}
unavailableReason="The project is being added. Wait for it to finish."
title={
loading
? "The project is being added. Wait for it to finish."
: undefined
}
>
{loading ? "Adding…" : "Add Project"} {loading ? "Adding…" : "Add Project"}
</Button> </Button>
</> </>
@@ -122,14 +122,6 @@ describe("ProjectRow", () => {
}); });
it("only allows opening a terminal while the container runs", () => { it("only allows opening a terminal while the container runs", () => {
const { unmount } = render(<ProjectRow project={baseProject} />);
expect(
screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
}),
).toBeDisabled();
unmount();
render(<ProjectRow project={{ ...baseProject, status: "running" }} />); render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
fireEvent.click( fireEvent.click(
screen.getByRole("button", { screen.getByRole("button", {
@@ -139,6 +131,38 @@ describe("ProjectRow", () => {
expect(mockOpenClaudeTerminal).toHaveBeenCalled(); expect(mockOpenClaudeTerminal).toHaveBeenCalled();
}); });
it("keeps the terminal button announced, and explains why, while stopped", () => {
render(<ProjectRow project={baseProject} />);
const button = screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
});
// Native `disabled` would drop the button out of the accessibility tree
// and out of the tab order, taking the reason with it.
expect(button).not.toBeDisabled();
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAccessibleDescription(/is not running/i);
});
it("ignores clicks and Enter/Space on the terminal button while stopped", () => {
render(<ProjectRow project={baseProject} />);
const button = screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
});
fireEvent.click(button);
fireEvent.keyDown(button, { key: "Enter" });
fireEvent.keyDown(button, { key: " " });
expect(mockOpenClaudeTerminal).not.toHaveBeenCalled();
});
it("drops aria-disabled once the container is running", () => {
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
const button = screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
});
expect(button).not.toHaveAttribute("aria-disabled");
expect(button).not.toHaveAccessibleDescription(/is not running/i);
});
it("shows container progress inline rather than in a blocking modal", () => { it("shows container progress inline rather than in a blocking modal", () => {
setStore({ containerProgress: { "test-1": "Pulling image…" } }); setStore({ containerProgress: { "test-1": "Pulling image…" } });
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />); render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
+18 -4
View File
@@ -3,6 +3,7 @@ import type { Project } from "../../lib/types";
import { useAppState, homeTabKey } from "../../store/appState"; import { useAppState, homeTabKey } from "../../store/appState";
import { useProjectActions } from "../../hooks/useProjectActions"; import { useProjectActions } from "../../hooks/useProjectActions";
import { ProjectStatusIndicator } from "../ui/StatusIndicator"; import { ProjectStatusIndicator } from "../ui/StatusIndicator";
import { useUnavailable } from "../ui/unavailable";
interface Props { interface Props {
project: Project; project: Project;
@@ -31,6 +32,15 @@ export default function ProjectRow({ project }: Props) {
const isTransitioning = const isTransitioning =
project.status === "starting" || project.status === "stopping"; project.status === "starting" || project.status === "stopping";
// A terminal needs a running container. Saying so out loud beats a `disabled`
// attribute that hides the button — and the reason — from anyone not using a
// mouse and eyes.
const terminal = useUnavailable({
unavailable: !isRunning,
reason: `${project.name} is not running. Start it to open a terminal.`,
onClick: () => openClaudeTerminal(),
});
return ( return (
<div <div
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${ className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
@@ -113,11 +123,14 @@ export default function ProjectRow({ project }: Props) {
</button> </button>
<button <button
type="button" type="button"
disabled={!isRunning} {...terminal.controlProps}
onClick={() => openClaudeTerminal()} title={
title={`Open a Claude terminal for ${project.name}`} isRunning
? `Open a Claude terminal for ${project.name}`
: `${project.name} is not running. Start it to open a terminal.`
}
aria-label={`Open a Claude terminal for ${project.name}`} aria-label={`Open a Claude terminal for ${project.name}`}
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors" className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent aria-disabled:cursor-not-allowed transition-colors"
> >
<svg <svg
className="w-3.5 h-3.5" className="w-3.5 h-3.5"
@@ -134,6 +147,7 @@ export default function ProjectRow({ project }: Props) {
<line x1="13" y1="15" x2="17" y2="15" /> <line x1="13" y1="15" x2="17" y2="15" />
</svg> </svg>
</button> </button>
{terminal.reasonNode}
</div> </div>
</div> </div>
); );
@@ -538,3 +538,59 @@ describe("TerminalView — reaching the URL prompt without a mouse", () => {
expect(document.activeElement).toBe(before); expect(document.activeElement).toBe(before);
}); });
}); });
describe("TerminalView — focus on request", () => {
/** Mount, then deliberately give focus away, so what the assertions below
* observe is the *request* taking effect and never the focus `active`
* already grants on mount. That distinction is the whole point: the notes
* dock sends to a terminal whose tab is already active, where nothing
* changes and no `active` effect re-runs. */
async function mountAndBlur() {
const view = mountSession("claude");
await act(async () => {});
const elsewhere = document.createElement("button");
document.body.appendChild(elsewhere);
elsewhere.focus();
expect(document.activeElement).toBe(elsewhere);
return view;
}
it("focuses the terminal named by the request", async () => {
const view = await mountAndBlur();
await act(async () => {
useAppState.getState().requestTerminalFocus("s1");
});
expect(document.activeElement).toBe(helperTextarea(view.container));
});
it("ignores a request meant for another session", async () => {
const view = await mountAndBlur();
const before = document.activeElement;
await act(async () => {
useAppState.getState().requestTerminalFocus("s2");
});
expect(document.activeElement).toBe(before);
expect(document.activeElement).not.toBe(helperTextarea(view.container));
});
it("clears the request, so a second send focuses again", async () => {
const view = await mountAndBlur();
await act(async () => {
useAppState.getState().requestTerminalFocus("s1");
});
expect(useAppState.getState().pendingTerminalFocus).toBeNull();
const elsewhere = document.querySelector("button");
(elsewhere as HTMLButtonElement).focus();
await act(async () => {
useAppState.getState().requestTerminalFocus("s1");
});
expect(document.activeElement).toBe(helperTextarea(view.container));
});
});
@@ -731,6 +731,21 @@ export default function TerminalView({ sessionId, active }: Props) {
} }
}, [active, gpuRenderingSetting]); }, [active, gpuRenderingSetting]);
// Focus on demand, for the caller that cannot rely on the effect above.
// That one keys off `active`, so it covers switching *to* a terminal and
// nothing else — and the notes dock sends to the terminal already on screen,
// where `active` never changes. Consumed once and cleared, so asking twice
// for the same terminal works.
const pendingTerminalFocus = useAppState((s) => s.pendingTerminalFocus);
const clearPendingTerminalFocus = useAppState(
(s) => s.clearPendingTerminalFocus,
);
useEffect(() => {
if (pendingTerminalFocus !== sessionId) return;
termRef.current?.focus();
clearPendingTerminalFocus();
}, [pendingTerminalFocus, sessionId, clearPendingTerminalFocus]);
// Auto-dismiss toast after 30 seconds — unless the user is standing in it. // Auto-dismiss toast after 30 seconds — unless the user is standing in it.
// A keyboard user who has just jumped into the toast is mid-decision, and // A keyboard user who has just jumped into the toast is mid-decision, and
// pulling it out from under them costs them the only route to finishing a // pulling it out from under them costs them the only route to finishing a
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import Button from "./Button";
const onClick = vi.fn();
const onKeyDown = vi.fn();
describe("Button", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("still supports the native disabled attribute", () => {
render(
<Button disabled onClick={onClick}>
Save
</Button>,
);
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
});
it("stays in the accessibility tree when unavailable, and says why", () => {
render(
<Button unavailable unavailableReason="Stop the container first.">
Save
</Button>,
);
const button = screen.getByRole("button", { name: "Save" });
expect(button).not.toBeDisabled();
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAccessibleDescription("Stop the container first.");
// The reason is a description, not part of the name.
expect(button).toHaveAccessibleName("Save");
});
it("guards clicks and Enter/Space while unavailable", () => {
render(
<Button unavailable unavailableReason="Stop the container first." onClick={onClick}>
Save
</Button>,
);
const button = screen.getByRole("button", { name: "Save" });
fireEvent.click(button);
fireEvent.keyDown(button, { key: "Enter" });
fireEvent.keyDown(button, { key: " " });
expect(onClick).not.toHaveBeenCalled();
});
it("still forwards keys that are not activation keys", () => {
render(
<Button
unavailable
unavailableReason="Stop the container first."
onKeyDown={onKeyDown}
>
Save
</Button>,
);
fireEvent.keyDown(screen.getByRole("button", { name: "Save" }), {
key: "Escape",
});
expect(onKeyDown).toHaveBeenCalled();
});
it("behaves like an ordinary button when available", () => {
render(
<Button unavailable={false} unavailableReason="Stop the container first." onClick={onClick}>
Save
</Button>,
);
const button = screen.getByRole("button", { name: "Save" });
expect(button).not.toHaveAttribute("aria-disabled");
expect(button).toHaveAccessibleDescription("");
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
});
+41 -11
View File
@@ -1,4 +1,5 @@
import type { ButtonHTMLAttributes, ReactNode } from "react"; import type { ButtonHTMLAttributes, ReactNode } from "react";
import { useUnavailable } from "./unavailable";
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost"; export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
export type ButtonSize = "sm" | "md"; export type ButtonSize = "sm" | "md";
@@ -7,22 +8,37 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant; variant?: ButtonVariant;
size?: ButtonSize; size?: ButtonSize;
children: ReactNode; children: ReactNode;
/**
* Unavailable, but still announced. Renders `aria-disabled` and wires
* `unavailableReason` to `aria-describedby` instead of using the native
* `disabled` attribute, which would take the button out of the tab order and
* out of the accessibility tree reason and all. Clicks and Enter/Space are
* guarded for you. Prefer this over `disabled` whenever there is a reason
* worth telling the user.
*/
unavailable?: boolean;
/** Why the button cannot be used. Required for `unavailable` to say anything. */
unavailableReason?: string;
} }
/** /**
* Real buttons with visible bounds and a 24px hit target. * Real buttons with visible bounds and a 24px hit target.
* Filled variants use the *-emphasis tokens so white text clears WCAG AA; * Filled variants use the *-emphasis tokens so white text clears WCAG AA;
* `--accent` stays reserved for foreground/link use. * `--accent` stays reserved for foreground/link use.
*
* The `aria-disabled:` class mirrors below exist because Tailwind's
* `disabled:` variant only matches the native attribute, which `unavailable`
* deliberately does not set. Keep the two lists in step.
*/ */
const VARIANTS: Record<ButtonVariant, string> = { const VARIANTS: Record<ButtonVariant, string> = {
primary: primary:
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)]", "bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] aria-disabled:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
secondary: secondary:
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)]", "bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
danger: danger:
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent", "bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-transparent",
ghost: ghost:
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent", "bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent",
}; };
const SIZES: Record<ButtonSize, string> = { const SIZES: Record<ButtonSize, string> = {
@@ -35,16 +51,30 @@ export default function Button({
size = "sm", size = "sm",
className = "", className = "",
type = "button", type = "button",
unavailable = false,
unavailableReason = "",
children, children,
...rest ...rest
}: Props) { }: Props) {
const { controlProps, reasonNode } = useUnavailable({
unavailable,
reason: unavailableReason,
onClick: rest.onClick,
onKeyDown: rest.onKeyDown,
});
return ( return (
<button <>
type={type} <button
{...rest} type={type}
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`} {...rest}
> {...controlProps}
{children} className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed aria-disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
</button> >
{children}
</button>
{/* Outside the button: inside, the reason would join its accessible name. */}
{reasonNode}
</>
); );
} }
+87
View File
@@ -0,0 +1,87 @@
import {
useId,
type KeyboardEventHandler,
type MouseEventHandler,
type ReactNode,
} from "react";
/** Keys a native `<button>` turns into a click. */
const ACTIVATION_KEYS = new Set([" ", "Spacebar", "Enter"]);
export interface UnavailableControlProps {
"aria-disabled"?: true;
"aria-describedby"?: string;
onClick?: MouseEventHandler<HTMLButtonElement>;
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
}
export interface UnavailableControl {
/** Spread onto the control. Carries the guarded handlers. */
controlProps: UnavailableControlProps;
/**
* Render as a *sibling* of the control inside it the reason would be
* appended to the accessible name instead of the description.
*/
reasonNode: ReactNode;
}
/**
* Makes a control unavailable without hiding it from assistive technology.
*
* `disabled` takes an element out of the tab order *and* out of the
* accessibility tree, so the `title` explaining why it cannot be used is
* announced to nobody and shown only to a sighted user with a mouse. That is
* backwards: the people who most need the reason are the ones who never get
* it. `aria-disabled` keeps the control focusable and announced, and
* `aria-describedby` hands over the reason.
*
* The catch is that `aria-disabled` is advisory it does not block clicks or
* Enter/Space the way `disabled` does. This hook therefore returns the guards
* along with the attributes, so a call site cannot take the announcement
* without the guard. Handlers that a form can reach without going through the
* control (Enter inside a text field submits the form) still have to guard
* themselves.
*/
export function useUnavailable({
unavailable,
reason,
onClick,
onKeyDown,
}: {
unavailable: boolean;
reason: string;
onClick?: MouseEventHandler<HTMLButtonElement>;
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
}): UnavailableControl {
const reasonId = `${useId()}unavailable`;
if (!unavailable) {
return { controlProps: { onClick, onKeyDown }, reasonNode: null };
}
return {
controlProps: {
"aria-disabled": true,
"aria-describedby": reasonId,
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
},
onKeyDown: (e) => {
if (!ACTIVATION_KEYS.has(e.key)) {
onKeyDown?.(e);
return;
}
// Suppress the default action before it can become a click, submit a
// form, or scroll the page.
e.preventDefault();
e.stopPropagation();
},
},
reasonNode: (
<span id={reasonId} className="sr-only">
{reason}
</span>
),
};
}
+24
View File
@@ -112,3 +112,27 @@ describe("toasts", () => {
expect(toasts()).toHaveLength(2); expect(toasts()).toHaveLength(2);
}); });
}); });
describe("terminal focus requests", () => {
beforeEach(() => useAppState.setState({ pendingTerminalFocus: null }));
const pending = () => useAppState.getState().pendingTerminalFocus;
it("names the session that should take focus", () => {
useAppState.getState().requestTerminalFocus("s1");
expect(pending()).toBe("s1");
});
// Consumed once, exactly like `pendingHomeTab`. Without the clear, the
// second send to a terminal already holding the request would set the same
// value, no state would change, and no effect would re-run — which is the
// failure this whole mechanism exists to fix.
it("is cleared once consumed, so the same terminal can be asked again", () => {
useAppState.getState().requestTerminalFocus("s1");
useAppState.getState().clearPendingTerminalFocus();
expect(pending()).toBeNull();
useAppState.getState().requestTerminalFocus("s1");
expect(pending()).toBe("s1");
});
});
+18
View File
@@ -144,6 +144,21 @@ interface AppState {
/** Consumed once by `ProjectHome`, then cleared. */ /** Consumed once by `ProjectHome`, then cleared. */
pendingHomeTab: { projectId: string; tab: string } | null; pendingHomeTab: { projectId: string; tab: string } | null;
clearPendingHomeTab: () => void; clearPendingHomeTab: () => void;
/**
* Ask a terminal to take keyboard focus.
*
* `TerminalView` already focuses when its tab *becomes* active, which covers
* switching to a terminal. It cannot cover being asked to focus the terminal
* that is already on screen nothing changes, so no effect re-runs and
* that is the ordinary case for the notes dock, which sits beside the
* terminal it sends to.
*
* Consumed once and cleared, like `pendingHomeTab`: holding the id would
* make a second request for the same terminal a no-op state write.
*/
pendingTerminalFocus: string | null;
requestTerminalFocus: (sessionId: string) => void;
clearPendingTerminalFocus: () => void;
closeHomeTab: (projectId: string) => void; closeHomeTab: (projectId: string) => void;
setActiveTabKey: (key: string) => void; setActiveTabKey: (key: string) => void;
cycleTab: (delta: number) => void; cycleTab: (delta: number) => void;
@@ -352,6 +367,9 @@ export const useAppState = create<AppState>((set) => ({
}), }),
pendingHomeTab: null, pendingHomeTab: null,
clearPendingHomeTab: () => set({ pendingHomeTab: null }), clearPendingHomeTab: () => set({ pendingHomeTab: null }),
pendingTerminalFocus: null,
requestTerminalFocus: (sessionId) => set({ pendingTerminalFocus: sessionId }),
clearPendingTerminalFocus: () => set({ pendingTerminalFocus: null }),
closeHomeTab: (projectId) => closeHomeTab: (projectId) =>
set((state) => { set((state) => {
const key = homeTabKey(projectId); const key = homeTabKey(projectId);
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
AppStream metadata for the AppImage.
Without this an AppImage manager (Gear Lever, AppImageLauncher and the like)
can adopt the file but has nothing to show for it: no summary, no category,
no release history. appimagetool warns about its absence on every build.
The id matches `identifier` in tauri.conf.json and the .desktop basename, so
the desktop entry, the AppStream component and the AppImage all name the
same application. `@VERSION@` is substituted at build time.
-->
<component type="desktop-application">
<id>com.triple-c.desktop</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>Triple-C</name>
<summary>Run Claude Code sessions in isolated Docker containers</summary>
<description>
<p>
Triple-C sandboxes Claude Code inside per-project Docker containers, so an
agent can install packages, edit files and run commands without touching
the host. Each project gets its own container, its own credentials and its
own terminal sessions.
</p>
<p>Features:</p>
<ul>
<li>Per-project containers with persistent home and config volumes</li>
<li>Multiple terminal sessions per project, in one reorderable tab strip</li>
<li>Notes that can be sent straight into a running agent's prompt</li>
<li>Anthropic, AWS Bedrock, Ollama, llama.cpp and OpenAI-compatible backends</li>
<li>Remote access over a browser terminal, and speech-to-text input</li>
</ul>
</description>
<launchable type="desktop-id">Triple-C.desktop</launchable>
<categories>
<category>Development</category>
<category>Utility</category>
</categories>
<url type="homepage">https://github.com/shadowdao/triple-c</url>
<url type="bugtracker">https://github.com/shadowdao/triple-c/issues</url>
<provides>
<binary>triple-c</binary>
</provides>
<releases>
<release version="@VERSION@" date="@DATE@"/>
</releases>
<content_rating type="oars-1.1"/>
</component>
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env bash
#
# Post-process a built AppImage: make it start on modern Mesa, and make it
# adoptable and updatable by an AppImage manager.
#
# Tauri hands off to linuxdeploy, which offers no hook between building the
# AppDir and packing it, so both jobs are done by unpacking the finished image
# and repacking it. That is also why the update information is embedded here
# rather than passed to the bundler.
#
# ---------------------------------------------------------------------------
# 1. The bundled Wayland client
# ---------------------------------------------------------------------------
#
# linuxdeploy-plugin-gtk bundles libwayland-client.so.0 as a dependency of
# GTK, and `AppRun.wrapped` puts the bundled lib directory ahead of the host's
# on the loader path. The host's Mesa then resolves its Wayland EGL platform
# against *our* copy instead of the system one it was built against, and when
# ours is older than Mesa needs, EGL initialisation fails outright:
#
# Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
#
# WebKitGTK prints that from its own C code and kills the webview, so the
# window comes up blank. Measured on CachyOS with wayland 1.26 / Mesa 26.2.1
# against an AppImage built on Ubuntu 22.04 (wayland 1.20): eleven symbols
# Mesa can ask for are missing from the bundled copy, `wl_proxy_get_display`,
# `wl_proxy_get_queue`, `wl_display_create_queue_with_name` and
# `wl_fixes_interface` among them. Removing this one file from the AppDir
# fixes it; removing libwayland-egl or libepoxy does not.
#
# **Building on a newer runner would not fix this.** libwayland-client is a
# host-coupled library in the same way libGL, libEGL and libdrm are: it has to
# match the compositor and Mesa actually running, not the ones the build
# machine had. Any pinned version is wrong on a system newer than the builder,
# so the only correct version is the host's. That is what AppImage excludelists
# are for; this library simply is not on linuxdeploy's.
#
# Bundling a *newer* wayland instead would not fix this either, only defer it.
# The version floor is set by the host's Mesa: `libEGL_mesa.so.0` — the driver
# libglvnd's `libEGL.so.1` dlopens — carries a hard DT_NEEDED on
# libwayland-client.so.0. If those symbols will not resolve, the driver never
# loads, glvnd is left with none, and `eglGetDisplay` reports no display. That
# is why forcing GDK_BACKEND=x11 does not dodge it, and why the symptom is a
# bad-parameter error rather than a link failure. Their Mesa updates independently of our releases, so any version
# we pick is one wayland release away from being too old again.
#
# So the copy is not deleted, it is demoted. It moves to a directory that is
# not on the loader path, and a hook puts that directory on the path only when
# the host has no libwayland-client of its own. Hosts with one — which is
# every host with a graphical desktop, since Mesa itself depends on it — get
# theirs, matching their Mesa. A host without one still gets a working app.
#
# The ordering works because `AppRun.wrapped` appends the inherited
# LD_LIBRARY_PATH after its own AppDir entries, so anything the hook exports
# lands last: a fallback, never an override.
#
# ---------------------------------------------------------------------------
# 2. Metadata an AppImage manager needs
# ---------------------------------------------------------------------------
#
# Two things, neither of which the bundler produces:
#
# * AppStream metadata, so a manager can show what the app is rather than a
# bare filename. appimagetool warns about its absence on every build.
# * Update information embedded in the image — the string that tells a
# manager where to look for a newer build. Without it the app can be
# adopted but never updated, which is the whole point.
#
# The update URL is a **fixed** tag on the GitHub mirror, which is where
# updates are pulled from, rather than `releases/latest`. `latest` follows
# whatever release is newest, and the Gitea-to-GitHub backfill creates one
# GitHub release per Gitea tag — including the `-win` and `-mac` tags, which
# carry no AppImage. A fixed tag cannot be pointed at a release that has none,
# and is equally immune to a release marked prerelease.
#
# The output is named for the fixed tag too. zsync records the filename it was
# generated for and a client resolves it relative to the .zsync URL, so a
# versioned name would send every client looking for the version it already
# has. The versioned copy is written afterwards for the normal release.
#
# It also fills in `Categories=`, which linuxdeploy leaves empty — that is what
# a desktop menu and most managers use to file the application.
#
# Usage: finalize-appimage.sh <directory holding the .AppImage>
set -euo pipefail
LIB="libwayland-client.so.0"
FALLBACK_DIR="usr/lib/wayland-fallback"
HOOK="apprun-hooks/triple-c-wayland-fallback.sh"
APPIMAGE_TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
APP_ID="com.triple-c.desktop"
STABLE_NAME="Triple-C_x86_64.AppImage"
UPDATE_TAG="linux-latest"
UPDATE_INFO="zsync|https://github.com/shadowdao/triple-c/releases/download/${UPDATE_TAG}/${STABLE_NAME}.zsync"
CATEGORIES="Development;Utility;"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
appdata_src="$repo_root/packaging/appimage/$APP_ID.appdata.xml"
dir="${1:?usage: unbundle-wayland-client.sh <bundle/appimage directory>}"
cd "$dir"
shopt -s nullglob
images=(*.AppImage)
shopt -u nullglob
if [ ${#images[@]} -eq 0 ]; then
echo "No .AppImage in $dir — nothing to do." >&2
exit 0
fi
appimage="${images[0]}"
here="$PWD"
work="$(mktemp -d)"
check="$(mktemp -d)"
trap 'rm -rf "$work" "$check"' EXIT
echo "Inspecting $appimage"
( cd "$work" && "$here/$appimage" --appimage-extract >/dev/null )
root="$work/squashfs-root"
if [ ! -e "$root/usr/lib/$LIB" ]; then
# Not a failure: linuxdeploy may have stopped bundling it, which is the
# outcome this script exists to produce.
echo "$LIB is not bundled — leaving $appimage alone."
exit 0
fi
mkdir -p "$root/$FALLBACK_DIR"
mv "$root/usr/lib/$LIB" "$root/$FALLBACK_DIR/$LIB"
cat > "$root/$HOOK" <<'HOOK_EOF'
#! /usr/bin/env bash
# Fall back to the bundled libwayland-client only when the host has none.
#
# The host's copy is the correct one whenever it exists: its Mesa was built
# against it, and `libEGL.so.1` needs symbols from it before it will load.
# Ours is here so a host without any libwayland-client still starts.
#
# This runs before AppRun.wrapped, which appends the inherited
# LD_LIBRARY_PATH after its own entries — so this is always a fallback.
_tc_host_has_wayland_client() {
if command -v ldconfig >/dev/null 2>&1 &&
ldconfig -p 2>/dev/null | grep -q "libwayland-client\.so\.0"; then
return 0
fi
local d
for d in /usr/lib /usr/lib64 /usr/lib/x86_64-linux-gnu \
/lib /lib64 /lib/x86_64-linux-gnu; do
[ -e "$d/libwayland-client.so.0" ] && return 0
done
return 1
}
if ! _tc_host_has_wayland_client; then
_TC_APPDIR="${APPDIR:-"$(dirname "$(readlink -f "$0")")/.."}"
export LD_LIBRARY_PATH="${_TC_APPDIR}/usr/lib/wayland-fallback${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
fi
unset -f _tc_host_has_wayland_client
HOOK_EOF
chmod +x "$root/$HOOK"
# AppRun sources each hook by name rather than globbing the directory, so a
# new hook file is inert until AppRun is told about it.
if ! grep -q "triple-c-wayland-fallback" "$root/AppRun"; then
python3 - "$root/AppRun" <<'PATCH_EOF'
import sys
path = sys.argv[1]
src = open(path).read()
exec_line = 'exec "$this_dir"/AppRun.wrapped "$@"'
if exec_line not in src:
raise SystemExit("AppRun does not have the exec line this patch expects")
src = src.replace(
exec_line,
'source "$this_dir"/apprun-hooks/"triple-c-wayland-fallback.sh"\n' + exec_line,
)
open(path, "w").write(src)
PATCH_EOF
fi
# --- metadata -------------------------------------------------------------
# Version comes from the artifact rather than a second source that could drift.
version="$(printf '%s' "$appimage" | sed -n 's/.*_\([0-9][0-9.]*\)_.*/\1/p')"
[ -n "$version" ] || { echo "Could not read a version out of $appimage" >&2; exit 1; }
if [ -f "$appdata_src" ]; then
mkdir -p "$root/usr/share/metainfo"
sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/" \
"$appdata_src" > "$root/usr/share/metainfo/$APP_ID.appdata.xml"
echo "Added AppStream metadata for $version."
else
echo "No AppStream source at $appdata_src — skipping." >&2
fi
# linuxdeploy emits `Categories=` empty, which files the app nowhere.
for desktop in "$root"/*.desktop; do
[ -e "$desktop" ] || continue
if grep -q "^Categories=$" "$desktop"; then
sed -i "s/^Categories=$/Categories=$CATEGORIES/" "$desktop"
echo "Filled in Categories for $(basename "$desktop")."
fi
done
echo "Demoted $LIB to $FALLBACK_DIR; repacking."
tool="$work/appimagetool"
curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL"
chmod +x "$tool"
# --appimage-extract-and-run: CI runners generally have no FUSE.
# -u embeds the update string and writes "$STABLE_NAME.zsync" beside the image.
ARCH=x86_64 "$tool" --appimage-extract-and-run \
-u "$UPDATE_INFO" "$root" "$STABLE_NAME" >/dev/null
chmod +x "$STABLE_NAME"
# The versioned name is what the per-version release publishes; the stable one
# and its .zsync go to the rolling tag. Same bytes, two names.
cp "$STABLE_NAME" "$appimage"
chmod +x "$appimage"
# The guards are the test. Each one is a way the repack could look like it
# worked while shipping the original bug.
( cd "$check" && "$here/$appimage" --appimage-extract >/dev/null )
out="$check/squashfs-root"
fail() { echo "FAILED: $1" >&2; exit 1; }
[ -e "$out/usr/lib/$LIB" ] && fail "$LIB is still on the loader path."
[ -e "$out/$FALLBACK_DIR/$LIB" ] || fail "the fallback copy of $LIB is missing."
[ -e "$out/$HOOK" ] || fail "the fallback hook is missing."
grep -q "triple-c-wayland-fallback" "$out/AppRun" || fail "AppRun does not source the hook."
[ -x "$out/usr/bin/triple-c" ] || fail "no executable usr/bin/triple-c."
# An empty Categories or missing metadata ships an image a manager cannot file
# or describe, and both fail silently at runtime rather than at build time.
grep -q "^Categories=.\+" "$out"/*.desktop || fail "Categories is still empty."
[ -f "$appdata_src" ] && { [ -e "$out/usr/share/metainfo/$APP_ID.appdata.xml" ] \
|| fail "AppStream metadata did not make it into the image."; }
# The update string is the difference between adoptable and updatable. It
# lives in the image's own `.upd_info` ELF section, not in the .zsync — the
# .zsync only records a *relative* filename, which a client resolves against
# the URL it fetched the .zsync from. That is exactly why the output is named
# for the fixed tag: a versioned name here resolves to the build the client
# already has.
[ -e "$STABLE_NAME" ] || fail "the stable-named image is missing."
[ -e "$STABLE_NAME.zsync" ] || fail "appimagetool wrote no $STABLE_NAME.zsync."
readelf -p .upd_info "$STABLE_NAME" 2>/dev/null | grep -q "$UPDATE_TAG" \
|| fail "the image carries no update information for the $UPDATE_TAG tag."
grep -aq "^Filename: $STABLE_NAME$" "$STABLE_NAME.zsync" \
|| fail "the .zsync names something other than $STABLE_NAME."
echo "OK: $appimage prefers the host $LIB (fallback kept), carries AppStream"
echo " metadata, and updates from the $UPDATE_TAG tag via $STABLE_NAME.zsync."
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
#
# Publish the AppImage and its .zsync to the fixed `linux-latest` tag on the
# GitHub mirror — the URL every installed copy checks for updates.
#
# This exists because the update URL has to be one that never moves.
# `releases/latest` does move: it follows whatever release is newest, and the
# Gitea-to-GitHub backfill creates one GitHub release per Gitea tag, including
# the `-win` and `-mac` tags that carry no AppImage. Pointing a million
# installed copies at a URL that can resolve to a release with no AppImage in
# it is a failure that shows up on users' machines and nowhere else.
#
# So this tag holds exactly two files, replaced in place on every release.
# The versioned per-release artifacts are published separately and are what a
# human downloads; this is what the updater reads.
#
# It writes to GitHub rather than Gitea because that mirror is where updates
# are pulled from. Needs GH_PAT with contents write on the mirror.
#
# Usage: GH_PAT=... publish-update-channel.sh <directory holding the artifacts>
set -euo pipefail
REPO="shadowdao/triple-c"
TAG="linux-latest"
API="https://api.github.com/repos/$REPO"
ASSETS=("Triple-C_x86_64.AppImage" "Triple-C_x86_64.AppImage.zsync")
: "${GH_PAT:?GH_PAT is required to publish the update channel}"
dir="${1:?usage: publish-update-channel.sh <artifacts directory>}"
cd "$dir"
for asset in "${ASSETS[@]}"; do
[ -e "$asset" ] || { echo "Missing $asset in $dir" >&2; exit 1; }
done
gh() { curl -sf -H "Authorization: Bearer $GH_PAT" -H "Accept: application/vnd.github+json" "$@"; }
echo "==> Looking for the $TAG release"
release="$(gh "$API/releases/tags/$TAG" 2>/dev/null || true)"
release_id="$(printf '%s' "$release" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))' 2>/dev/null || true)"
if [ -z "$release_id" ]; then
echo "==> Creating it"
# Not a prerelease, but deliberately not the "latest" release either: this
# tag is a channel, and it must never displace the versioned release a
# person lands on from the releases page.
release="$(gh -X POST "$API/releases" -d "$(python3 -c '
import json
print(json.dumps({
"tag_name": "'"$TAG"'",
"name": "Linux update channel",
"body": "Rolling AppImage build that Triple-Cs in-app updater reads. "
"The two files here are replaced on every release; for a specific "
"version, use the versioned releases instead.",
"draft": False,
"prerelease": False,
"make_latest": "false",
}))')")"
release_id="$(printf '%s' "$release" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')"
fi
echo "==> Removing superseded assets from release $release_id"
printf '%s' "$release" | python3 -c '
import sys, json
keep = set(sys.argv[1:])
for a in json.load(sys.stdin).get("assets", []):
if a["name"] in keep:
print(a["id"])
' "${ASSETS[@]}" | while read -r asset_id; do
[ -n "$asset_id" ] || continue
gh -X DELETE "$API/releases/assets/$asset_id" >/dev/null || true
done
for asset in "${ASSETS[@]}"; do
echo "==> Uploading $asset ($(du -h "$asset" | cut -f1))"
curl -sf -X POST \
-H "Authorization: Bearer $GH_PAT" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$asset" \
"https://uploads.github.com/repos/$REPO/releases/$release_id/assets?name=$asset" >/dev/null
done
# The updater is only as good as this URL, and a silent failure here means
# every installed copy quietly stops updating. Confirm both are actually
# fetchable at the address the AppImage was built to check.
echo "==> Verifying the published URLs"
for asset in "${ASSETS[@]}"; do
url="https://github.com/$REPO/releases/download/$TAG/$asset"
code="$(curl -s -o /dev/null -w '%{http_code}' -L "$url")"
[ "$code" = "200" ] || { echo "FAILED: $url returned $code" >&2; exit 1; }
echo " $code $url"
done
echo "OK: $TAG updated."