Compare commits

...
39 Commits
Author SHA1 Message Date
jknapp b59c6148ff Merge pull request 'Read a stopped container instead of claiming there is nothing to read' (#55) from fix/staleness-probe-stopped-container into main
Build App / compute-version (push) Successful in 4s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 3m29s
Build App / build-windows (push) Successful in 5m1s
Build App / build-linux (push) Successful in 5m10s
Build App / create-tag (push) Successful in 7s
Build App / sync-to-github (push) Successful in 1m36s
2026-09-11 03:54:40 +00:00
shadowdaoandClaude Opus 5 95a78fe9a3 Take the review: cache the stopped probe, and never let it cost an answer
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m58s
Build App (Preview) / build-linux (pull_request) Successful in 4m43s
Build App (Preview) / build-windows (pull_request) Successful in 5m9s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Six findings, all real. The one that mattered: `getContainerStaleness` is
called from a `useEffect` that fires whenever the container settles, so
merely opening a stopped project's Overview now committed its whole writable
layer — 44 s on a real project, against ~3 s for the snapshot probe it
replaced. Shipping that would have traded one bad banner for a bad page.

A stopped container's writable layer cannot change, so the probe is exactly
cacheable: `STOPPED_MANIFEST_CACHE` keys on the container's `FinishedAt`,
which moves on every stop. Cold 2967 ms, warm 1 ms, measured. A live test
asserts the restart case as well as the hit, because a cache that failed to
invalidate would plan a migration against a filesystem the project no longer
has — verified by breaking the token and watching that assertion fail.

Skipping the probe for projects that are not stale looked like the cheaper
fix and is unsafe: the deltas would be empty while `probeSettled` stayed
true, and the migrate action in the project menu is not gated on the banner,
so the pre-flight would report nothing to copy while the backend was told to
copy nothing. That is the hazard `canMigrate`'s comment already warns about.
Not done, and written down so it is not tried again.

Also from the review:

- A failed commit no longer costs an answer the snapshot could have given.
  Before this feature a stopped project read its snapshot directly, so
  surfacing this error would have made the banner worse than it was — and
  the failure modes are where the fallback earns its keep: a full disk (the
  commit allocates the whole layer, the snapshot probe allocates nothing)
  and a 409 from a concurrent claim.
- The probe no longer commits while the project is claimed. The collision is
  not symmetric: the probe losing is a retryable `probe_error`, but
  `start_project_container` removes the old container with a hard `?`, so a
  remove that raced a commit would fail the user's Start with an opaque
  error. `stopped_probe_policy` reads `project_lock::held` and probes the
  snapshot instead, or defers with a message that says so.
- The cleanup-failure warning claimed the next probe of the same container
  would reclaim the leftover. Unique names made that false the moment they
  landed; it is `reap_probe_images` that collects it.
- The TS binding still called the command read-only, which is how the
  auto-refresh got added in the first place.
- CLAUDE.md still documented the stable `triple-c-probe-{cid}:latest` name
  this PR removed as unsafe.

548 unit tests, 752 frontend tests, 4 live-Docker tests. Clippy unchanged at
44 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RSaoDLovVV2wmH4H8VVxz
2026-09-10 20:48:10 -07:00
shadowdaoandClaude Opus 5 307ea07409 Read a stopped container instead of claiming there is nothing to read
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m55s
Build App (Preview) / build-linux (pull_request) Successful in 4m56s
Build App (Preview) / build-windows (pull_request) Successful in 5m53s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
A project that was merely stopped reported "This project has no container
or snapshot image yet, so there is nothing to compare against the base
image" — with its container sitting right there — and Update stayed
disabled. Start it and the checks passed, which is the tell: the staleness
probe had only two sources, a *running* container via `docker exec` or the
project's snapshot image.

The snapshot is not a checkpoint. `commit_container_snapshot` runs only
before a container is destroyed (a config-change recreate) or inside a
migration, never on stop, so a project in daily use for a year can have no
snapshot at all — and five of the six projects on the box that reported
this had none. Absence of a snapshot was being read as absence of anything
to inspect.

So probe the stopped container directly: commit its writable layer to a
throwaway image, probe that, drop it. A stopped container now also outranks
the snapshot, for the same reason a running one already did — the snapshot
lags it by everything installed since the last commit. `pick_probe_source`
is the whole decision and is unit-tested; the message it used to emit now
describes only the case it is true of, no container and no snapshot.

Two things found on the way, both documented in CLAUDE.md:

`bollard` never hands back the image id from a commit — its `Commit` model
deserialises "ID" while the daemon sends "Id" — so the probe image has to be
tagged, and a tagged image is dangling-proof and therefore invisible to
`sweep_orphaned_snapshots`, `reap_stale_migration_pins` and
`scrub_secrets_from_snapshots` alike. Without a reaper of its own a crashed
probe would leak a multi-gigabyte image that nothing could ever reclaim, so
`reap_probe_images` runs at startup beside `reap_probe_containers`, age-gated
for the same reason that one is: `reference=` is daemon-wide and a second
instance's live probe matches the glob.

It removes by tag, never by image id: a force removal by id untags an image
everywhere, which is how a first draft of the reaper test deleted an
unrelated `alpine:latest`. Names are unique per call rather than stable per
container, because container ids do not survive a recreate and two
overlapping probes would otherwise fight over one tag.

Verified against the container that reported the bug: 13,365 paths and an
apt delta of cmake, ffmpeg, libobs-dev, qt6-base-dev and nine more — the
migration payload the Update flow could not see. 546 unit tests plus three
live-Docker tests pass; no new clippy warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RSaoDLovVV2wmH4H8VVxz
2026-09-10 19:24:05 -07:00
jknapp 37bbf181c9 Merge pull request 'Give the mouse back, retire the follow controls, update Claude per session' (#54) from feat/mouse-release-retire-follow-update into main
Build App / compute-version (push) Successful in 12s
Secret Scan / scan (push) Successful in 6s
Build App / build-macos (push) Successful in 2m53s
Build App / build-windows (push) Successful in 4m54s
Build App / build-linux (push) Successful in 5m44s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 9s
Build Container / build-container (push) Successful in 17m57s
2026-09-08 23:43:34 +00:00
shadowdaoandClaude Opus 5 5d16b5713d Give BuildKit the host's network, so it can reach the runner's cache
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m45s
Build App (Preview) / build-windows (pull_request) Successful in 4m45s
Build App (Preview) / build-linux (pull_request) Successful in 7m38s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 14m48s
The multi-arch build needs the `docker-container` driver — the plain `docker`
driver cannot do linux/amd64+linux/arm64 — and that driver runs BuildKit in
its own container on Docker's default bridge. act_runner advertises
ACTIONS_CACHE_URL as an address the *job* container can reach, and nothing
teaches the BuildKit container about it. So the job could reach
192.168.1.126:40649 while the container actually making the cache request
could not.

That is also why no other workflow here hit this: it is the only one using
buildx. The rest make their cache calls from the job container act_runner set
up.

`no route to host` is EHOSTUNREACH — a firewall rejecting, not a missing route
— which is what a default firewalld zone does to traffic from the docker
bridge, and the runner registers under the stock RHEL/Fedora hostname.
Sharing the host's namespace sidesteps it: the cache address becomes local to
BuildKit. No effect on runners where this already worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145mQi9NZiCDrznBUEEDE4n
2026-09-08 15:30:27 -07:00
shadowdaoandClaude Opus 5 c02c02cbfc Never fail a container build because the cache was unreachable
Secret Scan / scan (push) Successful in 4s
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 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m52s
Build App (Preview) / build-linux (pull_request) Successful in 8m0s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Build Container / build-container (pull_request) Successful in 10m21s
Every layer of both architectures built. The job then died exporting to
act_runner's emulated GitHub Actions cache service, which it could not route
to: `GetCacheEntryDownloadURL ... dial tcp 192.168.1.126:40649: no route to
host`.

On a pull_request `push:` is false, so this job pushes nothing and the cache
is its only output — which means a network problem between the buildx
`docker-container` builder and the runner host threw away a complete,
successful validation of the Dockerfile on linux/amd64 and linux/arm64. A
cache is an optimisation; it must degrade to "slow", never to "red".

Only the exporter needs the flag. The import is already non-fatal — the build
ran all 37 layers after warning it could not read the cache.

This does not fix the routing itself, so builds stay uncached until that is
sorted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145mQi9NZiCDrznBUEEDE4n
2026-09-08 12:48:41 -07:00
shadowdaoandClaude Opus 5 c0e4c87cec Give the mouse back, retire the follow controls, update Claude per session
Secret Scan / scan (push) Successful in 4s
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 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-linux (pull_request) Successful in 5m8s
Build App (Preview) / build-windows (pull_request) Successful in 6m28s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Build Container / build-container (pull_request) Failing after 14m49s
Three things the terminal was getting wrong.

**A program that grabs the mouse and dies used to freeze the tab.** A TUI sets
DECSET ?1000/?1002/?1003; if it exits without resetting them, xterm keeps
routing clicks, drags and — under ?1003 — every pointer *move* to the PTY.
Text selection dies and escape bytes flood the prompt. The only exit was
closing the tab. `TerminalView` now reconciles a flag against
`term.modes.mouseTrackingMode` in the `term.write()` callback — the mode only
changes because the container printed a sequence, so one check per write
catches every transition with no polling — and `Ctrl+Shift+X` or a status-bar
button writes the resets back through `term.write`, never `sendInput`: the
reset belongs to xterm's parser, and a still-live TUI told about it would just
re-grab on its next repaint.

The control is in the status bar deliberately. Mouse tracking is the *normal*
state of htop, vim, lazygit and Claude Code, so a badge over the terminal
would be on screen for the whole life of those programs and would swallow
clicks aimed at their own top-right corner. `macOptionClickForcesSelection` is
also on now: xterm's force-select is Shift everywhere except macOS, where it
is Option and is gated behind that option, which defaults to false — so until
now Mac users had no way to select text while a program held the mouse.

**"Following" and "Jump to Current" are gone.** Claude Code draws on the
alternate screen, which has no scrollback, so `viewportY` always equalled
`baseY` and neither control could do anything. They did still work in bash
tabs; xterm's native follow covers that, and the per-write `scrollToBottom()`
went with them because it fought exactly that. What remains, on activate and
after a refit, now samples `viewportY >= baseY` *before* the fit, so opening
the Notes dock no longer yanks a reader to the tail.

**`claude update` runs before every Claude session, not just at container
start.** Containers here stop/start and often just keep running, so a
long-lived one never re-checked. Both copies take the same flock: the
entrypoint prints "container ready" only after its own update finishes, so
opening a tab immediately would otherwise run two updaters against the same
~/.claude/bin, with `|| echo` hiding a half-written install one line before
`exec claude` ran it.

This turns the non-Bedrock path from a bare argv into a `bash -c` wrapper, so
flags and session names are shell-interpolated now and must go through
`shell_quote_arg`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145mQi9NZiCDrznBUEEDE4n
2026-09-08 11:02:33 -07:00
jknapp 3aec2998d8 Merge pull request 'Anchor the update channel tag, and stop shipping a duplicate AppImage' (#52) from fix/update-channel-durability into main
Build App / compute-version (push) Successful in 3s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 2m44s
Build App / build-linux (push) Successful in 4m48s
Build App / build-windows (push) Successful in 4m52s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 7s
2026-09-03 16:52:47 +00:00
shadowdao 019fb403d5 Merge remote-tracking branch 'origin/main' into fix/update-channel-durability
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 2m42s
Build App (Preview) / build-linux (pull_request) Successful in 4m51s
Build App (Preview) / build-windows (pull_request) Successful in 4m54s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
2026-09-03 09:41:22 -07:00
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
shadowdaoandClaude Opus 5 d38736007f Take the re-review: distinguish "absent" from "unreachable"
Secret Scan / scan (push) Successful in 3s
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 1s
Build App (Preview) / build-linux (pull_request) Failing after 2m3s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m56s
Build App (Preview) / prune-previews (pull_request) Skipped
Second review of this branch. Two blockers and one real defect I had papered
over with a true-but-misleading claim.

**`make_latest` was missing from the republish path.** The create path sends
`"make_latest": "false"` so the channel cannot displace the versioned release
on the releases page. The reuse path — taken on every run after the first —
omitted it, and the API's documented default for a publish transition is
`true`. So the second release would have quietly promoted `linux-latest` to
the repository's Latest release: a release whose own body says "for a specific
version, use the versioned releases instead". Now sent on both paths.
`tag_name` is re-sent deliberately and now says so in a comment — the API
removes the tag when a PATCH omits it, and this branch exists because a tag
disappeared.

**A transient Gitea error would have cost the whole release.** `curl -sf`
fails identically for "404, the tag is genuinely absent" and "503, Gitea is
briefly unreachable", and both landed in the create branch. Creating a tag that
already exists returns 409, which aborted the last step of `build-linux` — and
`create-tag` and `sync-to-github` both depend on it, so no version tag and no
GitHub sync at all. The failure message also read "the tag does not exist" when
Gitea had merely been unreachable. Now a `case` on the HTTP code — 200 leave
alone, 404 create, anything else fail loudly with the real code — the same
idiom `Upload to Gitea release` already uses two steps above. `422
already_exists` on the release POST is likewise a recoverable answer, not a
reason to lose a release.

**The empty `Categories=` was still shipping, and my claim hid it.** I wrote
that the guard "asserts the absence of an empty value rather than the presence
of any filled one" — true of the regex, false of the artifact. The AppDir root
`.desktop` is a *symlink* into usr/share/applications, so `sed -i` replaced the
link with a regular file and left the real entry empty; the guard globbed the
root only, so it saw the copy it had just written and passed. Verified on the
real artifact: two divergent entries, and the one that shipped was empty. Fixed
with `--follow-symlinks`, both locations globbed, and the guard turned into a
positive assertion over every entry — which also closes its missing-key and
unmatched-glob holes. Both entries now read `Categories=Development;Utility;`.

Also taken: the duplicate-AppImage check moves to a precondition, since as a
post-mortem it let the script repack and overwrite the versioned artifact
before failing, and it silently selected by glob order, i.e. the older version
— it now refuses in under a second; assets are deleted and re-uploaded one at
a time, because deleting both up front left a fresh AppImage with no .zsync if
the second upload failed, which silently stops every client; and the success
line no longer claims a fallback was kept when there was nothing to demote.

Left as informational, with the reasoning recorded rather than acted on:
`--retry-all-errors` retries permanent 4xx (fail-closed, matches the repo's
other upload steps); the release list is unpaginated (a GraphQL lookup by
pending tag name is the durable fix, but 7 releases is decades from the cliff,
and the 422 handling above covers the failure mode); process-substitution
failure is invisible to `mapfile` (fail-closed downstream).

Verified against the real 0.4.19 artifact — happy path, no AppImage, two
AppImages, and an AppDir rebuilt with the bundled library removed. shellcheck
clean at warning level on both scripts. appimagetool now reports the AppStream
metadata found.

Nothing here is CI-proven, and that is worth stating plainly: `build-linux`
fails on this branch before `tauri build` even runs, at "Install frontend
dependencies" with `npm error Cannot read properties of null (reading
'edgesOut')` — confirmed in the logs of jobs 5644 and 5636. Unrelated to this
change and tracked separately, but it means the finalizer has never executed
in CI on either commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-03 09:17:34 -07:00
shadowdaoandClaude Opus 5 63f282bef6 Fix the review findings: never destroy a working anchor
Secret Scan / scan (push) Successful in 4s
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 1s
Build App (Preview) / build-linux (pull_request) Failing after 1m49s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m55s
Build App (Preview) / prune-previews (pull_request) Skipped
An adversarial review of the previous commit found six real problems and
corrected one of my claims. Taking all of it.

**The anchoring could kill the channel it exists to protect.** It did
DELETE-then-POST so the tag would name the current build. If the POST failed
for any transient reason the script aborted having already deleted the anchor a
previous run put there, and the next mirror run pruned GitHub's copy — a
transient Gitea error converting a healthy channel into a dead one, which is
strictly worse than the step not existing. There was also a real window
between the two calls with no tag at all.

The DELETE bought nothing. The update string resolves the tag by *name* and the
assets hang off the release object, so nothing about the channel depends on
which commit the tag points at; moving it changes only the source-zip link.
It existed solely to get past a 409, since Gitea's POST /tags has no force
semantics. Now the tag is created if absent and otherwise left alone, which
removes the window too.

**My "no window where the two disagree" claim was wrong, and it is the third
time in this area I have asserted something I had not established.** The
release POST sets no `target_commitish`, so GitHub creates its tag at its own
default-branch HEAD, not at `GITEA_SHA`; the two agree only because
`sync_on_commit` pushes main minutes earlier. And the DELETE actively created
the window. What the ordering genuinely buys is narrower: if anchoring fails,
the script aborts before creating a GitHub release that would be orphaned.

**Orphaned drafts were invisible to the release lookup.** GitHub demotes a
release to a draft when its tag is deleted, and `/releases/tags/` never returns
drafts — precisely the state every mirror run left behind. The by-tag lookup
reported "absent" while 86 MB drafts accumulated, one per release. The lookup
now reads the authenticated list, republishes the newest, and deletes the rest.

**A guard that could not catch what it named.** The update-info assertion was
a substring match on the tag, so it passed for a wrong host, path, filename or
transport — verified: an `evil.example.com/.../linux-latest/...` string passes
the old check and fails the new one. Now a fixed full-string match.

Also from the review: an absent bundled library no longer exits early, because
that skipped the metadata *and* left `update-channel/` uncreated, killing the
publish step on a missing directory and taking the tag and mirror jobs with it;
the Categories guard asserts the absence of an empty value rather than the
presence of any filled one; the channel directory is cleared before use so a
stale zsync cannot satisfy an existence check while describing the previous
build; the AppImage count uses a glob array, since `ls | wc -l` aborted under
pipefail before the message it promised could print; uploads carry the
retry/http1.1 hardening this repo's other upload steps already learned to
need; verification compares served size against built size, because a status
code only proves something is served; and the release workflow now fails on
empty artifacts instead of publishing a release with no AppImage.

The metainfo file is installed as `Triple-C.appdata.xml`. appimagetool derives
the name it looks for from the .desktop basename, so under the id-based name it
warned the metadata was missing on every build while this script reported it
present. Now it prints "AppStream upstream metadata found in
usr/share/metainfo/Triple-C.appdata.xml" — the AppStream id inside the file is
unchanged and is what identifies the component.

Two review hypotheses did not hold and nothing was changed for them: `set -e`
does not abort on a failing `&&` list mid-script, and my claim of a `trap`
reassignment was wrong — there is one trap, installed once.

Verified against the real 0.4.19 artifact: exit 0, one AppImage beside the
release, channel pair in its own directory, appimagetool reporting the metadata
found, and the wayland fallback intact. Guards exercised individually — the
duplicate one bites, the exact-match one rejects an impostor carrying the tag,
the empty directory reports cleanly, and all four publisher preconditions
refuse rather than half-publishing. Header parsing for the size check was
tested against a real redirecting GitHub asset URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-03 08:53:13 -07:00
shadowdaoandClaude Opus 5 d561ce03d5 Anchor the update channel tag, and stop shipping a duplicate AppImage
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 1s
Build App (Preview) / build-linux (pull_request) Failing after 1m49s
Build App (Preview) / build-macos (pull_request) Successful in 2m57s
Build App (Preview) / build-windows (pull_request) Successful in 16m16s
Build App (Preview) / prune-previews (pull_request) Skipped
Two defects in the update channel, both visible in 0.4.20 and 0.4.21.

**The channel tag does not survive.** `publish-update-channel.sh` created the
GitHub release, uploaded both assets and verified each URL returned 200 — the
job log shows it succeeding at 00:38. By 13:04 the tag was gone and every
installed copy was checking a 404.

Gitea push-mirrors this repo to GitHub every four hours, and a mirror push
deletes remote refs with no local counterpart. `linux-latest` was created by
GitHub's release API and never existed as a Gitea tag, so the mirror removed
it. Versioned tags were never affected because `create-tag` creates them in
Gitea first.

So the tag is now anchored in Gitea, and before the GitHub release rather than
after, so there is no window where the two disagree. Its absence fails the
step instead of warning, because it is the only thing keeping the channel
alive. Worth stating plainly: publishing correctly is not evidence the channel
still works, and the verification that passed at 00:38 could not have caught a
failure that arrives twelve hours later.

**Every release carried the AppImage twice.** The channel's stable-named copy
sat beside the versioned one, where the release job's `*.AppImage` glob picked
it up — so v0.4.21 published `Triple-C_0.4.21_amd64.AppImage` and
`Triple-C_x86_64.AppImage`, byte-identical at 86,686,200 bytes each, and
`sync-to-github` copied both to the mirror. 80 MB of duplicate per release,
under a name that reads like a different build. That is how it was noticed.

The channel pair now lives in `bundle/appimage/update-channel/`, out of the
glob's reach, and a guard fails the build if more than one AppImage is left
beside the release. Verified by planting a second one: it fails.

One appimagetool quirk found while moving it — zsyncmake writes the .zsync
into the working directory, not beside the image it describes, so it has to be
collected rather than assumed in place. The existing guard caught that too.

Verified against the real 0.4.19 artifact: exactly one AppImage at top level,
the channel pair in its own directory, update string still resolving to the
fixed tag, and the wayland fallback intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-03 08:29:58 -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
shadowdaoandClaude Opus 5 0f3fff92f4 Lay the notes panel out by its own width, not the window's
Secret Scan / scan (push) Successful in 5s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m45s
Build App (Preview) / build-windows (pull_request) Successful in 4m58s
Build App (Preview) / build-linux (pull_request) Successful in 5m13s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
The panel splits master/detail unconditionally: a 192px title column beside
the editor. That fits the Project Home tab and does not fit the dock. At the
dock's 352px default the editor gets 157px, and its action row wants ~200px,
so the Delete button lands outside the dock's `overflow-hidden` with no
scrollbar to reach it, and the textarea collapses to a two-word column.

The two surfaces differ in width while sharing a viewport, so this is a
container query rather than a `md:` breakpoint — a viewport query reads the
window and hands both surfaces the same answer, which is wrong for one of
them. Tailwind v4 has these in core; verified as real
`@container (min-width: 32rem)` rules in the built CSS, since a variant that
silently compiles to nothing looks identical in review.

The threshold is arithmetic: side by side needs the 192px list, an editor
wide enough for its own buttons (~280px), and the divider. `@lg` (512px) is
the first stop clearing ~473px. Below it the titles become a capped strip
above the editor, so the note being written keeps the height.

The action row now wraps, which is the part that holds at *any* width rather
than on one side of a threshold: the buttons are a group that does not shrink,
the title field shrinks to 96px, and past that the title takes one row and the
buttons the next. Nothing can be pushed out of the panel.

Not covered by the suite — jsdom has no layout engine, so 711 tests pass
before and after. This needs eyes on the dock at its minimum, default and
maximum widths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-02 11:34:02 -07:00
shadowdaoandClaude Opus 5 2708772bf9 Order the notes cache by sequence, not by who resolves last
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 4s
Build App (Preview) / build-macos (pull_request) Successful in 2m48s
Build App (Preview) / build-linux (pull_request) Successful in 6m2s
Build App (Preview) / build-windows (pull_request) Successful in 6m4s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
One gesture puts two requests in flight. With the tab already loaded, clicking
the dock toggle while the textarea has focus fires `blur` -> `saveNote` and the
dock's mount -> `list_notes` in the same tick. The save finishes and its re-read
writes the post-save list; the mount's read -- issued earlier, still out -- then
lands its pre-save snapshot on top, and both panels show stale text until
something else refreshes.

`mutationChains` could not have caught this: it orders a project's writes
against each other and the mount load is a read outside it. Putting the read on
the chain would work, but it buys correctness with latency the user feels -- a
panel mount waiting behind `save_note`'s double-fsync write -- and leaves a
"mutation chain" holding reads.

The two requests are not competing for a resource; the loser's result is simply
older. So every write into `notesByProject[p]` now claims a per-project sequence
when the request behind it is issued, and `commitNotes` drops one whose sequence
predates what is already cached. Reads take their sequence at issue time, since
being ordered by resolution is the bug. Local patches -- the filter behind a
confirmed delete, the prepend behind a failed re-read -- take a fresh one at
commit time, because they are authoritative then rather than derived from an
earlier read, and anything still in flight behind them is genuinely stale. A
failed read commits under its *own* sequence, not a fresh one, so its empty list
cannot beat a later read that has the real answer.

`isCurrent()` stays, and is not folded in. It guards `setSaveState`, not the
cache: it asks whether this *panel* is still showing the project a save was made
for, which a per-project counter cannot answer -- two panels on one project share
every sequence value. Ordering and panel identity are two questions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
2026-09-01 14:23:55 -07:00
shadowdaoandClaude Opus 5 436b6dd470 Send a lone CR through the newline transform, and say what pinned is
`toClaudePayload` matched `/\r?\n/`, so a bare CR that is not part of a
CRLF went through verbatim — and a bare CR *submits* in a Claude prompt
and *runs* the line in a shell, which is the terminator the function's
own contract says it never appends. A `<textarea>` cannot produce one,
but `load_in` returns whatever a hand-edited or externally written notes
file holds, so the guarantee has to cover that rather than only what the
editor can type.

`Note.pinned` is persisted and sorted on, but nothing in the app sets
it: there is no pin control and no indicator. The spec stated the
ordering rule as though pinning existed and §8 did not list it, so the
spec is amended to say `pinned` is reserved and inert in v1, and pinning
is added to the out-of-scope list. No UI is added — a user-facing
affordance does not belong in a fix wave.

Also renamed NotesPanel.test.tsx's "deletes the selected note and falls
back to another": `useNotes` is mocked in that file and the mocked list
never changes, so the fallback was never exercised. A name that claims
coverage which is absent is worse than an absent test, because it makes
the gap invisible. The real assertion now lives against the real hook in
NotesPanel.shared.test.tsx.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
2026-09-01 13:47:01 -07:00
shadowdaoandClaude Opus 5 5c47656444 Cache notes in one place, and serialise a project's writes
Implements the design spec's §2 — "notes cached in zustand keyed by
project id" — which the plan substituted with a hook-local `useState`.

Sharing the `NotesPanel` *component* between the Project Home sub-tab and
the dock did not share the *cache*. Both resolve to the same project, so
two panels mount two `useNotes(P)`, each with its own list. Edit a note
in the dock and blur; the tab's copy is still pre-edit, and the tab's
next blur commits `{...staleRecord, title, body}` — the dock's edit gone
from disk with no error and no indicator. That is the feature's own
primary workflow: take notes in the dock while the agent runs, which is
the reason the dock exists, then go back to the tab.

`notesByProject` plus a per-project in-flight flag now hold the list.
Both surfaces render from one array; two panels mounting for one project
make one read; and because the write is keyed by project, a response
that lands after the user has moved on updates the project it belongs to
rather than whichever is on screen. This is also the boundary §8 says a
detached notes window needs.

Three more bugs in the same code, fixed with it:

- Delete-after-edit could resurrect the note. Clicking Delete with the
  textarea focused fires blur first, so `save_note` and `delete_note` go
  out back to back; Rust's `write_lock` stops them interleaving but does
  not order them, and a delete that wins the lock is undone by the
  upsert behind it. A project's mutations now go through one promise
  chain, module-scoped for the reason `useTerminal`'s input queue is.
- An unsaved draft vanished when any other note was saved, because the
  re-read replaced the list with the backend's. "New note" now persists,
  so the backend owns the row from the start — chosen over merging local
  drafts because a local-only row in a *shared* cache would exist in the
  panel that made it and nowhere else.
- The save outcome was reported for the wrong project after a switch:
  the guard covered only the list replacement, so the new project's
  SaveIndicator flashed "Saved ✓" for the old project's write. The
  indicator now resets on a project change and reports only its own.

`NotesPanel` also re-seeds its draft when the *stored* text of the note
it has selected changes, so an edit made in the other surface reaches
the editor and not only the list. It never overwrites something
half-typed; that still blurs into a last-writer-wins save, as any
blur-commit editor does.

NotesPanel.shared.test.tsx is the configuration none of the existing
tests had: two panels, one project, the real hook. Four of its six
assertions fail against the previous implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
2026-09-01 13:47:01 -07:00
shadowdaoandClaude Opus 5 be47c5edfd Cap the corrupt-notes copies and put a version envelope on disk
Two things the plan dropped from the design spec's §1.

`keep_corrupt_copy`'s only guard was "does this second's copy already
exist", so a persistently unparseable file minted a full copy of the
user's prose every time the clock ticked over — and `list_notes` runs on
*every* NotesPanel mount, i.e. every project switch, every
dock-follows-tab change, every sub-tab toggle. A minute of clicking
between two projects was ~60 copies. `MAX_CORRUPT_BACKUPS`,
`corrupt_backups_full()` and the three-outcome `Kept` enum come across
from `migration_store` whole, including the reason the cap is asked
*before* the copy (so it is not implemented by writing a file and
deleting it again, and so the surviving copies are the oldest ones) and
the reason the log line must not claim a backup that was never written.

The file itself is now `{ version, notes }` rather than a bare array.
It costs nothing today and gets permanently more expensive once files
exist in the field. No released build has written notes, so there is no
migration path — but a bare array is still *read*, because declaring a
perfectly readable file corrupt is the one outcome this store exists to
avoid, and a developer's own notes are prose nothing else has a copy of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
2026-09-01 13:40:40 -07:00
shadowdao 037ed78570 Test the dock's load-path clamp and keyboard resize direction
- notesDockWidth store initialization now clamps/defaults a bad
  localStorage value on load, not just on write (verified this fails
  without the clamp).
- The keyboard resize test asserts the exact widened/narrowed value
  instead of just that the setter was called, so a swapped or
  inverted arrow-key branch would be caught.
2026-09-01 13:22:35 -07:00
shadowdao 31e8f9df5f Add the notes dock 2026-09-01 13:15:13 -07:00
shadowdao 3704064006 Add the Notes tab 2026-09-01 13:08:17 -07:00
shadowdao f79a44e0a8 Add the send-to-agent button 2026-09-01 13:02:22 -07:00
shadowdao 5a8e24ccbe Extract the Claude newline sequence and the session display name 2026-09-01 12:57:29 -07:00
shadowdao a1f4eee9a3 Fix critical cross-project data corruption bug in useNotes hook
When a save is in flight for project A and the user switches to project B before it resolves, the stale closure still has projectId=A. When A's save resolves, the post-save re-read of listNotes(projectId) runs with the stale closed-over projectId, and setNotes(reloaded) overwrites B's displayed notes with A's list—the same cross-project contamination class as Finding 2 but reintroduced through the fix itself.

Fix: Add a currentProjectId ref updated on every render, and guard both saveNote and deleteNote callbacks with a check before replacing/filtering the whole list. If the project changed while the async operation was in flight, bail out of the state update but still report success (the operation itself succeeded on the backend; only the stale list update is skipped).

Added test: a save in flight for one project, a switch to another, then the first save resolving—asserts the second project's notes are still displayed.
2026-09-01 12:50:23 -07:00
shadowdao b6ba6deb09 Fix critical data corruption and stale-data bugs in useNotes hook
- Finding 1 (saveNote): After a successful save, re-read the canonical list from the backend instead of patching in place. A successful save stamps a new updated_at, and the backend sorts by updated_at descending, so the record's position has changed and positional patching would disagree with what a reload would show. If the re-read fails, keep the save reported as successful and leave the existing list alone.

- Finding 2 (stale notes): Clear notes on projectId change (not only when empty) and on load failure. Previously, switching from project A to project B would leave A's notes on screen until B's fetch resolved, and if a user edited one, A's note would be written into B's notes file—cross-project data corruption. If a load fails, A's notes stay visible under B indefinitely.

- Added four new tests covering these scenarios: projectId change clears old notes, failed load leaves no stale notes, saving a new note ends with the backend's list, and saves re-read the list rather than patching.
2026-09-01 12:45:35 -07:00
shadowdao cd3160b1cd Add the notes hook and its IPC wrappers 2026-09-01 12:38:33 -07:00
shadowdao 60abff1717 Expose notes over IPC and drop them with the project 2026-09-01 12:34:35 -07:00
shadowdao cc767bd544 Add a per-project notes store 2026-09-01 12:28:09 -07:00
shadowdaoandClaude Opus 5 221e7566c3 Plan the project Notes implementation
Seven tasks, each ending in a testable deliverable: the store, the IPC
surface, the hook, the two shared helpers, the send button, the tab, and
the dock.

Two extractions are folded in rather than left for later, both because
this feature would otherwise duplicate knowledge that is already written
down. `\x1b\r` becomes `lib/claudeInput.ts` so the hard-won comment in
`TerminalView` stays the single source of truth for a sequence that must
never be "simplified" to `\n`. The session display-name rule becomes
`lib/sessionName.ts`, which is a fix rather than a precaution: the rule is
currently written twice inside `MainTabs.tsx`, both copies local and
non-exported, and the send-target picker would have made three.

The spec is also corrected in three places against what the code actually
does. `migration_store` is a free-function module with no struct, so the
notes store is too, and the "read-modify-write under the store's Mutex"
line described a shape that file does not have — the upsert takes an
explicit process-wide write lock instead, and the read path takes none.
`useProjectSave` has no debounce; its only timer is a 2500 ms reset of the
"Saved" label. And the storage section now specifies the durable write
`migration_store` uses — fsync the file, rename, fsync the directory —
rather than `projects_store`'s bare rename, because notes are prose
nothing else holds a copy of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
2026-09-01 12:00:52 -07:00
shadowdaoandClaude Opus 5 e58e2cdaf7 Design a per-project Notes tab with a send-to-agent action
Notes are discrete, addressable items with a button that puts one into a
running Claude session's prompt. That is deliberately not what
`claude_instructions` does — that field is *ambient*, merged into the
container's CLAUDE.md on every start and always in context. Nor is it a
`NOTES.md` in the workspace, which the agent can read but the user cannot,
once the container is stopped. Discrete items, fired on demand, readable
with the container down, is the gap neither of those covers.

Storage is one file per project under the app data dir, following
`migration_store.rs` rather than living on the `Project` record: that record
is rewritten on every blur by the debounced save path, so notes there would
mean the whole project list is rewritten per keystroke-batch and a note edit
could clobber a Config edit. `migration_store.rs` already documents that
reasoning for itself.

Two findings are worth more than the design they support.

**Newlines already have a verified answer.** A note body has newlines; typed
as raw keystrokes each one submits a separate prompt, so a note would arrive
as N truncated messages. `TerminalView.tsx` already sends `\x1b\r` for
Shift+Enter and its comment states those are the in-band bytes, not a guess,
with an explicit warning against simplifying to `\n` because a shell would
run the line. Send-to-agent reuses that sequence through one shared helper,
and — from the same comment — only offers `claude` sessions as targets,
since bash's readline has no binding for it and merely bells.

**The dock cannot widen the OS window.** A throwaway Tauri app was built and
run on KDE Plasma to find out, because the app has no window-geometry code to
reason from. Under XWayland every test passed exactly. Under native Wayland
the same binary asked +420 and got +600, moved the height +276 without being
asked, compounded that offset on every call, and ended reporting 5400x2900 on
a 4800x2700 monitor. Worse, `outer_position()` did not fail — it returned
`Ok(0,0)` for a window that was not at 0,0, so a "cannot determine position,
do not grow" fallback never fires. A clean failure could have been handled; a
plausible wrong answer cannot be detected from the value itself.

AppImages get XWayland because linuxdeploy-plugin-gtk forces GDK_BACKEND=x11;
the .deb and .rpm do not. The split is therefore by *packaging*, not platform
— two users on identical hardware would see different behavior. So the dock
takes space inward on every backend, which also costs nothing: the
ResizeObserver in `TerminalView.tsx` already reflows xterm and resizes the
container PTY on width change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
2026-09-01 11:48:31 -07:00
61 changed files with 8558 additions and 268 deletions
+43 -7
View File
@@ -299,8 +299,34 @@ jobs:
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules package-lock.json
npm install
# `npm ci` — from the lockfile, never resolving afresh.
#
# 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
working-directory: ./app
@@ -319,14 +345,22 @@ jobs:
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
run: |
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
run: |
mkdir -p artifacts
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/rpm/*.rpm artifacts/ 2>/dev/null || true
ls -la artifacts/
# Assets, not workflow artifacts — see the note at the top of this file.
@@ -418,8 +452,10 @@ jobs:
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules
npm install
# `npm ci` here too, so all three platforms install identically and
# 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
working-directory: ./app
+70 -7
View File
@@ -172,8 +172,34 @@ jobs:
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules package-lock.json
npm install
# `npm ci` — from the lockfile, never resolving afresh.
#
# 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
working-directory: ./app
@@ -185,16 +211,38 @@ jobs:
working-directory: ./app
run: |
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
run: |
mkdir -p artifacts
# The versioned AppImage only. The update channel's copy lives in
# bundle/appimage/update-channel/ precisely so this glob cannot pick
# it up and publish an 80 MB duplicate under a second name.
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/rpm/*.rpm artifacts/ 2>/dev/null || true
ls -la artifacts/
# A green job that published nothing is the worst outcome available:
# the release exists, carries no AppImage, and nobody is told. The
# `|| true` above is there so a missing bundle does not mask the real
# error, which makes this check the thing that catches it.
shopt -s nullglob
collected=(artifacts/*)
if [ ${#collected[@]} -eq 0 ]; then
echo "No artifacts collected — the bundler produced nothing." >&2
exit 1
fi
- name: Upload to Gitea release
if: gitea.event_name == 'push'
env:
@@ -270,6 +318,19 @@ jobs:
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
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 }}
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
GITEA_SHA: ${{ gitea.sha }}
run: |
bash scripts/publish-update-channel.sh \
app/src-tauri/target/release/bundle/appimage/update-channel
build-macos:
runs-on: macos-latest
needs: [compute-version]
@@ -325,8 +386,10 @@ jobs:
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules
npm install
# `npm ci` here too, so all three platforms install identically and
# 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
working-directory: ./app
+38 -1
View File
@@ -28,6 +28,27 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
# Put BuildKit in the host's network namespace so it can reach
# act_runner's cache service.
#
# The `docker-container` driver — which the multi-arch build below
# requires, since the plain `docker` driver cannot do
# linux/amd64+linux/arm64 — runs BuildKit in its *own* container on
# Docker's default bridge. act_runner advertises ACTIONS_CACHE_URL as
# an address the *job* container can reach, and nothing teaches the
# BuildKit container about it: the job could reach
# 192.168.1.126:40649 while the container actually making the request
# could not, and the build died with `no route to host`.
#
# `no route to host` is EHOSTUNREACH — a firewall rejecting, not a
# missing route (a wrong address times out instead) — which is what a
# default firewalld zone does to traffic arriving from the docker
# bridge. Sharing the host's namespace sidesteps the question
# entirely: the cache address becomes local to BuildKit.
#
# No effect on runners where this already worked.
driver-opts: network=host
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
@@ -55,5 +76,21 @@ jobs:
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
ghcr.io/shadowdao/triple-c-sandbox:latest
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
# `ignore-error` is what stops a cache failure failing a build that
# already succeeded. act_runner emulates the GitHub Actions cache
# service on the runner host's LAN address, and the `docker-container`
# builder `setup-buildx-action` creates could not route to it —
# every layer of both arches built, then the job died on
# `GetCacheEntryDownloadURL: no route to host` while exporting.
#
# On a pull_request `push:` above is false, so this job pushes
# nothing and the cache is its only output: failing it discarded a
# complete, successful validation of the Dockerfile for both
# architectures. A cache is an optimisation and must degrade to
# "slow", never to "red".
#
# The import is already non-fatal — the build ran all 37 layers after
# warning that it could not read the cache — so only the exporter
# needs the flag.
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=max,ignore-error=true
+97 -2
View File
@@ -413,6 +413,26 @@ container is created once by a very long function where a dropped capability is
existing toggle: the label fingerprints *the setting*, not the set of things the setting drives,
so a project already at `true` gets no recreation at all on upgrade.
### Keeping Claude Code current
`claude update` runs in **two** places, and both are needed:
- `container/entrypoint.sh` runs it once per container start, before any session exists.
- `commands/terminal_commands.rs` (and its twin in `web_terminal/ws_handler.rs`) prepend it to the
command every Claude session launches with, because containers use a stop/start model and a
long-lived one would otherwise never re-check.
Both are `timeout`-bounded and `|| echo`'d, so an offline or slow network delays a tab rather than
failing it, and **both take the same `flock` on `/tmp/.triple-c-claude-update.lock`**. That lock is
not tidiness: the entrypoint prints "container ready" only after its own update finishes, so
starting a project and immediately opening a tab — or opening two tabs at once — otherwise runs two
updaters against the same `~/.claude/bin`, and `|| echo` would hide a half-written install behind a
friendly message one line before `exec claude` ran it. `-E 0` makes losing the race a success,
because the holder just did the work. The per-session copy is what forced the non-Bedrock path from a bare `["claude", ...]`
argv into a `bash -c` wrapper — the flags and the session name are interpolated into a shell
string now, so **anything added there must go through `shell_quote_arg`**. Bash sessions are
deliberately untouched.
### Container Lifecycle
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
@@ -436,6 +456,63 @@ security update. Migration is the non-destructive way out; Reset is the destruct
bump: churn on the old base, and it would consume the "you should migrate" signal without
migrating. `get_container_staleness` surfaces it; `migrate_project_to_base` acts on it.
- **A missing lineage label means "unknown, probe instead", never "stale".**
- **The snapshot image is not a checkpoint — never read its absence as "nothing to inspect".**
`commit_container_snapshot` runs only before a container is destroyed (a config-change recreate)
or inside a migration. **Never on stop.** So a project in daily use for a year can legitimately
have no `triple-c-snapshot-{id}:latest` at all, and one that has is stale by everything installed
since. `pick_probe_source` therefore reads a *stopped* container directly — commit its writable
layer to a unique `triple-c-probe-*` image, probe that, drop it — and ranks it **above** the snapshot,
for the same reason a running container already outranked it. Assuming a snapshot existed is what
made a stopped, never-recreated project report "no container or snapshot image yet" with its
container sitting right there, and left Update disabled on the projects furthest behind.
- **`bollard` never gives you the image id back from a commit.** Its `Commit` response model
deserialises `"ID"`; the daemon sends `"Id"`, so `commit_container` returns `id: None` every time
(verified: bollard 0.18.1, Engine 29.6). Neither long-standing commit site notices because both
discard the response — but it means any commit you need a *reference* to has to be **tagged**.
- **A tagged leftover is the one orphan no sweep can reach, so the probe image has its own reaper.**
`sweep_orphaned_snapshots` collects `dangling` + `triple-c.managed=true`; `reap_stale_migration_pins`
and `scrub_secrets_from_snapshots` both filter `triple-c-snapshot-*`. A `triple-c-probe-*` image is
tagged and so matches none of them, which would make a crashed probe a permanent multi-gigabyte
leak with no UI to find it. `reap_probe_images` runs at startup beside `reap_probe_containers` and
is **load-bearing, not tidying** — it is also what makes the probe image's unscrubbed writable
layer acceptable. Two rules it earned the hard way:
- **Age-gate it** (`PROBE_REAP_MIN_AGE_SECS`, same as the container reaper). `reference=` is
daemon-wide, so a second copy of the app has live probe images matching the glob.
- **Remove by tag, never by image id.** A `force` removal by id untags an image *everywhere*; a
fixture that tagged `alpine:latest` into this namespace deleted the user's alpine that way.
- **Probe image names are unique per call, and must stay that way.** A stable per-container name was
tried: container ids do not survive a recreate, so most leftovers were stranded permanently, and
two concurrent probes fought over one tag — whichever finished first force-removed the image the
other was still reading, reporting a bogus `probe_error` on a healthy project. `get_container_staleness`
takes no `project_lock` claim (the migration banner needs it to answer *during* a migration), so
uniqueness is what makes overlapping probes safe.
- **The stopped-container probe is cached per stop, and that is not an optimisation you may drop.**
`getContainerStaleness` is called from a `useEffect` that fires whenever the container settles, so
merely opening a stopped project's Overview probes it. Uncached that is a `docker commit` of the
whole writable layer per visit — measured at 44 s on a real project, against ~3 s for the snapshot
probe it replaced. `STOPPED_MANIFEST_CACHE` is keyed on the container's `FinishedAt`, which is
exact rather than merely plausible: nothing can write to a stopped container's writable layer, and
`FinishedAt` moves on every stop. A live test asserts the restart case, because a cache that
failed to invalidate would plan a migration against a filesystem the project no longer has.
- **Do not "skip the probe when the project is not stale" to save that cost.** It was tried. The
deltas would be empty while `probeSettled` (`!probing && staleness && !probe_error`) stayed *true*,
which leaves the migrate action in the project menu enabled — that action is not gated on the
banner — so the pre-flight would report nothing to copy while the backend was told to copy
nothing. That is the exact hazard `ProjectHome.tsx`'s `canMigrate` comment already warns about.
- **A failed stopped-container probe falls back to the snapshot whenever one exists.** Before this
feature a stopped project read its snapshot directly, so surfacing a commit failure where the
snapshot could have answered would make the banner *worse* than it was — and the failure modes are
exactly the ones where the fallback earns its keep: a full disk (the commit allocates the whole
writable layer; the snapshot probe allocates nothing) and a 409 from a concurrent claim.
- **`get_container_staleness` never commits while the project is claimed.** It takes no
`project_lock` claim itself, deliberately — the banner has to answer *during* a migration — so it
reads `project_lock::held` instead and probes the snapshot rather than the container. The
collision is not symmetric: the probe losing is a retryable `probe_error`, but
`start_project_container` removes the old container with a hard `?`, so a remove that raced a
commit would fail the user's Start with an opaque error.
- **An image's `Created` is the image's own, not its tag's.** Tagging an existing image gives you
that image's age; BuildKit stamps `docker build` output with a fixed epoch. Only `docker commit`
stamps *now* — which is what real probe images do, and what any fixture for them must do.
- **`:latest` keeps pointing at the old lineage until the final commit.** That is what makes every
crash before that point self-heal — `start_project_container` just recreates from the old
snapshot. After the container swap, the new container's `triple-c.migration-state=in-progress`
@@ -679,8 +756,26 @@ deliberately out of scope — this is not a project backup.
## Packaging
Linux ships as `.deb`, `.rpm` and AppImage, all three built by `build-app.yml` (releases) and
`build-app-preview.yml` (the PR check). **There is deliberately no Arch package.** A
Linux ships as **AppImage only**, built by `build-app.yml` (releases) and
`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
`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
+32 -8
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. |
| **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` |
| **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. |
| **Linux (all distributions)** | `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`.
> **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.
### Adding an AppImage to the app launcher
@@ -241,7 +243,7 @@ Anthropic-backend project uses that token without its own login. See
│ │ │ │ │
│ │ └──────────────────────────────────────────────────┘ │
├─────────────┴────────────────────────────────────────────────────────┤
│ 2 project(s) · 1 running · 2 terminal(s) Jump to Current ↓
│ 2 project(s) · 1 running · 2 terminal(s) Notes
└──────────────────────────────────────────────────────────────────────┘
```
@@ -266,8 +268,8 @@ Anthropic-backend project uses that token without its own login. See
- **Main area** — Shows the active tab: a Project Home view or an xterm.js terminal. With no tabs
open you get a welcome screen with Docker/image/project readiness checks.
- **StatusBar** — Counts of total projects, running containers and open terminal sessions; the
**Jump to Current ↓** button when a terminal is scrolled up; and the microphone button when
speech-to-text is enabled.
**🖱 Mouse captured — release** button while a program in the terminal is holding the mouse; the
**Notes** toggle; and the microphone button when speech-to-text is enabled.
---
@@ -1222,9 +1224,31 @@ Programs inside the container can copy text to your host clipboard. When a conta
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container as `/tmp/clipboard_<timestamp>.png` and the file path is injected into the terminal input so Claude Code can reference it. A toast notification confirms the upload.
### Jump to Current
### Scrolling
When you scroll up in the terminal to review previous output, a **Jump to Current** button appears in the bottom-right corner. Click it to scroll back to the latest output.
Scrolling is the terminal's own: scroll up to read back and it holds position, scroll to the
bottom and it follows new output again. There is no follow toggle — an earlier **Following /
Paused** control and a **Jump to Current** button were retired once they stopped doing anything
useful, because Claude Code draws its interface on the alternate screen, which has no scrollback
for them to act on.
### When the mouse stops working
Some programs ask the terminal for the mouse, so that clicks and drags go to the program instead
of selecting text. If one of them exits without handing the mouse back, the terminal looks stuck:
you cannot select text, and stray characters can appear as you move the pointer.
A **🖱 Mouse captured — release** button appears in the status bar whenever a program holds the
mouse. Click it, or press **Ctrl+Shift+X**, to take the mouse back. Nothing is sent into the
container — only the terminal's own state is reset.
Note that holding the mouse is normal for programs like `htop`, `vim` and Claude Code itself, so
the button is showing most of the time you are in one. It is there for when a program exits
without handing the mouse back and the terminal is left stuck; releasing while a program is still
running just takes the mouse away from that program.
To select text *without* taking the mouse back, hold **Shift** while dragging — or **Option** on
macOS.
### Files
+1 -1
View File
@@ -528,7 +528,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering |
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic |
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Notes toggle, STT mic |
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
+1 -1
View File
@@ -58,7 +58,7 @@ choice it never asked about.
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
container start, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
container start *and* before every Claude session launches, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
file drag-drop, STT), the web terminal, and workspace backup.
---
+6 -3
View File
@@ -62,10 +62,13 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
Implementation gotchas for the terminal view and its global controls (merged in PR #7, `terminal-layout-statusbar`):
- **xterm padding lives on a wrapper, never the host.** FitAddon measures the same element that `term.open()` mounts into, so any padding on that host element makes the grid overhang and clip its rightmost column / bottom row. Padding must live on a **wrapper `div`**; the xterm host fills it with no padding of its own. Do not reintroduce padding on the host element in `TerminalView.tsx`.
- **STT mic and "Jump to Current" live in the global `StatusBar`, not per-terminal overlays.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
- **The STT mic lives in the global `StatusBar`, not a per-terminal overlay.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
- **Recording is pinned to where it started.** The STT transcript targets `recordingSessionIdRef` (the session recording began in), **not** the live active session — switching tabs mid-recording must not misroute the transcript.
- **"Jump to Current" state is written only by the active terminal.** The active `TerminalView` surfaces `terminalAtBottom` and `scrollActiveToBottom` through the store; only the active terminal writes them, and they are cleared on its unmount.
- **Set store function values via object-merge, not the updater form**`set({ fn: value })`, not `set(state => ...)`when publishing action callbacks (like `scrollActiveToBottom`) into the Zustand store.
- **Scrolling is left to xterm, and the "Following" / "Jump to Current" controls that used to drive it are gone.** They were built for the normal buffer. Claude Code draws on the *alternate* screen, which has no scrollback, so in a Claude tab `viewportY` always equalled `baseY`, `isAtBottom` was permanently true and neither control could ever do anything — which is what made them look broken. **They did still work in `bash` tabs**, which run `bash -l` on the normal buffer; removing them is a real behaviour change there, and the justification is that xterm's native follow already covers it, not that nothing was lost. The manual `scrollToBottom()` on every write went with them — it fought that native behaviour, which follows the tail while the viewport is at the bottom and holds position while you read further up. `scrollToBottom()` remains only on activate and after a refit, and **both sample `viewportY >= baseY` before the `fit()`** so they re-anchor only a viewport that was already on the tail: the ResizeObserver fires for the Notes dock, the sidebar drag and any window resize, none of which are a reason to yank a reader to the bottom.
- **A program that grabs the mouse and dies must be escapable without closing the tab.** A TUI sets DECSET `?1000`/`?1002`/`?1003` and, if it exits without resetting them, xterm keeps routing clicks, drags and (under `?1003`) every pointer *move* to the PTY — text selection dies and escape bytes flood the prompt. `TerminalView` reconciles a badge against `term.modes.mouseTrackingMode` **in the `term.write()` callback**: the mode only changes because the container printed a sequence, so one check per write catches every transition with no polling. Releasing writes the resets through `term.write`, **never `sendInput`**the reset belongs to xterm's parser and must not reach the container, or a still-live TUI would simply re-grab the mouse on its next repaint. Bound to the control and to `Ctrl+Shift+X`, because the failure being recovered from is the pointer not working.
- **The release control lives in the `StatusBar`, not over the terminal.** Mouse tracking is the *normal* steady state of every mouse-driven TUI — htop, vim, lazygit and Claude Code all set `?1000`/`?1002` — so a badge painted at `absolute top-2 right-4 z-50` would be on screen for the entire life of those programs and would swallow clicks aimed at that program's own top-right corner, silently killing its mouse with no undo. The active `TerminalView` publishes `terminalMouseCaptured` and `releaseActiveMouse` through the store instead, the same way `terminalHasSelection` and `sttToggle` already do.
- **`macOptionClickForcesSelection: true` is set, and without it macOS has no force-select at all.** `SelectionService.shouldForceSelection` is `isMac ? altKey && macOptionClickForcesSelection : shiftKey`, and the option defaults to `false` — so the "hold Shift to select while a program holds the mouse" escape hatch is Shift everywhere else and **Option** on macOS, and existed on macOS only once this was turned on.
- **Set store function values via object-merge, not the updater form**`set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `sttToggle`) into the Zustand store.
### bollard (Docker API)
+213 -11
View File
@@ -92,8 +92,111 @@ fn pick_recorded_lineage(
.or_else(|| from_snapshot.filter(|v| !v.is_empty()))
}
/// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to
/// be called on demand, not polled.
/// Reported as `probe_error` when there is genuinely nothing to read: no
/// container, stopped or otherwise, and no snapshot image.
///
/// It used to be reported for a *stopped* container too, which was simply
/// untrue — the container was sitting right there — and it disabled Update on
/// exactly the long-lived projects that had never been recreated and so had no
/// snapshot to fall back on.
const NOTHING_TO_PROBE: &str = "This project has no container or snapshot image yet, so there is nothing to compare against the base image.";
/// Where [`get_container_staleness`] reads the project's *current* filesystem
/// from, in descending order of how current the answer is.
#[derive(Debug, PartialEq, Eq)]
enum ProbeSource {
/// `docker exec` into the live container. The only source that includes
/// everything installed since the last commit *in this session*.
RunningContainer,
/// Commit the stopped container's writable layer to a throwaway image and
/// probe that. Exactly as current as the container, which is what makes it
/// preferable to the snapshot — see below.
StoppedContainer,
/// A throwaway container from `triple-c-snapshot-<id>:latest`.
Snapshot,
/// Nothing to read: no container, no snapshot.
Nothing,
}
/// Pick the probe source. `container_running` is `None` when the project has no
/// container at all, `Some(false)` when it has a stopped one.
///
/// **A stopped container outranks the snapshot.** The snapshot image is not a
/// checkpoint — `commit_container_snapshot` runs only before a removal (a
/// config-change recreate) or inside a migration, so a project that has never
/// hit either has *no snapshot at all*, however long it has been in use, and
/// one that has is stale by everything installed since. The container's
/// writable layer is the truth in both cases. This is the same argument
/// [`mig::manifest_from_container`] already makes for the running case; it does
/// not stop applying when the container is stopped.
///
/// Getting this wrong is what made a stopped, never-recreated project report
/// "no container or snapshot image yet" — with its container sitting right
/// there — and left Update disabled on the projects that most needed it.
fn pick_probe_source(container_running: Option<bool>, snapshot_exists: bool) -> ProbeSource {
match (container_running, snapshot_exists) {
(Some(true), _) => ProbeSource::RunningContainer,
(Some(false), _) => ProbeSource::StoppedContainer,
(None, true) => ProbeSource::Snapshot,
(None, false) => ProbeSource::Nothing,
}
}
/// Reported as `probe_error` when another operation owns the project and there
/// is no snapshot image to read instead. Deliberately not a claim about the
/// container: nothing is wrong with it, the answer is simply not safe to take
/// right now. See [`stopped_probe_policy`].
const PROJECT_BUSY: &str = "Another operation is running on this project, so its contents could not be inspected. Try again once it finishes.";
/// What to do about a stopped container, whose probe is the expensive one: it
/// commits the writable layer before it can read anything.
#[derive(Debug, PartialEq, Eq)]
enum StoppedProbe {
/// Commit and probe. The current answer, and the default.
Commit,
/// Probe the snapshot image instead. Less current — it lags the container by
/// everything installed since the last commit — but it allocates nothing and
/// touches nothing, which is what makes it the right answer while another
/// operation owns the container.
SnapshotInstead,
/// Report rather than guess.
Defer,
}
/// Pick what to do about a stopped container.
///
/// **Never commits while the project is claimed.** `get_container_staleness`
/// takes no [`crate::project_lock`] claim of its own, by design, so a commit
/// here can overlap a Recreate or Reset — and the collision is not symmetric.
/// The probe losing is harmless: a surfaced `probe_error` the user retries. The
/// *recreate* losing is not, because `start_project_container` removes the old
/// container with a hard `?`, so a non-404 from a remove that raced this commit
/// fails the whole Start with an opaque "Failed to remove container". Reading
/// the claim costs nothing and takes that failure off the table.
fn stopped_probe_policy(project_is_busy: bool, snapshot_exists: bool) -> StoppedProbe {
match (project_is_busy, snapshot_exists) {
(false, _) => StoppedProbe::Commit,
(true, true) => StoppedProbe::SnapshotInstead,
(true, false) => StoppedProbe::Defer,
}
}
/// Runs two filesystem probes (~3 s each) and is therefore meant to be called
/// on demand, not polled.
///
/// **Not read-only, despite only reporting.** The stopped-container path commits
/// a throwaway image and force-removes it, which makes this a writer of a
/// `triple-c-probe-*` image and puts it in the class of thing
/// [`crate::project_lock`] exists for — and it takes no claim. That is
/// deliberate: this is what the migration banner calls to decide whether to
/// offer an update, including while a migration is in flight, so refusing it
/// under a claim would blank the banner exactly when it has the most to say.
/// The exposure is bounded to a surfaced error — a concurrent Recreate, Reset or
/// migration can remove the container out from under the commit, and the result
/// is a `probe_error` the user can retry, never a damaged container or a
/// mislabelled image. Two overlapping probes cannot collide either, because
/// probe image names are unique per call; see
/// [`crate::docker::container::get_probe_image_name`].
#[tauri::command]
pub async fn get_container_staleness(
project_id: String,
@@ -145,16 +248,62 @@ pub async fn get_container_staleness(
};
// ── Probes ───────────────────────────────────────────────────────────
let running = match &container_id {
Some(id) => docker::is_container_running(id).await.unwrap_or(false),
None => false,
let container_running = match &container_id {
Some(id) => Some(docker::is_container_running(id).await.unwrap_or(false)),
None => None,
};
let from_manifest = if running {
mig::manifest_from_container(container_id.as_ref().unwrap()).await
} else if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
mig::manifest_from_image(&snapshot_image).await
} else {
Err("This project has no container or snapshot image yet, so there is nothing to compare against the base image.".to_string())
let snapshot_exists = docker::image_exists(&snapshot_image).await.unwrap_or(false);
let from_manifest = match (
pick_probe_source(container_running, snapshot_exists),
&container_id,
) {
(ProbeSource::RunningContainer, Some(id)) => mig::manifest_from_container(id).await,
(ProbeSource::StoppedContainer, Some(id)) => {
let busy = crate::project_lock::held(&project_id).is_some();
match stopped_probe_policy(busy, snapshot_exists) {
StoppedProbe::Commit => {
match mig::manifest_from_stopped_container_cached(id).await {
Ok(m) => Ok(m),
// **Never let a failed commit cost an answer the
// snapshot could have given.** Before stopped
// containers were readable at all, a stopped project
// fell straight through to its snapshot, so surfacing
// this error where the snapshot exists would make the
// banner *worse* than it was — and the ways this fails
// are the ones where the fallback matters most: a full
// disk (the commit has to allocate the whole writable
// layer; the snapshot probe allocates nothing) and a
// 409 from an operation that claimed the project after
// the check above.
Err(e) if snapshot_exists => {
log::warn!(
"Probing the stopped container for project {} failed ({}) — \
falling back to its snapshot image, which may lag it",
project_id,
e
);
mig::manifest_from_image(&snapshot_image).await
}
Err(e) => Err(e),
}
}
StoppedProbe::SnapshotInstead => {
log::info!(
"Project {} is claimed by another operation — probing its snapshot image \
rather than committing the container",
project_id
);
mig::manifest_from_image(&snapshot_image).await
}
StoppedProbe::Defer => Err(PROJECT_BUSY.to_string()),
}
}
(ProbeSource::Snapshot, _) => mig::manifest_from_image(&snapshot_image).await,
// `container_running` is `Some` exactly when `container_id` is, so the
// two arms above are the only ones those variants can reach. This arm
// is `ProbeSource::Nothing` — and now *only* that: it used to also
// swallow every stopped container, which is the bug.
(_, _) => Err(NOTHING_TO_PROBE.to_string()),
};
let (from_manifest, base_manifest) = match from_manifest {
@@ -1964,6 +2113,59 @@ mod tests {
assert_eq!(pick_recorded_lineage(some(""), None), None);
}
#[test]
fn a_stopped_container_is_probed_rather_than_reported_missing() {
// The regression: a container that exists but is stopped, with no
// snapshot ever taken, read as "nothing to compare against".
assert_eq!(
pick_probe_source(Some(false), false),
ProbeSource::StoppedContainer
);
}
#[test]
fn the_container_outranks_the_snapshot_whether_or_not_it_is_running() {
// The snapshot lags the container by everything installed since the
// last commit, in both states.
assert_eq!(
pick_probe_source(Some(true), true),
ProbeSource::RunningContainer
);
assert_eq!(
pick_probe_source(Some(false), true),
ProbeSource::StoppedContainer
);
}
#[test]
fn the_snapshot_is_the_fallback_only_once_the_container_is_gone() {
assert_eq!(pick_probe_source(None, true), ProbeSource::Snapshot);
}
#[test]
fn nothing_to_probe_is_reserved_for_no_container_and_no_snapshot() {
// The one case the "no container or snapshot image yet" message may
// still describe.
assert_eq!(pick_probe_source(None, false), ProbeSource::Nothing);
}
#[test]
fn a_stopped_container_is_committed_only_when_nothing_else_owns_the_project() {
assert_eq!(stopped_probe_policy(false, false), StoppedProbe::Commit);
assert_eq!(stopped_probe_policy(false, true), StoppedProbe::Commit);
}
#[test]
fn a_busy_project_falls_back_rather_than_racing_a_recreate() {
// The snapshot lags, but a stale answer beats failing someone's Start.
assert_eq!(
stopped_probe_policy(true, true),
StoppedProbe::SnapshotInstead
);
// Nothing to fall back to: say so instead of committing anyway.
assert_eq!(stopped_probe_policy(true, false), StoppedProbe::Defer);
}
#[test]
fn byte_sizes_read_the_way_a_disk_warning_should() {
assert_eq!(human_bytes(512), "512 B");
+1
View File
@@ -8,6 +8,7 @@ pub mod help_commands;
pub mod inspect_commands;
pub mod install_helper_commands;
pub mod migration_commands;
pub mod notes_commands;
pub mod project_commands;
pub mod settings_commands;
pub mod settings_export_commands;
@@ -0,0 +1,33 @@
use crate::models::Note;
use crate::storage::notes_store;
/// Every project's notes, oldest concept first: pinned notes, then most
/// recently edited.
///
/// Sorted here rather than in the webview so the dock and the tab — two views
/// of the same list — cannot drift into two different orders.
#[tauri::command]
pub async fn list_notes(project_id: String) -> Result<Vec<Note>, String> {
let mut notes = notes_store::load(&project_id)?;
notes.sort_by(|a, b| {
b.pinned
.cmp(&a.pinned)
.then_with(|| b.updated_at.cmp(&a.updated_at))
});
Ok(notes)
}
/// Insert or replace one note.
///
/// There is deliberately no whole-list setter. A bulk write is exactly the
/// clobbering this store's per-project file exists to avoid, and every caller
/// here is editing one note.
#[tauri::command]
pub async fn save_note(project_id: String, note: Note) -> Result<Note, String> {
notes_store::upsert(&project_id, note)
}
#[tauri::command]
pub async fn delete_note(project_id: String, note_id: String) -> Result<(), String> {
notes_store::delete(&project_id, &note_id)
}
@@ -722,6 +722,15 @@ pub async fn remove_project(
// holding an entire snapshot image that nothing will ever reference again.
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
// A project's notes are the one piece of its state that is purely the
// user's prose, so removal takes them with it rather than leaving an
// orphan file keyed by an id nothing will ever look up again. Logged and
// not propagated: an orphaned notes file is harmless, and a project that
// cannot be removed is not.
if let Err(e) = crate::storage::notes_store::clear(&project_id) {
log::warn!("Could not remove notes for project {}: {}", project_id, e);
}
// Stop and remove container if it exists. Everything named in `report`
// below is what will be unreachable the moment this function drops the
// project record — see [`ProjectRemovalReport`] and
+187 -27
View File
@@ -6,10 +6,58 @@ use crate::AppState;
/// Build the command to run in the container terminal.
///
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
/// Always a `bash -c` script, because every session runs [`UPDATE_PRELUDE`]
/// before `exec claude`. For Bedrock Profile projects the script additionally
/// validates the AWS session first, and runs `aws sso login` if it has expired
/// so the user can re-authenticate (the URL is clickable via xterm.js
/// WebLinksAddon).
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
let settings = state.settings_store.get();
build_claude_terminal_cmd(
project,
settings.global_aws.aws_profile.as_deref(),
session_name,
)
}
/// Shell line run immediately before `exec claude` in every Claude terminal
/// session.
///
/// `container/entrypoint.sh` already runs `claude update` when the container
/// starts, but containers here use a stop/start (and often just keep running)
/// model, so a long-lived container's CLI goes stale between restarts. Running
/// it per session is what keeps a week-old container current.
///
/// Deliberately non-fatal and time-bounded: `|| echo` swallows a failure (no
/// network, npm registry down) so a session always opens, and `timeout 60`
/// bounds how long a user waits for a terminal.
///
/// **`flock` is load-bearing, not tidiness.** Nothing serialises this against
/// the entrypoint's own `claude update`, and the entrypoint prints "container
/// ready" only *after* its copy finishes — so "start the project, open a tab"
/// races two updaters against the same `~/.claude/bin` install, as does
/// opening two tabs at once. `|| echo` would then hide a half-written install
/// behind a friendly message and the very next line (`exec claude`) would run
/// it. `-w 90` gives the entrypoint's `timeout 120` copy room to finish rather
/// than failing the wait, and `-E 0` makes losing the race a success: the
/// other holder just updated, so there is nothing left to do.
pub(crate) const UPDATE_PRELUDE: &str = concat!(
"flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock ",
r#"timeout 60 claude update 2>&1 || echo "(update skipped — continuing)""#,
);
/// Single-quote one argument for interpolation into a shell script string.
fn shell_quote_arg(arg: &str) -> String {
format!(" '{}'", arg.replace('\'', "'\\''"))
}
/// The testable core of [`build_terminal_cmd`], taking the resolved global AWS
/// profile rather than the whole [`AppState`].
fn build_claude_terminal_cmd(
project: &Project,
global_aws_profile: Option<&str>,
session_name: Option<&str>,
) -> Vec<String> {
let is_bedrock_profile = project.backend == Backend::Bedrock
&& project
.bedrock_config
@@ -19,36 +67,27 @@ fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<
let permission_args = project.effective_permission_mode().cli_args();
// The args are interpolated into a shell script string, so single-quote
// each one.
let name_flag = session_name
.filter(|n| !n.is_empty())
.map(|n| format!(" -n{}", shell_quote_arg(n)))
.unwrap_or_default();
let permission_flags: String = permission_args.iter().map(|a| shell_quote_arg(a)).collect();
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
if !is_bedrock_profile {
let mut cmd = vec!["claude".to_string()];
cmd.extend(permission_args);
if let Some(name) = session_name {
if !name.is_empty() {
cmd.push("-n".to_string());
cmd.push(name.to_string());
}
}
return cmd;
return vec![
"bash".to_string(),
"-c".to_string(),
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
];
}
let profile = aws_commands::resolve_profile_for_project(
project,
state.settings_store.get().global_aws.aws_profile.as_deref(),
);
let profile = aws_commands::resolve_profile_for_project(project, global_aws_profile);
// Build a bash wrapper that validates credentials, re-auths if needed,
// then exec's into claude.
let name_flag = session_name
.filter(|n| !n.is_empty())
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
.unwrap_or_default();
// The args are interpolated into a shell script string, so single-quote
// each one (same escaping style as name_flag above).
let permission_flags: String = permission_args
.iter()
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
.collect();
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
let script = format!(
r#"
@@ -75,9 +114,11 @@ else
echo ""
fi
fi
{update_prelude}
{claude_cmd}
"#,
profile = profile,
update_prelude = UPDATE_PRELUDE,
claude_cmd = claude_cmd
);
@@ -325,6 +366,9 @@ pub async fn stop_audio_bridge(
#[cfg(test)]
mod tests {
use super::{build_claude_terminal_cmd, UPDATE_PRELUDE};
use crate::models::Project;
/// A dropped file must be named the way the *user* named it.
///
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
@@ -338,6 +382,122 @@ mod tests {
/// answer comes from the spelling, and a path that does not name a file is
/// refused rather than silently substituted (it used to fall back to
/// `"dropped-file"`).
/// A `Project` with only the fields these tests care about set; the rest
/// come through serde so the test does not have to track every field.
fn project(backend: &str, bedrock_config: serde_json::Value) -> Project {
serde_json::from_value(serde_json::json!({
"id": "p1",
"name": "Test",
"paths": [],
"container_id": null,
"status": "running",
"backend": backend,
"bedrock_config": bedrock_config,
"ollama_config": null,
"openai_compatible_config": null,
"allow_docker_access": false,
"full_permissions": false,
"ssh_key_path": null,
"git_user_name": null,
"git_user_email": null,
"created_at": "now",
"updated_at": "now"
}))
.expect("test project deserializes")
}
/// Every Claude session updates the CLI before launching it.
///
/// `container/entrypoint.sh` only updates at container *start*, and these
/// containers are long-lived, so a stale CLI is the normal case without
/// this. The plain (non-Bedrock) path therefore has to be a `bash -c`
/// wrapper rather than a bare `claude` argv.
#[test]
fn build_terminal_cmd_updates_before_launching_claude() {
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None);
assert_eq!(cmd[0], "bash");
assert_eq!(cmd[1], "-c");
assert!(
cmd[2].contains(UPDATE_PRELUDE),
"plain path must run the update prelude: {}",
cmd[2]
);
assert!(cmd[2].contains("exec claude"), "got: {}", cmd[2]);
// The update has to happen *before* the exec, which never returns.
assert!(
cmd[2].find(UPDATE_PRELUDE).unwrap() < cmd[2].find("exec claude").unwrap(),
"prelude must precede the exec: {}",
cmd[2]
);
assert!(
UPDATE_PRELUDE.contains("timeout 60") && UPDATE_PRELUDE.contains("||"),
"the update must stay time-bounded and non-fatal"
);
}
/// The session name is interpolated into a shell script, so a quote in it
/// must not break out of its single-quoted argument.
#[test]
fn build_terminal_cmd_escapes_a_quoted_session_name() {
let cmd = build_claude_terminal_cmd(
&project("anthropic", serde_json::Value::Null),
None,
Some("Bob's tab; rm -rf /"),
);
assert!(
cmd[2].contains(r#"exec claude -n 'Bob'\''s tab; rm -rf /'"#),
"session name must be single-quote escaped: {}",
cmd[2]
);
}
/// Permission flags travel the same escaped path, and an empty name adds
/// no `-n` at all.
#[test]
fn build_terminal_cmd_quotes_permission_flags_and_omits_an_empty_name() {
let mut p = project("anthropic", serde_json::Value::Null);
p.full_permissions = true;
let cmd = build_claude_terminal_cmd(&p, None, Some(""));
assert!(
cmd[2].contains("exec claude '--dangerously-skip-permissions'\n"),
"got: {}",
cmd[2]
);
assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]);
}
/// The Bedrock-profile path keeps its AWS validation *and* gains the
/// prelude, immediately before the exec.
#[test]
fn build_terminal_cmd_bedrock_validates_aws_and_updates() {
let cmd = build_claude_terminal_cmd(
&project("bedrock", serde_json::json!({
"auth_method": "profile",
"aws_region": "us-east-1",
"aws_profile": "acme",
"model_id": null,
"disable_prompt_caching": false
})),
None,
Some("it's fine"),
);
assert_eq!(cmd[0], "bash");
let script = &cmd[2];
assert!(script.contains("aws sts get-caller-identity --profile 'acme'"), "got: {}", script);
assert!(script.contains("triple-c-sso-refresh"), "got: {}", script);
assert!(script.contains(UPDATE_PRELUDE), "got: {}", script);
assert!(script.contains(r#"exec claude -n 'it'\''s fine'"#), "got: {}", script);
assert!(
script.find(UPDATE_PRELUDE).unwrap() < script.find("exec claude").unwrap(),
"prelude must precede the exec: {}",
script
);
}
#[test]
fn a_dropped_file_keeps_the_name_the_user_dropped() {
use crate::commands::file_commands::host_upload_name;
+140 -4
View File
@@ -3052,6 +3052,118 @@ fn blanked_secret_env() -> Vec<String> {
.collect()
}
/// Image-name prefix for the throwaway commit a staleness probe of a stopped
/// container makes. The reaper's only handle on a leftover — see
/// [`crate::docker::migration::reap_probe_images`] — so nothing else may use it.
pub const PROBE_IMAGE_PREFIX: &str = "triple-c-probe-";
/// The throwaway image a staleness probe of a **stopped** container commits to.
///
/// **Unique per call**, and both halves of the name earn their place: the
/// container id prefix makes a leftover traceable in `docker images`, and the
/// counter makes two overlapping probes independent.
///
/// An earlier version of this was deliberately *stable* per container, on the
/// theory that the next probe would move the tag off an abandoned image and
/// leave it dangling for [`sweep_orphaned_snapshots`]. That was wrong twice
/// over. A container id does not survive a recreate, so for most leftovers
/// there is no "next probe of the same container" and the image was stranded
/// permanently; and a stable name made two concurrent probes fight over one
/// tag, where whichever finished first force-removed the image the other was
/// still reading and turned a healthy project into a bogus `probe_error`.
/// Uniqueness fixes both, and [`crate::docker::migration::reap_probe_images`]
/// is what collects the leftovers instead.
pub fn get_probe_image_name(container_id: &str) -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let short: String = container_id.chars().take(12).collect();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!(
"{}{}-{}-{}:latest",
PROBE_IMAGE_PREFIX,
short,
nanos,
SEQ.fetch_add(1, Ordering::Relaxed)
)
}
/// Commit a **stopped** container's filesystem to a throwaway image, returning
/// its name. The caller owns the image and must remove it.
///
/// This exists so a stopped project can be read at all. `docker exec` needs a
/// running container and the snapshot image is not a checkpoint — see
/// [`crate::commands::migration_commands`]'s probe-source pick — so without
/// this there is no way to see inside a project that is merely stopped.
///
/// ## Why it is tagged at all
///
/// An untagged commit would be tidier: untagged plus the `triple-c.managed=true`
/// that `docker commit` copies off the container is exactly the pair
/// [`sweep_orphaned_snapshots`] already collects, so a leftover would self-heal
/// with no new machinery. **It is not available.** `bollard`'s `Commit` response
/// model deserialises `"ID"` while the daemon sends `"Id"`, so
/// `commit_container` hands back `id: None` every time and there is no
/// reference left to probe. Neither existing commit site notices, because both
/// discard the response. Verified against Engine 29.6, bollard 0.18.1.
///
/// So the image needs a name, a tagged image is not dangling, and the sweep
/// therefore cannot be the safety net. [`crate::docker::migration::reap_probe_images`]
/// is, and [`get_probe_image_name`] carries the rest of that argument.
///
/// ## What is in the image, and what is not
///
/// `pause: false` because nothing is running — pausing a stopped container is
/// an error, the same reason [`recommit_without_secrets`]'s scratch commit
/// passes `false`.
///
/// Secrets are blanked from the env for the same reason
/// [`commit_container_snapshot`] blanks them: the commit bakes the container's
/// full ENV into the image, and "it only lives a few seconds" is not a property
/// this function can promise after a crash.
///
/// **The writable layer is committed unscrubbed, and that is unavoidable here.**
/// [`commit_container_snapshot`] runs [`scrub_writable_layer`] first precisely
/// because a commit stacks a layer and never rewrites one — but that scrub is a
/// `docker exec`, which is exactly what a stopped container cannot serve, and
/// scrubbing is not wanted anyway: the probe's whole job is to report the
/// filesystem as it actually is. What makes it acceptable is that this copies
/// bytes that are *already on this disk* in the container's own writable layer,
/// into an image that is never pushed, never created from, and reaped — so it
/// duplicates data inside one trust domain rather than widening it. That
/// argument depends on the reaping actually happening; treat
/// [`crate::docker::migration::reap_probe_images`] as load-bearing, not tidying.
pub async fn commit_container_for_probe(container_id: &str) -> Result<String, String> {
let docker = get_docker()?;
let image_name = get_probe_image_name(container_id);
let (repo, tag) = image_name
.rsplit_once(':')
.map(|(r, t)| (r.to_string(), t.to_string()))
.expect("get_probe_image_name always emits a tag");
docker
.commit_container(
CommitContainerOptions {
container: container_id.to_string(),
repo,
tag,
pause: false,
..Default::default()
},
Config::<String> {
env: Some(blanked_secret_env()),
..Default::default()
},
)
.await
.map_err(|e| format!("Failed to commit stopped container {}: {}", container_id, e))?;
Ok(image_name)
}
/// Whether `env` (an image's `Config.Env`) holds a non-empty value for any
/// name in [`SECRET_ENV_KEYS`].
fn env_holds_a_secret(env: &[String]) -> bool {
@@ -3518,9 +3630,10 @@ pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
remove_image_by_name(&get_snapshot_image_name(project)).await
}
/// Remove a Docker image by name/tag, treating "does not exist" as success.
/// Shared by [`remove_snapshot_image`] and the pending-cleanup retry, which
/// only has the image name (the project record is already gone by then).
/// Remove a Docker image by name, tag or **id**, treating "does not exist" as
/// success. Shared by [`remove_snapshot_image`], the pending-cleanup retry
/// (which only has the image name the project record is already gone by
/// then), and the staleness probe's throwaway commit, which has only an id.
pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
let docker = get_docker()?;
@@ -3536,7 +3649,7 @@ pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
.await
{
Ok(_) => {
log::info!("Removed snapshot image {}", image_name);
log::info!("Removed image {}", image_name);
Ok(())
}
Err(bollard::errors::Error::DockerResponseServerError {
@@ -4464,6 +4577,29 @@ mod tests {
assert!(env_holds_a_secret(&env));
}
/// The probe image's name must be **unique per call**. A stable name was
/// tried and is wrong twice over: a container id does not survive a
/// recreate, so a crashed probe's leftover would never be reclaimed by "the
/// next probe of the same container"; and two concurrent probes sharing one
/// tag means whichever finishes first force-removes the image the other is
/// still reading. See `commit_container_for_probe` and `reap_probe_images`.
#[test]
fn probe_image_names_are_unique_per_call_and_reapable_by_prefix() {
let id = "75993e6d5e1ab473b029a408c5ff0339";
let a = get_probe_image_name(id);
let b = get_probe_image_name(id);
assert_ne!(a, b, "two probes of one container must not share a tag");
// The prefix is the reaper's only handle on a leftover, so every name
// has to carry it — and it must not be the snapshot namespace, which is
// what a project is rebuilt from.
assert!(a.starts_with(PROBE_IMAGE_PREFIX), "{}", a);
assert!(!a.starts_with("triple-c-snapshot-"), "{}", a);
// Traceable back to its container, which is the point of the prefix.
assert!(a.contains("75993e6d5e1a"), "{}", a);
assert!(a.ends_with(":latest"), "{}", a);
}
#[test]
fn the_scrub_report_only_claims_success_when_nothing_is_left() {
let clean = SnapshotScrubReport {
+461
View File
@@ -886,6 +886,100 @@ pub async fn reap_probe_containers() {
}
}
/// Remove throwaway images left behind by a staleness probe of a stopped
/// container — [`super::container::commit_container_for_probe`]'s commits.
///
/// **Load-bearing, not tidying.** A probe image is *tagged*, because bollard
/// gives no image id back from a commit and there has to be something to probe.
/// Tagged means not dangling, so [`super::container::sweep_orphaned_snapshots`]
/// — which collects every other kind of orphan this app can leave — will never
/// see one. Without this, a probe that dies between its commit and its own
/// cleanup (SIGKILL, a crash, a 409 from a concurrent remove) strands a
/// multi-gigabyte image that **no code path can ever reclaim**, and there is no
/// UI to find it either. That is the one leak in this app with no floor on it,
/// so this runs at startup beside [`reap_probe_containers`].
///
/// Age-gated for exactly the reason that one is: `reference=` is a daemon-wide
/// filter, so a second copy of the app probing a project on the same daemon has
/// images matching this glob, and removing one mid-capture fails that probe with
/// "No such image" — the bogus `probe_error` the staleness work exists to get
/// rid of. In-process state cannot see the other instance, so age is the only
/// brake, and [`PROBE_REAP_MIN_AGE_SECS`] is already the right one: a probe is a
/// `find` over a root filesystem, not a multi-minute job.
///
/// Never fails the caller. Housekeeping, like every other sweep here.
pub async fn reap_probe_images() {
use bollard::image::{ListImagesOptions, RemoveImageOptions};
let docker = match get_docker() {
Ok(d) => d,
Err(e) => {
log::warn!("Could not reap leftover probe images: {}", e);
return;
}
};
let filters = HashMap::from([(
"reference".to_string(),
vec![format!("{}*", super::container::PROBE_IMAGE_PREFIX)],
)]);
let images = match docker
.list_images(Some(ListImagesOptions {
all: false,
filters,
..Default::default()
}))
.await
{
Ok(images) => images,
Err(e) => {
log::warn!("Could not list leftover probe images: {}", e);
return;
}
};
let now = chrono::Utc::now().timestamp();
for image in images {
// Unlike a container summary, an image summary always carries a
// `Created`, so there is no unknown-age case to defend against here.
if now - image.created < PROBE_REAP_MIN_AGE_SECS {
log::info!(
"Leaving probe image {:?} alone — it is younger than {} minutes, so it may belong \
to another Triple-C instance's live probe",
image.repo_tags,
PROBE_REAP_MIN_AGE_SECS / 60
);
continue;
}
// By **tag**, never by image id. A `force` removal by id untags an
// image everywhere, so an id that happens to carry another name loses
// that name too — which is how a test fixture that tagged
// `alpine:latest` into this namespace deleted the user's alpine. A real
// leftover has exactly the one probe tag, so removing the tag removes
// the image; anything else keeps whatever other names it has.
for tag in image
.repo_tags
.iter()
.filter(|t| t.starts_with(super::container::PROBE_IMAGE_PREFIX))
{
log::info!("Removing leftover probe image {}", tag);
if let Err(e) = docker
.remove_image(
tag,
Some(RemoveImageOptions {
force: true,
noprune: false,
}),
None,
)
.await
{
log::warn!("Could not remove leftover probe image {}: {}", tag, e);
}
}
}
}
/// How old a `triple-c.probe=migration` container must be before
/// [`reap_probe_containers`] will force-remove it, in seconds.
///
@@ -993,6 +1087,119 @@ pub async fn manifest_from_container(container_id: &str) -> Result<Manifest, Str
Ok(parse_manifest(&out))
}
/// Cached stopped-container manifests, keyed by container id, each paired with
/// the container's `FinishedAt` at the time it was captured.
///
/// **Sound because a stopped container's writable layer cannot change.** Nothing
/// can write to it while it is not running, so a manifest captured after it
/// stopped stays true until it is started again — and `FinishedAt` moves on
/// every stop, which is what makes the key exact rather than merely plausible.
///
/// This exists because `get_container_staleness` is called from a `useEffect`
/// that fires whenever the container settles, so simply opening a stopped
/// project's Overview probes it. Uncached that meant a `docker commit` of the
/// whole writable layer per visit — measured at 44 s on a real project — where
/// before this feature the same visit cost one throwaway container or nothing at
/// all. A regression like that is not worth the answer it buys.
///
/// Capped, because a `Manifest` of a real container is a few MB: this only has
/// to serve "the project whose page is open", so a handful of entries is the
/// whole working set and the oldest is dropped past that.
static STOPPED_MANIFEST_CACHE: std::sync::Mutex<
Option<Vec<(String, String, Manifest)>>,
> = std::sync::Mutex::new(None);
/// How many stopped-container manifests [`STOPPED_MANIFEST_CACHE`] keeps.
const STOPPED_MANIFEST_CACHE_MAX: usize = 4;
/// `FinishedAt` for a container, the cache's validity token. `None` when it
/// cannot be read, which is never treated as a hit.
async fn container_finished_at(container_id: &str) -> Option<String> {
let docker = get_docker().ok()?;
docker
.inspect_container(container_id, None)
.await
.ok()?
.state?
.finished_at
.filter(|s| !s.is_empty())
}
/// Capture a [`Manifest`] from a **stopped** container, reusing a cached one
/// when the container has not been started since it was taken.
///
/// See [`STOPPED_MANIFEST_CACHE`] for why this is exact and why it is needed.
pub async fn manifest_from_stopped_container_cached(
container_id: &str,
) -> Result<Manifest, String> {
let finished_at = container_finished_at(container_id).await;
if let Some(token) = &finished_at {
let guard = STOPPED_MANIFEST_CACHE.lock();
if let Ok(cache) = guard {
if let Some(entries) = cache.as_ref() {
if let Some((_, _, manifest)) = entries
.iter()
.find(|(id, tok, _)| id == container_id && tok == token)
{
log::debug!(
"Reusing the cached manifest for stopped container {}",
container_id
);
return Ok(manifest.clone());
}
}
}
}
let manifest = manifest_from_stopped_container(container_id).await?;
// Only cacheable if the container's state could be read at all; an unknown
// `FinishedAt` means there is no token that could later be compared.
if let Some(token) = finished_at {
if let Ok(mut cache) = STOPPED_MANIFEST_CACHE.lock() {
let entries = cache.get_or_insert_with(Vec::new);
entries.retain(|(id, _, _)| id != container_id);
entries.push((container_id.to_string(), token, manifest.clone()));
while entries.len() > STOPPED_MANIFEST_CACHE_MAX {
entries.remove(0);
}
}
}
Ok(manifest)
}
/// Capture a [`Manifest`] from a **stopped** container.
///
/// Commits the container's writable layer to a throwaway image, probes that,
/// and removes it. This is as current as [`manifest_from_container`] — it reads
/// the same filesystem — and it is why a stopped project no longer has to fall
/// back to its snapshot image, which may not exist at all and lags the
/// container by everything installed since the last commit when it does.
///
/// The image is removed on every path, including a failed probe. See
/// [`super::container::commit_container_for_probe`] for what a crash in the
/// window between the two costs, and why it is bounded.
pub async fn manifest_from_stopped_container(container_id: &str) -> Result<Manifest, String> {
let image = super::container::commit_container_for_probe(container_id).await?;
let manifest = manifest_from_image(&image)
.await
.map_err(|e| format!("Probe of the stopped container did not complete: {}", e));
if let Err(e) = super::container::remove_image_by_name(&image).await {
log::warn!(
"Could not remove the staleness probe's throwaway image {}: {} — `reap_probe_images` \
collects it at the next app start; the orphan sweep never will, because it is tagged",
image,
e
);
}
manifest
}
/// The image ID (`sha256:…`) of a local image, or `None` if it is not present.
///
/// Deliberately the **ID**, not a repo digest: locally built images and custom
@@ -2146,4 +2353,258 @@ mod tests {
assert!(!pin_is_reapable("pre-migration-handmade", false, ancient, &now));
assert!(!pin_is_reapable("latest", false, ancient, &now));
}
// ── Live Docker ─────────────────────────────────────────────────────────
/// The cache serves a second read of an unchanged stopped container, and —
/// the half that matters — stops serving it the moment the container is
/// started and stopped again. If invalidation were wrong this would report a
/// filesystem the project no longer has, and a migration would be planned
/// against it.
///
/// ```text
/// cargo test -- --ignored --nocapture stopped_manifest_cache
/// ```
#[cfg(unix)]
#[tokio::test]
#[ignore = "needs a Docker daemon; creates, commits and removes a throwaway container"]
async fn the_stopped_manifest_cache_survives_a_reread_but_not_a_restart() {
fn docker_cli(args: &[&str]) -> String {
let out = std::process::Command::new("docker")
.args(args)
.output()
.expect("docker CLI");
assert!(
out.status.success(),
"docker {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
let image = std::env::var("TRIPLE_C_TEST_IMAGE")
.unwrap_or_else(|_| "ghcr.io/shadowdao/triple-c-sandbox:latest".to_string());
let first = format!("/opt/cache-marker-a-{}", std::process::id());
let second = format!("/opt/cache-marker-b-{}", std::process::id());
let id = docker_cli(&[
"run", "-d", "--label", "triple-c.managed=true",
"--entrypoint", "/bin/sh",
&image, "-c", "sleep 600",
]);
let cleanup = || {
let _ = std::process::Command::new("docker")
.args(["rm", "-f", &id])
.output();
};
docker_cli(&["exec", &id, "mkdir", "-p", &first]);
docker_cli(&["stop", "-t", "1", &id]);
let t0 = std::time::Instant::now();
let cold = manifest_from_stopped_container_cached(&id).await;
let cold_ms = t0.elapsed().as_millis();
let t1 = std::time::Instant::now();
let warm = manifest_from_stopped_container_cached(&id).await;
let warm_ms = t1.elapsed().as_millis();
// Restart, change the filesystem, stop again — `FinishedAt` moves.
docker_cli(&["start", &id]);
docker_cli(&["exec", &id, "mkdir", "-p", &second]);
docker_cli(&["stop", "-t", "1", &id]);
let after_restart = manifest_from_stopped_container_cached(&id).await;
cleanup();
let has = |m: &Manifest, p: &str| m.paths.iter().any(|e| e.path == p && e.is_dir());
let cold = cold.expect("cold read");
let warm = warm.expect("warm read");
let after_restart = after_restart.expect("read after restart");
assert!(has(&cold, &first), "cold read missed {}", first);
assert!(has(&warm, &first), "warm read missed {}", first);
println!("cold {} ms, warm {} ms", cold_ms, warm_ms);
assert!(
warm_ms * 5 < cold_ms.max(5),
"the second read cost {} ms against a cold {} ms — it re-committed \
instead of using the cache",
warm_ms,
cold_ms
);
// The restart must have invalidated it: the new directory has to show up.
assert!(
has(&after_restart, &second),
"a restart did not invalidate the cache — {} is missing, so this is \
a stale manifest of a filesystem the container no longer has",
second
);
assert!(has(&after_restart, &first), "the restart lost {}", first);
}
/// The reaper finds a leftover probe image by prefix and — crucially —
/// refuses to remove a young one, because that image may be another
/// Triple-C instance's live probe. Only a real daemon can say whether the
/// `reference=` glob matches the names `get_probe_image_name` produces.
///
/// The fixture is **committed**, not tagged and not built. An image's
/// `Created` is its own, not its tag's, so tagging something already on disk
/// into this namespace yields a fixture the reaper is right to call ancient
/// — and BuildKit stamps a fixed epoch on `docker build` output, so a built
/// one looks ancient too. A commit stamps *now*, verified against Engine
/// 29.6, which is also how real probe images get their age.
///
/// Both of those mistakes were made here first, and one of them deleted an
/// unrelated `alpine:latest` — which is why `reap_probe_images` removes by
/// tag rather than by image id.
///
/// ```text
/// cargo test -- --ignored --nocapture reaper_spares
/// ```
#[cfg(unix)]
#[tokio::test]
#[ignore = "needs a Docker daemon; builds and removes a throwaway image"]
async fn the_reaper_spares_a_probe_image_young_enough_to_be_someone_elses() {
use std::process::Command;
fn docker_out(args: &[&str]) -> std::process::Output {
Command::new("docker").args(args).output().expect("docker CLI")
}
let base = std::env::var("TRIPLE_C_TEST_IMAGE")
.unwrap_or_else(|_| "alpine:latest".to_string());
let name = crate::docker::container::get_probe_image_name("reapertest01234");
// A never-started container is enough to commit from, and leaves the
// daemon's run state alone entirely.
let created = docker_out(&["create", &base, "true"]);
assert!(
created.status.success(),
"could not create the fixture container from {}: {}",
base,
String::from_utf8_lossy(&created.stderr)
);
let cid = String::from_utf8_lossy(&created.stdout).trim().to_string();
let committed = docker_out(&["commit", "--pause=false", &cid, &name]);
let _ = docker_out(&["rm", "-f", &cid]);
assert!(
committed.status.success(),
"could not commit the fixture image: {}",
String::from_utf8_lossy(&committed.stderr)
);
reap_probe_images().await;
let still_there = Command::new("docker")
.args(["image", "inspect", &name])
.output()
.expect("docker image inspect")
.status
.success();
let _ = Command::new("docker").args(["rmi", &name]).output();
assert!(
still_there,
"a probe image committed seconds ago was reaped — that is another \
instance's live probe being broken, see PROBE_REAP_MIN_AGE_SECS"
);
}
/// A *stopped* container is readable, and what comes back is its writable
/// layer rather than the image it was created from. This is the whole point
/// of the function: the base image cannot answer it, and the project may
/// well have no snapshot image at all.
///
/// Also asserts the throwaway commit leaves nothing behind, which no unit
/// test can. It has to assert on the `triple-c-probe-*` tags specifically:
/// the probe image is *tagged*, so a leak never shows up as a dangling
/// image and a dangling-set assertion here would pass either way.
///
/// Ignored because it needs Docker and commits a container; run it with
///
/// ```text
/// cargo test -- --ignored --nocapture stopped_container
/// ```
#[cfg(unix)]
#[tokio::test]
#[ignore = "needs a Docker daemon; creates, commits and removes a throwaway container"]
async fn a_stopped_container_is_read_from_its_writable_layer() {
fn docker_cli(args: &[&str]) -> String {
let out = std::process::Command::new("docker")
.args(args)
.output()
.expect("docker CLI");
assert!(
out.status.success(),
"docker {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn probe_images() -> Vec<String> {
let mut ids: Vec<String> = docker_cli(&[
"images", "-q",
"--filter",
&format!("reference={}*", crate::docker::container::PROBE_IMAGE_PREFIX),
])
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
ids.sort();
ids
}
let image = std::env::var("TRIPLE_C_TEST_IMAGE")
.unwrap_or_else(|_| "ghcr.io/shadowdao/triple-c-sandbox:latest".to_string());
// A marker only the writable layer can carry, under a MANIFEST_ROOTS root.
let marker = format!("/opt/probe-marker-{}", std::process::id());
// Another instance's live probe images are allowed to exist; what must
// hold is that this probe adds none of its own.
let before = probe_images();
let id = docker_cli(&[
"run", "-d", "--label", "triple-c.managed=true",
"--entrypoint", "/bin/sh",
&image, "-c", "sleep 300",
]);
let cleanup = |id: &str| {
let _ = std::process::Command::new("docker")
.args(["rm", "-f", id])
.output();
};
docker_cli(&["exec", &id, "mkdir", "-p", &marker]);
docker_cli(&["stop", "-t", "1", &id]);
let result = manifest_from_stopped_container(&id).await;
cleanup(&id);
let manifest = result.expect("a stopped container must be probeable");
assert!(
manifest.paths.iter().any(|e| e.path == marker && e.is_dir()),
"the probe read the image, not the container's writable layer: {} missing",
marker
);
// Non-empty package sets prove the probe script really ran, rather than
// parsing an empty transcript into an empty-but-Ok manifest.
assert!(
!manifest.apt_manual.is_empty(),
"apt-mark showmanual came back empty, so the probe did not run"
);
assert_eq!(
probe_images(),
before,
"the throwaway probe image was not cleaned up"
);
}
}
+13 -1
View File
@@ -263,12 +263,20 @@ pub fn run() {
// logged warning rather than a failed start.
//
// Ordering matters. Probes are removed first because a probe holds
// an image open and the sweep will not force; pins are untagged
// an image open and the sweep will not force — both the probe
// containers and the probe images, the latter being the one orphan
// the sweep can never reach on its own; pins are untagged
// second so the images they were holding are dangling by the time
// the sweep lists them; the sweep runs last and collects both.
let projects_store_for_cleanup = projects_store_setup.clone();
tauri::async_runtime::spawn(async move {
crate::docker::reap_probe_containers().await;
// Probe *images* too, and for a sharper reason: a probe
// container merely pins an image the sweep then refuses to
// touch, whereas a leftover probe image is tagged and so
// nothing else in this app can ever collect it. See
// `reap_probe_images`.
crate::docker::reap_probe_images().await;
let reaped = crate::docker::reap_stale_migration_pins().await;
if reaped > 0 {
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
@@ -470,6 +478,10 @@ pub fn run() {
commands::project_commands::stop_project_container,
commands::project_commands::rebuild_project_container,
commands::project_commands::reconcile_project_statuses,
// Notes
commands::notes_commands::list_notes,
commands::notes_commands::save_note,
commands::notes_commands::delete_note,
// Container base-image migration
commands::migration_commands::get_container_staleness,
commands::migration_commands::migrate_project_to_base,
+13 -4
View File
@@ -3,10 +3,19 @@
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
/// since 2.42) fails outright on some Mesa/driver/compositor combinations
/// under Wayland, printing `Could not create default EGL display:
/// EGL_BAD_PARAMETER. Aborting.` straight to stderr from WebKitGTK's own C
/// code and killing the webview before Triple-C's own logging even starts —
/// see triple-c#34, reported on CachyOS/Arch with Wayland.
/// under Wayland, killing the webview and leaving a blank window — 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
/// variable is exported into an XWayland client's environment too, so a
+6 -4
View File
@@ -1,15 +1,17 @@
pub mod project;
pub mod container_config;
pub mod app_settings;
pub mod container_config;
pub mod gateway_settings;
pub mod migration;
pub mod note;
pub mod project;
pub mod settings_export;
pub mod update_info;
pub use project::*;
pub use container_config::*;
pub use app_settings::*;
pub use container_config::*;
pub use gateway_settings::*;
pub use migration::*;
pub use note::*;
pub use project::*;
pub use settings_export::*;
pub use update_info::*;
+34
View File
@@ -0,0 +1,34 @@
use serde::{Deserialize, Serialize};
/// One note. A scratchpad entry the user can also fire at a running Claude
/// session.
///
/// Deliberately has no `kind`/`type` field. What makes a note "for the agent"
/// is that the user pressed Send, not a mode chosen when it was written — a
/// classification decision at writing time is one the user is least willing to
/// make, and it would turn one pane into two features.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Note {
pub id: String,
pub title: String,
pub body: String,
/// Pinned notes sort first, then by `updated_at` descending.
#[serde(default)]
pub pinned: bool,
pub created_at: String,
pub updated_at: String,
}
impl Note {
pub fn new(title: String, body: String) -> Self {
let now = chrono::Utc::now().to_rfc3339();
Self {
id: uuid::Uuid::new_v4().to_string(),
title,
body,
pinned: false,
created_at: now.clone(),
updated_at: now,
}
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod migration_store;
pub mod notes_store;
pub mod pending_cleanup;
pub mod projects_store;
pub mod secure;
+593
View File
@@ -0,0 +1,593 @@
//! Host-side persistence for per-project notes.
//!
//! One JSON file per project under `<data_dir>/triple-c/notes/`, on the same
//! free-function shape as `migration_store` — no struct, nothing in
//! `AppState`, no in-memory copy. `ProjectsStore` holds a `Mutex` because it
//! caches the project list; a store that reads and writes the file per call
//! has nothing to cache and nothing to guard.
//!
//! Deliberately *not* a field on `Project`. `projects.json` is rewritten on
//! every blur by the debounced-nothing save path in `useSaveState`, so notes
//! there would mean the whole project list is rewritten per edit, and a note
//! save racing a Config save would silently drop one of them.
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize};
use crate::models::Note;
/// The version stamped into every notes file this build writes.
const NOTES_FORMAT_VERSION: u32 = 1;
/// What is actually on disk: a version envelope around the notes.
///
/// The list is wrapped rather than written bare because the wrapper costs
/// nothing today and cannot be added cheaply later — once files exist in the
/// field, every reader has to sniff two shapes forever. `version` is written
/// and read back but nothing branches on it yet: it is the hook a future
/// format change hangs off, and its value is only useful if it has been there
/// since the first file.
///
/// Not in `models/` and not exposed over IPC: the frontend receives
/// `Vec<Note>` from `list_notes` and never sees the envelope, so this is a
/// storage detail rather than part of the IPC contract.
#[derive(Debug, Serialize, Deserialize)]
struct ProjectNotes {
version: u32,
#[serde(default)]
notes: Vec<Note>,
}
/// Serialises the read-modify-write half of an upsert or delete.
///
/// Nothing here is cached, so there is no shared state to protect — but an
/// upsert reads the whole file, edits one entry and writes it back, and two of
/// those interleaving would lose whichever note was written first. The read
/// path does not take it.
fn write_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
/// `<data_dir>/triple-c/notes`, created on demand.
pub fn notes_dir() -> Result<PathBuf, String> {
let dir = dirs::data_dir()
.ok_or_else(|| {
"Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string()
})?
.join("triple-c")
.join("notes");
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create notes directory: {}", e))?;
Ok(dir)
}
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
/// the write anywhere but the notes directory.
fn sanitize(project_id: &str) -> String {
project_id
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect()
}
fn notes_path_in(dir: &Path, project_id: &str) -> PathBuf {
dir.join(format!("{}.json", sanitize(project_id)))
}
// ── Public API. Each resolves the real directory, then defers to the `_in`
// variant, which is what the tests exercise against a temp dir. `ProjectsStore`
// hardcodes `dirs::data_dir()` in its constructor and is therefore untestable
// as a unit; this store does not inherit that. ─────────────────────────────
pub fn load(project_id: &str) -> Result<Vec<Note>, String> {
load_in(&notes_dir()?, project_id)
}
pub fn upsert(project_id: &str, note: Note) -> Result<Note, String> {
upsert_in(&notes_dir()?, project_id, note)
}
pub fn delete(project_id: &str, note_id: &str) -> Result<(), String> {
delete_in(&notes_dir()?, project_id, note_id)
}
/// Remove a project's notes file entirely. Missing is success.
pub fn clear(project_id: &str) -> Result<(), String> {
clear_in(&notes_dir()?, project_id)
}
// ── Implementation ─────────────────────────────────────────────────────────
/// Read a project's notes. A missing file is an empty list.
///
/// **An unparseable file is copied aside and left in place**, then reported as
/// empty. Erroring instead would make the Notes tab permanently unusable for
/// that project with no way out through the UI; deleting instead would destroy
/// the only copy of what the user wrote. The copy is timestamped so a second
/// corruption cannot overwrite the first — which is the one taken before
/// anything rewrote the file, and therefore the one worth having — and capped,
/// because `list_notes` runs on *every* panel mount. See [`keep_corrupt_copy`].
fn load_in(dir: &Path, project_id: &str) -> Result<Vec<Note>, String> {
let path = notes_path_in(dir, project_id);
if !path.exists() {
return Ok(Vec::new());
}
let data = fs::read_to_string(&path).map_err(|e| format!("Failed to read notes: {}", e))?;
match parse(&data) {
Ok(notes) => Ok(notes),
Err(e) => {
let kept = keep_corrupt_copy(&path, &chrono::Utc::now());
log::error!(
"Failed to parse notes for project {}: {} — treating as empty; the file is \
left in place{}",
project_id,
e,
kept.describe()
);
Ok(Vec::new())
}
}
}
/// Parse a notes file: the versioned envelope, or a bare array.
///
/// The bare array is what this store wrote before [`ProjectNotes`] existed —
/// only ever on a development build, but a developer's own notes are still
/// prose nothing else holds a copy of, and the alternative is `load_in`
/// declaring a perfectly readable file corrupt. It is read, never written: the
/// first save rewrites the file with an envelope.
fn parse(data: &str) -> Result<Vec<Note>, serde_json::Error> {
match serde_json::from_str::<ProjectNotes>(data) {
Ok(file) => Ok(file.notes),
// Report the envelope's error, not the array's — the envelope is the
// shape this store writes, so its message is the one that describes
// what is actually wrong with the file.
Err(envelope_err) => serde_json::from_str::<Vec<Note>>(data).map_err(|_| envelope_err),
}
}
/// How many timestamped copies of one project's corrupt notes file are kept.
///
/// Timestamping fixes "a second corruption overwrote the first" and introduces
/// its opposite: `load_in` runs on every `list_notes`, which is every panel
/// mount — every project switch, every dock-follows-tab change, every sub-tab
/// toggle. A file that is *persistently* unparseable (the normal case, since
/// nothing repairs it) would otherwise mint a fresh full copy of the user's
/// prose every time the clock's second changed. Nothing ever reads them back
/// and nothing ever removed them.
///
/// Four is enough for the only use there is: a human looking at what the file
/// held. Same constant, same reasoning as `migration_store`.
const MAX_CORRUPT_BACKUPS: usize = 4;
/// What [`keep_corrupt_copy`] did, so the log line can tell the truth about
/// whether a file exists.
///
/// Three outcomes, and they must not be conflated. Folding "already kept
/// enough" into success and then saying "a copy was kept" names a file that
/// was never created — which is what someone reads before going to look for
/// their data.
enum Kept {
Copied(PathBuf),
/// This exact second's copy was already on disk.
AlreadyThere(PathBuf),
/// The cap is reached; the earlier copies are kept and this one is not.
EnoughAlready(usize),
Failed(String),
}
impl Kept {
fn describe(&self) -> String {
match self {
Kept::Copied(p) | Kept::AlreadyThere(p) => format!(" (a copy is at {})", p.display()),
// The earliest copies are the ones worth having, so the cap keeps
// those and drops this one. Say so, rather than implying a file
// exists.
Kept::EnoughAlready(n) => format!(
" (no copy kept — {} earlier copies of this file are already saved alongside it)",
n
),
Kept::Failed(e) => format!(" (could not keep a copy: {})", e),
}
}
}
/// Where a copy of an unreadable notes file is kept.
fn corrupt_backup_path(path: &Path, now: &chrono::DateTime<chrono::Utc>) -> PathBuf {
path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")))
}
/// Whether [`MAX_CORRUPT_BACKUPS`] copies of this project's file already exist.
///
/// Asked *before* the copy rather than pruning after it, so the cap is not
/// implemented by writing a file and deleting it again on every pass — and so
/// the copies that survive are the oldest, which are the ones taken closest to
/// whatever produced the corruption.
///
/// A directory that cannot be listed answers "not full": failing open costs at
/// most one extra file, and failing closed would drop the very first copy of
/// prose nothing else has kept.
fn corrupt_backups_full(path: &Path) -> bool {
let (Some(dir), Some(stem)) = (path.parent(), path.file_stem()) else {
return false;
};
// `{stem}.json.corrupt-` — the same shape `corrupt_backup_path` builds, so
// this can never match another project's copies or an unrelated `.bak`.
let prefix = format!("{}.json.corrupt-", stem.to_string_lossy());
let Ok(entries) = fs::read_dir(dir) else {
return false;
};
entries
.flatten()
.filter(|e| {
let name = e.file_name().to_string_lossy().to_string();
name.starts_with(&prefix) && name.ends_with(".bak")
})
.count()
>= MAX_CORRUPT_BACKUPS
}
fn keep_corrupt_copy(path: &Path, now: &chrono::DateTime<chrono::Utc>) -> Kept {
let backup = corrupt_backup_path(path, now);
if backup.exists() {
return Kept::AlreadyThere(backup);
}
if corrupt_backups_full(path) {
return Kept::EnoughAlready(MAX_CORRUPT_BACKUPS);
}
match fs::copy(path, &backup) {
Ok(_) => Kept::Copied(backup),
Err(e) => Kept::Failed(e.to_string()),
}
}
/// Insert or replace one note, leaving the rest untouched.
///
/// `created_at` and `id` are the store's, not the caller's: the webview sends
/// a whole `Note` back and must not be able to rewrite when a note was made.
/// `updated_at` is stamped here for the same reason.
fn upsert_in(dir: &Path, project_id: &str, mut note: Note) -> Result<Note, String> {
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
let mut notes = load_in(dir, project_id)?;
note.updated_at = chrono::Utc::now().to_rfc3339();
match notes.iter_mut().find(|n| n.id == note.id) {
Some(existing) => {
note.created_at = existing.created_at.clone();
*existing = note.clone();
}
None => notes.push(note.clone()),
}
save_all(dir, project_id, &notes)?;
Ok(note)
}
/// Remove one note. Removing one that is already gone is success — the UI can
/// retry a delete whose result it never saw.
fn delete_in(dir: &Path, project_id: &str, note_id: &str) -> Result<(), String> {
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
let mut notes = load_in(dir, project_id)?;
let before = notes.len();
notes.retain(|n| n.id != note_id);
if notes.len() == before {
return Ok(());
}
save_all(dir, project_id, &notes)
}
fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> {
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
let path = notes_path_in(dir, project_id);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("Failed to remove notes: {}", e)),
}
}
/// Atomically **and durably** write the whole list.
///
/// Write-temp-then-rename alone is only half of it. `fs::write` returns once
/// the bytes are in the page cache; the rename is atomic with respect to other
/// readers, not to power loss. Losing power in that window leaves the rename
/// applied and the data not written — a truncated file, produced by the code
/// whose job is to prevent one. So the file is fsynced before the rename and
/// the directory after it, since the rename is directory metadata. Notes are
/// prose the user typed and nothing else holds a copy.
fn save_all(dir: &Path, project_id: &str, notes: &[Note]) -> Result<(), String> {
let path = notes_path_in(dir, project_id);
let file = ProjectNotes {
version: NOTES_FORMAT_VERSION,
notes: notes.to_vec(),
};
let data = serde_json::to_string_pretty(&file)
.map_err(|e| format!("Failed to serialize notes: {}", e))?;
let tmp = path.with_extension("json.tmp");
{
use std::io::Write;
let mut file =
fs::File::create(&tmp).map_err(|e| format!("Failed to write notes: {}", e))?;
file.write_all(data.as_bytes())
.map_err(|e| format!("Failed to write notes: {}", e))?;
file.sync_all()
.map_err(|e| format!("Failed to flush notes to disk: {}", e))?;
}
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit notes: {}", e))?;
sync_dir(&path);
Ok(())
}
/// fsync the directory holding `path`, so the rename survives power loss.
///
/// Best effort only where it is meaningless: Windows has no directory handle
/// to sync and returns an error for the attempt, so a failure is logged rather
/// than propagated. The file's own `sync_all` carries the data and is not best
/// effort.
fn sync_dir(path: &Path) {
let Some(dir) = path.parent() else { return };
if let Err(e) = fs::File::open(dir).and_then(|d| d.sync_all()) {
log::debug!(
"Could not fsync the notes directory {}: {} — the file itself was flushed",
dir.display(),
e
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"triple-c-notes-{}-{}",
tag,
uuid::Uuid::new_v4().simple()
));
std::fs::create_dir_all(&dir).expect("temp dir");
dir
}
fn corrupt_copies(dir: &std::path::Path) -> Vec<String> {
std::fs::read_dir(dir)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.contains(".corrupt-"))
.collect()
}
#[test]
fn project_ids_cannot_escape_the_notes_directory() {
// The id arrives over IPC. It must not be able to steer the write.
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
assert_eq!(sanitize("a/b"), "a_b");
assert_eq!(sanitize("a\\b"), "a_b");
// A real UUID must survive untouched, or every note file would move
// the first time this function changed.
assert_eq!(
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
"ab62cd24-51aa-4645-8f5c-17a124062050"
);
}
#[test]
fn a_missing_file_is_an_empty_list_not_an_error() {
let dir = temp_dir("missing");
assert_eq!(load_in(&dir, "nobody").unwrap(), Vec::<Note>::new());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_upserted_note_round_trips() {
let dir = temp_dir("roundtrip");
let note = Note::new("Deploy steps".into(), "one\ntwo".into());
let saved = upsert_in(&dir, "p1", note.clone()).unwrap();
assert_eq!(saved.id, note.id);
let loaded = load_in(&dir, "p1").unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].body, "one\ntwo");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn upserting_an_existing_id_replaces_it_and_keeps_created_at() {
let dir = temp_dir("replace");
let mut note = Note::new("Title".into(), "first".into());
upsert_in(&dir, "p1", note.clone()).unwrap();
note.body = "second".into();
note.created_at = "1999-01-01T00:00:00Z".into(); // a client must not rewrite this
let saved = upsert_in(&dir, "p1", note.clone()).unwrap();
let loaded = load_in(&dir, "p1").unwrap();
assert_eq!(loaded.len(), 1, "an upsert must not append a duplicate");
assert_eq!(loaded[0].body, "second");
assert_ne!(
saved.created_at, "1999-01-01T00:00:00Z",
"created_at is owned by the store, not by whatever the webview sent"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn deleting_a_note_leaves_the_others_and_a_missing_one_is_success() {
let dir = temp_dir("delete");
let keep = upsert_in(&dir, "p1", Note::new("keep".into(), "".into())).unwrap();
let drop = upsert_in(&dir, "p1", Note::new("drop".into(), "".into())).unwrap();
delete_in(&dir, "p1", &drop.id).unwrap();
let loaded = load_in(&dir, "p1").unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].id, keep.id);
// Idempotent: removing what is already gone is not an error, because
// the UI can retry a delete it never saw the result of.
delete_in(&dir, "p1", &drop.id).unwrap();
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_unreadable_file_is_copied_aside_and_reads_as_empty() {
// Same reasoning as migration_store: a corrupt file must not make the
// tab permanently unusable, and the bytes must not be destroyed.
let dir = temp_dir("corrupt");
let path = notes_path_in(&dir, "p1");
std::fs::write(&path, b"{ not json").unwrap();
assert_eq!(load_in(&dir, "p1").unwrap(), Vec::<Note>::new());
assert!(path.exists(), "the unreadable file is left in place");
assert_eq!(
corrupt_copies(&dir).len(),
1,
"the bytes must be kept exactly once"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn what_is_written_is_a_version_envelope_not_a_bare_array() {
// The envelope costs nothing now and cannot be added cheaply once
// files exist in the field, so the very first file has to carry it.
let dir = temp_dir("envelope");
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
let raw = std::fs::read_to_string(notes_path_in(&dir, "p1")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed["version"], NOTES_FORMAT_VERSION);
assert_eq!(parsed["notes"].as_array().unwrap().len(), 1);
assert_eq!(parsed["notes"][0]["body"], "b");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_pre_envelope_bare_array_still_reads_and_is_not_called_corrupt() {
// Only a development build ever wrote this shape, but declaring a
// perfectly readable file corrupt is the one outcome this store exists
// to avoid. It is read, never written back.
let dir = temp_dir("legacy");
let note = Note::new("Deploy".into(), "one\ntwo".into());
std::fs::write(
notes_path_in(&dir, "p1"),
serde_json::to_string(&vec![note.clone()]).unwrap(),
)
.unwrap();
let loaded = load_in(&dir, "p1").unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].body, "one\ntwo");
let copies = corrupt_copies(&dir);
assert!(copies.is_empty(), "a readable file must not be copied aside");
// The next write upgrades it in place.
upsert_in(&dir, "p1", note).unwrap();
let raw = std::fs::read_to_string(notes_path_in(&dir, "p1")).unwrap();
assert!(raw.contains("\"version\""));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn corrupt_copies_are_capped_rather_than_one_per_second() {
// `list_notes` runs on every panel mount, so an unrepaired file would
// otherwise mint a full copy of the user's prose every time the
// clock's second changed.
let dir = temp_dir("cap");
let path = notes_path_in(&dir, "p1");
std::fs::write(&path, b"{ not json").unwrap();
let base = chrono::Utc::now();
for i in 0..MAX_CORRUPT_BACKUPS as i64 + 3 {
let at = base + chrono::Duration::seconds(i);
let kept = keep_corrupt_copy(&path, &at);
if i < MAX_CORRUPT_BACKUPS as i64 {
assert!(matches!(kept, Kept::Copied(_)), "copy {} should be kept", i);
} else {
assert!(
matches!(kept, Kept::EnoughAlready(MAX_CORRUPT_BACKUPS)),
"copy {} should be refused by the cap",
i
);
}
}
assert_eq!(corrupt_copies(&dir).len(), MAX_CORRUPT_BACKUPS);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_second_read_in_the_same_second_does_not_re_copy() {
let dir = temp_dir("samesecond");
let path = notes_path_in(&dir, "p1");
std::fs::write(&path, b"{ not json").unwrap();
let at = chrono::Utc::now();
assert!(matches!(keep_corrupt_copy(&path, &at), Kept::Copied(_)));
assert!(matches!(
keep_corrupt_copy(&path, &at),
Kept::AlreadyThere(_)
));
assert_eq!(corrupt_copies(&dir).len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_log_line_never_claims_a_backup_that_was_not_written() {
// A message that invents a backup is worse than no message: it is what
// someone reads before going to look for their data.
let dir = temp_dir("honesty");
let path = notes_path_in(&dir, "p1");
std::fs::write(&path, b"{ not json").unwrap();
let copied = keep_corrupt_copy(&path, &chrono::Utc::now()).describe();
assert!(copied.contains("a copy is at"));
let refused = Kept::EnoughAlready(MAX_CORRUPT_BACKUPS).describe();
assert!(refused.contains("no copy kept"));
assert!(!refused.contains("a copy is at"));
let failed = Kept::Failed("permission denied".into()).describe();
assert!(failed.contains("could not keep a copy"));
assert!(!failed.contains("a copy is at"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_write_leaves_no_temp_file_behind() {
let dir = temp_dir("tmp");
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "the rename must have consumed the temp file");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn clearing_a_project_removes_its_file_and_missing_is_success() {
let dir = temp_dir("clear");
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
assert!(notes_path_in(&dir, "p1").exists());
clear_in(&dir, "p1").unwrap();
assert!(!notes_path_in(&dir, "p1").exists());
clear_in(&dir, "p1").unwrap(); // idempotent
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn clearing_is_what_project_removal_calls_and_it_never_fails_on_absence() {
// `remove_project` must not be able to fail because a project simply
// never had any notes — an orphaned notes file is harmless, a project
// that cannot be removed is not.
let dir = temp_dir("removal");
assert!(clear_in(&dir, "never-had-notes").is_ok());
std::fs::remove_dir_all(&dir).ok();
}
}
+20 -11
View File
@@ -206,6 +206,11 @@ pub async fn handle_connection(socket: WebSocket, state: Arc<WebTerminalState>)
writer_handle.abort();
}
/// The desktop terminal's update prelude, reused verbatim. Shared rather than
/// copied so the web terminal cannot drift from it — a duplicated `const` with
/// a "keep these identical" comment is only as good as the next reader.
use crate::commands::terminal_commands::UPDATE_PRELUDE;
/// Build the command for a terminal session, mirroring terminal_commands.rs logic.
fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec<String> {
let is_bedrock_profile = project.backend == Backend::Bedrock
@@ -217,17 +222,6 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
let permission_args = project.effective_permission_mode().cli_args();
if !is_bedrock_profile {
let mut cmd = vec!["claude".to_string()];
cmd.extend(permission_args);
return cmd;
}
let profile = aws_commands::resolve_profile_for_project(
project,
settings_store.get().global_aws.aws_profile.as_deref(),
);
// The args are interpolated into a shell script string below, so
// single-quote each one.
let permission_flags: String = permission_args
@@ -236,6 +230,19 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
.collect();
let claude_cmd = format!("exec claude{}", permission_flags);
if !is_bedrock_profile {
return vec![
"bash".to_string(),
"-c".to_string(),
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
];
}
let profile = aws_commands::resolve_profile_for_project(
project,
settings_store.get().global_aws.aws_profile.as_deref(),
);
let script = format!(
r#"
echo "Validating AWS session for profile '{profile}'..."
@@ -260,9 +267,11 @@ else
echo ""
fi
fi
{update_prelude}
{claude_cmd}
"#,
profile = profile,
update_prelude = UPDATE_PRELUDE,
claude_cmd = claude_cmd
);
+2
View File
@@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event";
import Sidebar from "./components/layout/Sidebar";
import TopBar from "./components/layout/TopBar";
import StatusBar from "./components/layout/StatusBar";
import NotesDock from "./components/layout/NotesDock";
import TerminalView from "./components/terminal/TerminalView";
import DockerInstallDialog from "./components/DockerInstallDialog";
import ProjectHome from "./components/projects/home/ProjectHome";
@@ -161,6 +162,7 @@ export default function App() {
</div>
)}
</main>
<NotesDock />
</div>
<StatusBar stt={stt} />
<ToastHost />
+6 -12
View File
@@ -10,6 +10,7 @@ import {
} from "../../store/appState";
import { effectivePermissionMode } from "../projects/PermissionModeControl";
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
import { sessionDisplayName } from "../../lib/sessionName";
import type { PermissionMode } from "../../lib/types";
interface ContextMenuState {
@@ -195,11 +196,10 @@ export default function MainTabs() {
}
const session = sessions.find((s) => s.id === tabKeyId(key));
if (!session) return "";
const custom = getCustomName(session.projectId, session.id);
return custom
? `${session.projectName}: ${custom}`
: (session.sessionName ?? session.projectName) +
(session.sessionType === "bash" ? " (bash)" : "");
return sessionDisplayName(
session,
projects.find((p) => p.id === session.projectId),
);
};
const endDrag = () => {
@@ -358,13 +358,7 @@ export default function MainTabs() {
const session = sessions.find((s) => s.id === sessionId);
if (!session) return null;
const project = projects.find((p) => p.id === session.projectId);
const customName = getCustomName(session.projectId, session.id);
const baseLabel =
(session.sessionName ?? session.projectName) +
(session.sessionType === "bash" ? " (bash)" : "");
const displayLabel = customName
? `${session.projectName}: ${customName}`
: baseLabel;
const displayLabel = sessionDisplayName(session, project);
const isRenaming = renamingId === session.id;
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import NotesDock from "./NotesDock";
import type { Project, TerminalSession } from "../../lib/types";
vi.mock("../notes/NotesDockPanel", () => ({
default: ({ projectId }: { projectId: string }) => (
<div data-testid="panel">{`panel:${projectId}`}</div>
),
}));
let state: Record<string, unknown> = {};
vi.mock("../../store/appState", () => ({
useAppState: Object.assign(
(selector: (s: unknown) => unknown) => selector(state),
{ getState: () => state },
),
isHomeTab: (k: string) => k.startsWith("home:"),
isTerminalTab: (k: string) => k.startsWith("term:"),
tabKeyId: (k: string) => k.slice(k.indexOf(":") + 1),
// The mocked store module still needs to supply the width constants the
// dock imports from it for the separator's aria-value attributes.
NOTES_DOCK_MIN_WIDTH: 260,
NOTES_DOCK_MAX_WIDTH: 720,
}));
const session: TerminalSession = {
id: "s1",
projectId: "p9",
projectName: "api",
sessionType: "claude",
sessionName: null,
};
beforeEach(() => {
state = {
notesDockOpen: true,
setNotesDockOpen: vi.fn(),
toggleNotesDock: vi.fn(),
notesDockWidth: 352,
setNotesDockWidth: vi.fn(),
activeTabKey: null,
sessions: [session],
projects: [{ id: "p9", name: "api" } as unknown as Project],
};
});
describe("NotesDock", () => {
it("renders nothing when closed", () => {
state.notesDockOpen = false;
const { container } = render(<NotesDock />);
expect(container).toBeEmptyDOMElement();
});
it("follows a project home tab", () => {
state.activeTabKey = "home:p1";
render(<NotesDock />);
expect(screen.getByTestId("panel")).toHaveTextContent("panel:p1");
});
it("follows the project of the active terminal tab", () => {
// The dock exists to be visible while the agent runs, so a terminal tab
// must resolve to its project, not to nothing.
state.activeTabKey = "term:s1";
render(<NotesDock />);
expect(screen.getByTestId("panel")).toHaveTextContent("panel:p9");
});
it("explains itself when no project is active", () => {
state.activeTabKey = null;
render(<NotesDock />);
expect(screen.queryByTestId("panel")).not.toBeInTheDocument();
expect(screen.getByText(/open a project/i)).toBeInTheDocument();
});
it("shows nothing for a terminal whose session has gone", () => {
state.activeTabKey = "term:vanished";
render(<NotesDock />);
expect(screen.queryByTestId("panel")).not.toBeInTheDocument();
});
it("renders at the stored width", () => {
state.activeTabKey = "home:p1";
state.notesDockWidth = 420;
render(<NotesDock />);
expect(screen.getByLabelText("Notes")).toHaveStyle({ width: "420px" });
});
it("has a keyboard-reachable resize handle", () => {
// Drag is a mouse gesture; a separator that only responds to pointer
// events is unusable without one.
state.activeTabKey = "home:p1";
render(<NotesDock />);
const handle = screen.getByRole("separator", { name: /resize notes/i });
fireEvent.keyDown(handle, { key: "ArrowLeft" });
expect(state.setNotesDockWidth).toHaveBeenCalled();
});
it("widens on ArrowLeft and narrows on ArrowRight, by the exact step", () => {
// The dock sits on the right edge, so dragging or pressing left grows it
// and right shrinks it. Asserting only "was called" would pass even if
// the branches were swapped or the sign inverted.
state.activeTabKey = "home:p1";
render(<NotesDock />);
const handle = screen.getByRole("separator", { name: /resize notes/i });
fireEvent.keyDown(handle, { key: "ArrowLeft" });
expect(state.setNotesDockWidth).toHaveBeenLastCalledWith(368);
fireEvent.keyDown(handle, { key: "ArrowRight" });
expect(state.setNotesDockWidth).toHaveBeenLastCalledWith(336);
});
});
+128
View File
@@ -0,0 +1,128 @@
import { useShallow } from "zustand/react/shallow";
import {
useAppState,
isHomeTab,
isTerminalTab,
tabKeyId,
NOTES_DOCK_MIN_WIDTH,
NOTES_DOCK_MAX_WIDTH,
} from "../../store/appState";
import NotesDockPanel from "../notes/NotesDockPanel";
import Button from "../ui/Button";
/**
* Notes beside whatever is on screen.
*
* Project Home and Terminal are sibling top-level tabs, so notes living only
* in a sub-tab would be hidden exactly when the agent is running which is
* when a note is worth sending. The dock is the answer to that.
*
* **It takes space from inside the window and never resizes it.** Growing the
* OS window was tried and rejected on evidence: honoured under XWayland,
* silently corrupting under native Wayland, where `outer_position()` returns a
* confident `Ok(0,0)` for a window that is somewhere else. See the design doc,
* §6.1. Narrowing the terminal instead costs nothing `TerminalView`'s
* ResizeObserver already reflows xterm and resizes the container PTY.
*/
export default function NotesDock() {
const {
notesDockOpen,
setNotesDockOpen,
notesDockWidth,
setNotesDockWidth,
activeTabKey,
sessions,
} = useAppState(
useShallow((s) => ({
notesDockOpen: s.notesDockOpen,
setNotesDockOpen: s.setNotesDockOpen,
notesDockWidth: s.notesDockWidth,
setNotesDockWidth: s.setNotesDockWidth,
activeTabKey: s.activeTabKey,
sessions: s.sessions,
})),
);
// Dragging the separator. Pointer capture rather than window listeners, so
// the drag survives the pointer crossing the terminal — which swallows
// events — and ends correctly if the button is released outside the window.
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
const startX = e.clientX;
const startWidth = notesDockWidth;
// The dock is on the right, so dragging left widens it.
const onMove = (move: PointerEvent) =>
setNotesDockWidth(startWidth + (startX - move.clientX));
const onUp = () => {
handle.releasePointerCapture(e.pointerId);
handle.removeEventListener("pointermove", onMove);
handle.removeEventListener("pointerup", onUp);
};
handle.addEventListener("pointermove", onMove);
handle.addEventListener("pointerup", onUp);
};
const onHandleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const step = e.shiftKey ? 64 : 16;
if (e.key === "ArrowLeft") {
e.preventDefault();
setNotesDockWidth(notesDockWidth + step);
} else if (e.key === "ArrowRight") {
e.preventDefault();
setNotesDockWidth(notesDockWidth - step);
}
};
if (!notesDockOpen) return null;
// Follow whatever is in front: a home tab is its own project, a terminal tab
// is the project it belongs to.
let projectId: string | null = null;
if (activeTabKey && isHomeTab(activeTabKey)) {
projectId = tabKeyId(activeTabKey);
} else if (activeTabKey && isTerminalTab(activeTabKey)) {
projectId =
sessions.find((s) => s.id === tabKeyId(activeTabKey))?.projectId ?? null;
}
return (
<aside
aria-label="Notes"
style={{ width: `${notesDockWidth}px` }}
className="relative flex-shrink-0 flex flex-col min-h-0 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden"
>
{/* Separator, not decoration: it carries a role and arrow keys, because
a resize that only answers to a drag is unavailable to anyone not
using a mouse. */}
<div
role="separator"
aria-label="Resize notes panel"
aria-orientation="vertical"
aria-valuenow={notesDockWidth}
aria-valuemin={NOTES_DOCK_MIN_WIDTH}
aria-valuemax={NOTES_DOCK_MAX_WIDTH}
tabIndex={0}
onPointerDown={onPointerDown}
onKeyDown={onHandleKeyDown}
className="absolute left-0 top-0 h-full w-1.5 cursor-col-resize hover:bg-[var(--accent-muted)] transition-colors"
/>
<div className="flex items-center justify-between gap-2 px-3 h-9 flex-shrink-0 border-b border-[var(--border-color)]">
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">Notes</h2>
<Button variant="ghost" onClick={() => setNotesDockOpen(false)} aria-label="Close notes">
Close
</Button>
</div>
<div className="flex-1 min-h-0">
{projectId ? (
<NotesDockPanel projectId={projectId} />
) : (
<p className="p-4 text-[13px] text-[var(--text-secondary)]">
Open a project or a terminal to see its notes.
</p>
)}
</div>
</aside>
);
}
+19 -8
View File
@@ -10,7 +10,7 @@ interface Props {
export default function StatusBar({ stt }: Props) {
const {
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
terminalAtBottom, scrollActiveToBottom,
notesDockOpen, toggleNotesDock, terminalMouseCaptured, releaseActiveMouse,
} = useAppState(
useShallow(s => ({
projects: s.projects,
@@ -18,8 +18,10 @@ export default function StatusBar({ stt }: Props) {
terminalHasSelection: s.terminalHasSelection,
activeSessionId: s.activeSessionId,
sttEnabled: s.appSettings?.stt?.enabled,
terminalAtBottom: s.terminalAtBottom,
scrollActiveToBottom: s.scrollActiveToBottom,
notesDockOpen: s.notesDockOpen,
toggleNotesDock: s.toggleNotesDock,
terminalMouseCaptured: s.terminalMouseCaptured,
releaseActiveMouse: s.releaseActiveMouse,
}))
);
const running = projects.filter((p) => p.status === "running").length;
@@ -58,17 +60,26 @@ export default function StatusBar({ stt }: Props) {
</span>
</>
)}
{/* Right-aligned controls: Jump to Current + STT mic */}
{/* Right-aligned controls: mouse release + Notes + STT mic */}
<div className="ml-auto flex items-center gap-3 pl-2">
{activeSessionId && !terminalAtBottom && (
{activeSessionId && terminalMouseCaptured && (
<button
onClick={() => scrollActiveToBottom()}
data-mouse-release="true"
onClick={() => releaseActiveMouse()}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
title="Scroll the terminal to the latest output"
title="A program in the container is reading the mouse, so clicks and drags go to it instead of selecting text. Click, or press Ctrl+Shift+X, to take it back. To select text without taking it back, hold Shift while dragging (Option on macOS)."
>
Jump to Current
🖱 Mouse captured release
</button>
)}
<button
onClick={toggleNotesDock}
aria-pressed={notesDockOpen}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
title="Show or hide the notes panel beside the current tab"
>
Notes
</button>
{sttEnabled && activeSessionId && (
<SttButton
state={stt.state}
+71
View File
@@ -0,0 +1,71 @@
import SendToAgentButton from "./SendToAgentButton";
import Button from "../ui/Button";
interface Props {
projectId: string;
title: string;
body: string;
onTitleChange: (value: string) => void;
onBodyChange: (value: string) => void;
onCommit: () => void;
onDelete: () => void;
}
/**
* Title and body, saved when a field loses focus.
*
* Plain text on purpose. There is no markdown rendering and no view/edit split,
* so there is no moment where the text on screen is not the text that would be
* sent which is what makes "the agent gets exactly what you see" true rather
* than nearly true.
*/
export default function NoteEditor({
projectId,
title,
body,
onTitleChange,
onBodyChange,
onCommit,
onDelete,
}: Props) {
return (
<div className="flex flex-col h-full min-h-0 gap-2 p-3">
{/* Wraps rather than overflows. The two buttons are a group with a fixed
appetite (~190px) and the title field can shrink only so far, so in a
narrow dock the title takes the first row and the buttons the second.
Without the wrap the group is simply clipped by the dock's
`overflow-hidden`, which puts Delete off-window with no scrollbar to
reach it. */}
<div className="flex flex-wrap items-center gap-2">
<input
value={title}
onChange={(e) => onTitleChange(e.target.value)}
onBlur={onCommit}
placeholder="Note title"
aria-label="Note title"
className="flex-1 min-w-24 px-2 h-8 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"
/>
<div className="flex items-center gap-2 flex-shrink-0">
{/* The live editor text, not `note.body` what is on screen is what
gets sent. */}
<SendToAgentButton projectId={projectId} body={body} />
<Button variant="danger" onClick={onDelete} aria-label="Delete note">
Delete
</Button>
</div>
</div>
<textarea
value={body}
onChange={(e) => onBodyChange(e.target.value)}
onBlur={onCommit}
placeholder="Reminders, gotchas, a prompt worth keeping…"
aria-label="Note body"
className="flex-1 min-h-0 w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] resize-none font-mono transition-colors"
/>
<p className="text-xs text-[var(--text-secondary)]">
Notes save when a field loses focus. Sending puts the note in the agent&rsquo;s
prompt you press Enter.
</p>
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,175 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within, fireEvent, waitFor } from "@testing-library/react";
import NotesPanel from "./NotesPanel";
import NotesDockPanel from "./NotesDockPanel";
import { useAppState } from "../../store/appState";
import type { Note } from "../../lib/types";
/**
* Two panels, one project the configuration the app actually runs in.
*
* `NotesTab` mounts a `NotesPanel` and `NotesDock` mounts a `NotesDockPanel`,
* and the dock follows the active tab's project, so opening the dock over a
* Project Home tab mounts both for the *same* project. Every other notes test
* mounts exactly one, which is precisely the configuration in which a
* per-panel cache looks correct: it is only with two that an edit made in one
* is seen or lost by the other. `useNotes` is deliberately **not** mocked
* 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[]> = {};
vi.mock("../../lib/tauri-commands", () => ({
listNotes: async (p: string) => [...(files[p] ?? [])],
saveNote: async (p: string, n: Note) => {
const list = files[p] ?? (files[p] = []);
const at = list.findIndex((x) => x.id === n.id);
if (at === -1) list.unshift(n);
else list[at] = n;
return n;
},
deleteNote: async (p: string, id: string) => {
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
},
}));
vi.mock("./SendToAgentButton", () => ({
default: () => <button type="button">Send to agent</button>,
}));
const note = (over: Partial<Note> = {}): Note => ({
id: "n1",
title: "Deploy steps",
body: "one",
pinned: false,
created_at: "2026-09-01T00:00:00Z",
updated_at: "2026-09-01T00:00:00Z",
...over,
});
/** The tab and the dock, mounted together the way `App` mounts them. */
function renderBothSurfaces() {
render(
<>
<div data-testid="tab">
<NotesPanel projectId="p1" />
</div>
<div data-testid="dock">
<NotesDockPanel projectId="p1" />
</div>
</>,
);
return {
tab: () => within(screen.getByTestId("tab")),
dock: () => within(screen.getByTestId("dock")),
};
}
beforeEach(() => {
for (const key of Object.keys(files)) delete files[key];
files.p1 = [note()];
useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] });
});
describe("the tab and the dock both open on one project", () => {
it("shows an edit made in one surface in the other", async () => {
const { tab, dock } = renderBothSurfaces();
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
const dockTitle = dock().getByLabelText("Note title");
fireEvent.change(dockTitle, { target: { value: "Deploy steps v2" } });
fireEvent.blur(dockTitle);
// The other surface's list *and* its editor, not just one of them.
await waitFor(() =>
expect(tab().getByRole("button", { name: /deploy steps v2/i })).toBeInTheDocument(),
);
expect(tab().getByLabelText("Note title")).toHaveValue("Deploy steps v2");
});
it("does not write one surface's stale copy over the other's edit", async () => {
// The reported repro: edit in the dock, then go back to the tab and edit
// there. With a cache per panel, the tab committed `{...staleNote, ...}`
// and the dock's edit was gone from disk with no error and no indicator.
const { tab, dock } = renderBothSurfaces();
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
const dockTitle = dock().getByLabelText("Note title");
fireEvent.change(dockTitle, { target: { value: "Deploy steps v2" } });
fireEvent.blur(dockTitle);
await waitFor(() => expect(files.p1[0].title).toBe("Deploy steps v2"));
const tabBody = tab().getByLabelText("Note body");
fireEvent.change(tabBody, { target: { value: "two" } });
fireEvent.blur(tabBody);
await waitFor(() => expect(files.p1[0].body).toBe("two"));
expect(files.p1).toHaveLength(1);
expect(files.p1[0].title).toBe("Deploy steps v2");
});
it("reads the project once for both surfaces", async () => {
// Two panels are two `useNotes`, but the in-flight flag is per project, so
// mounting the dock over an open Notes tab does not re-read the file.
const listNotes = vi.spyOn(
await import("../../lib/tauri-commands"),
"listNotes",
);
renderBothSurfaces();
await waitFor(() =>
expect(screen.getAllByLabelText("Note body")[0]).toHaveValue("one"),
);
expect(listNotes).toHaveBeenCalledTimes(1);
listNotes.mockRestore();
});
it("keeps text the user is part-way through typing when the other surface saves", async () => {
// Showing a remote edit must never mean discarding an unsaved local one.
const { tab, dock } = renderBothSurfaces();
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
const tabBody = tab().getByLabelText("Note body");
fireEvent.change(tabBody, { target: { value: "half-typed" } });
const dockBody = dock().getByLabelText("Note body");
fireEvent.change(dockBody, { target: { value: "saved in the dock" } });
fireEvent.blur(dockBody);
await waitFor(() => expect(files.p1[0].body).toBe("saved in the dock"));
expect(tabBody).toHaveValue("half-typed");
});
it("falls back to another note when the selected one is deleted", async () => {
// The claim a differently-named test in NotesPanel.test.tsx used to make
// and could not keep: `useNotes` is mocked there and its list never
// changes, so the fallback was invisible. Here the list is real.
files.p1 = [note(), note({ id: "n2", title: "Gotchas", body: "beware" })];
const { tab } = renderBothSurfaces();
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
fireEvent.click(tab().getByRole("button", { name: /delete note/i }));
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("beware"));
expect(tab().queryByRole("button", { name: /deploy steps/i })).not.toBeInTheDocument();
expect(files.p1).toHaveLength(1);
});
it("shows a note created in one surface in the other", async () => {
const { tab, dock } = renderBothSurfaces();
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
// 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(() =>
expect(tab().getAllByRole("button", { name: /untitled note/i })).toHaveLength(1),
);
expect(files.p1).toHaveLength(2);
});
});
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import NotesPanel from "./NotesPanel";
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,
}),
}));
vi.mock("./SendToAgentButton", () => ({
default: ({ body }: { body: string }) => (
<button type="button" data-testid="send">{`send:${body}`}</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();
notes = [];
loading = false;
});
describe("NotesPanel", () => {
it("invites the user to start when there are no notes", () => {
render(<NotesPanel projectId="p1" />);
expect(screen.getByText(/no notes yet/i)).toBeInTheDocument();
});
it("lists notes by title and selects the first", () => {
notes = [note(), note({ id: "n2", title: "Gotchas" })];
render(<NotesPanel projectId="p1" />);
expect(screen.getByRole("button", { name: /deploy steps/i })).toBeInTheDocument();
expect(screen.getByLabelText("Note body")).toHaveValue("one\ntwo");
});
it("shows an untitled note under a placeholder rather than a blank row", () => {
notes = [note({ title: "" })];
render(<NotesPanel projectId="p1" />);
expect(screen.getByRole("button", { name: /untitled note/i })).toBeInTheDocument();
});
it("switches the editor when another note is selected", () => {
notes = [note(), note({ id: "n2", title: "Gotchas", body: "beware" })];
render(<NotesPanel projectId="p1" />);
fireEvent.click(screen.getByRole("button", { name: /gotchas/i }));
expect(screen.getByLabelText("Note body")).toHaveValue("beware");
});
it("saves on blur, not on every keystroke", async () => {
notes = [note()];
render(<NotesPanel projectId="p1" />);
const body = screen.getByLabelText("Note body");
fireEvent.change(body, { target: { value: "edited" } });
expect(saveNote).not.toHaveBeenCalled();
fireEvent.blur(body);
await waitFor(() => expect(saveNote).toHaveBeenCalledWith(
expect.objectContaining({ id: "n1", body: "edited" }),
));
});
it("does not save on blur when nothing changed", async () => {
// Clicking through notes to read them must not write the file.
notes = [note()];
render(<NotesPanel projectId="p1" />);
fireEvent.blur(screen.getByLabelText("Note body"));
await waitFor(() => expect(saveNote).not.toHaveBeenCalled());
});
it("hands the live editor text to the send button, not the last saved copy", () => {
// Sending what is on screen is the whole contract: no transform on the way
// out except the newline substitution.
notes = [note()];
render(<NotesPanel projectId="p1" />);
fireEvent.change(screen.getByLabelText("Note body"), { target: { value: "fresh" } });
expect(screen.getByTestId("send")).toHaveTextContent("send:fresh");
});
it("asks the hook to delete the selected note", async () => {
// Only the call: `useNotes` is mocked here and the mocked list never
// changes, so nothing in this file can exercise what the panel selects
// afterwards. The fallback is covered against the real hook in
// NotesPanel.shared.test.tsx.
notes = [note(), note({ id: "n2", title: "Gotchas" })];
render(<NotesPanel projectId="p1" />);
fireEvent.click(screen.getByRole("button", { name: /delete note/i }));
await waitFor(() => expect(deleteNote).toHaveBeenCalledWith("n1"));
});
});
+115
View File
@@ -0,0 +1,115 @@
import { useMemo, useState } from "react";
import { useNotes } from "../../hooks/useNotes";
import { useNoteDraft } from "./useNoteDraft";
import NoteEditor from "./NoteEditor";
import Button from "../ui/Button";
import SaveIndicator from "../ui/SaveIndicator";
interface Props {
projectId: string;
}
const UNTITLED = "Untitled note";
/**
* The notes surface itself, shared by the Project Home tab and the dock so the
* two cannot drift into different behaviour.
*
* Master/detail: titles beside the editor when there is room, stacked above it
* when there is not. That is a **container** query, not a viewport one, because
* the two surfaces differ in width while sharing a viewport the dock opens at
* 352px and the tab is the width of the main area. A `md:` breakpoint would
* read the window and give both the same answer, which is the wrong answer for
* one of them.
*
* The threshold is arithmetic, not taste: side by side needs the 192px list,
* plus an editor wide enough for its own action row (~280px), plus the divider.
* Below ~473px the editor is narrower than its buttons, so `@lg` (512px) is the
* first stop that clears it.
*
* The editor holds draft text locally and commits on blur, which is how every
* other editable field in the app behaves (`ClaudeInstructionsEditor`, the
* Config tab).
*/
export default function NotesPanel({ 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>
);
}
return (
<div className="@container flex flex-col h-full min-h-0">
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-[var(--border-color)]">
<Button variant="primary" onClick={onCreate}>
New note
</Button>
<SaveIndicator state={saveState} />
</div>
{notes.length === 0 ? (
<div className="flex-1 flex items-center justify-center p-4">
<p className="text-[13px] text-[var(--text-secondary)] text-center">
No notes yet. Keep reminders here, and send any of them straight to a
running Claude session.
</p>
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col @lg:flex-row">
{/* Stacked: a capped strip of titles above the editor, so the note
being written keeps most of the height. Side by side: a full-height
column of the fixed width the editor's arithmetic assumes. */}
<ul className="flex-shrink-0 overflow-y-auto py-1 max-h-32 border-b @lg:max-h-none @lg:w-48 @lg:border-b-0 @lg:border-r border-[var(--border-color)]">
{notes.map((n) => (
<li key={n.id}>
<button
type="button"
onClick={() => setSelectedId(n.id)}
className={`w-full text-left px-3 py-1.5 text-xs truncate transition-colors ${
selected?.id === n.id
? "bg-[var(--bg-tertiary)] text-[var(--text-primary)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{n.title.trim() || UNTITLED}
</button>
</li>
))}
</ul>
<div className="flex-1 min-w-0">
{selected && (
<NoteEditor
projectId={projectId}
title={title}
body={body}
onTitleChange={setTitle}
onBodyChange={setBody}
onCommit={commit}
onDelete={() => void deleteNote(selected.id)}
/>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,191 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import SendToAgentButton from "./SendToAgentButton";
import type { Project, TerminalSession } from "../../lib/types";
const sendInput = vi.fn(async () => {});
let sessions: TerminalSession[] = [];
vi.mock("../../hooks/useTerminal", () => ({
useTerminal: () => ({ sessions, sendInput }),
}));
const setActiveTabKey = vi.fn();
const requestTerminalFocus = vi.fn();
const pushToast = vi.fn();
let projects: Project[] = [];
vi.mock("../../store/appState", () => ({
useAppState: Object.assign(
(selector: (s: unknown) => unknown) =>
selector({ projects, setActiveTabKey, requestTerminalFocus, pushToast }),
{
getState: () => ({
projects,
setActiveTabKey,
requestTerminalFocus,
pushToast,
}),
},
),
terminalTabKey: (id: string) => `term:${id}`,
}));
const session = (over: Partial<TerminalSession> = {}): TerminalSession => ({
id: "s1",
projectId: "p1",
projectName: "api",
sessionType: "claude",
sessionName: null,
...over,
});
beforeEach(() => {
vi.clearAllMocks();
sessions = [];
projects = [{ id: "p1", name: "api", renamed_session_names: {} } as unknown as Project];
});
describe("SendToAgentButton", () => {
// 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" />);
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("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" })];
render(<SendToAgentButton projectId="p1" body="hello" />);
expect(
screen.getByRole("button", { name: /send to agent/i }),
).toHaveAttribute("aria-disabled", "true");
});
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
// shell is never a target.
sessions = [session({ sessionType: "bash" })];
render(<SendToAgentButton projectId="p1" body="hello" />);
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 () => {
sessions = [session()];
render(<SendToAgentButton projectId="p1" body={"one\ntwo"} />);
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
await waitFor(() => expect(sendInput).toHaveBeenCalledWith("s1", "one\x1b\rtwo"));
expect(sendInput.mock.calls[0][1].endsWith("\r")).toBe(false);
});
it("focuses the terminal it sent to, so the user watches it land", async () => {
sessions = [session()];
render(<SendToAgentButton projectId="p1" body="hi" />);
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
await waitFor(() => expect(setActiveTabKey).toHaveBeenCalledWith("term:s1"));
});
it("offers a menu of display names when several sessions are open", async () => {
sessions = [session(), session({ id: "s2", sessionName: "review" })];
projects = [
{ id: "p1", name: "api", renamed_session_names: { s1: "release" } } as unknown as Project,
];
render(<SendToAgentButton projectId="p1" body="hi" />);
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
expect(sendInput).not.toHaveBeenCalled();
fireEvent.click(await screen.findByRole("menuitem", { name: "api: release" }));
await waitFor(() => expect(sendInput).toHaveBeenCalledWith("s1", "hi"));
});
it("reports a failed send rather than looking like it worked", async () => {
sessions = [session()];
sendInput.mockRejectedValueOnce(new Error("session closed"));
render(<SendToAgentButton projectId="p1" body="hi" />);
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
await waitFor(() => expect(pushToast).toHaveBeenCalled());
});
it("does nothing for an empty note", () => {
sessions = [session()];
render(<SendToAgentButton projectId="p1" body=" " />);
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"));
});
});
@@ -0,0 +1,168 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useTerminal } from "../../hooks/useTerminal";
import { useAppState, terminalTabKey } from "../../store/appState";
import { toClaudePayload } from "../../lib/claudeInput";
import { sessionDisplayName } from "../../lib/sessionName";
import Button from "../ui/Button";
interface Props {
projectId: 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;
}
/**
* Puts a note into a running Claude session's prompt.
*
* Three behaviours by target count: none disables the button, one sends
* straight there, several ask which. It never guesses the note goes to a
* session the user named, or to the only one there is.
*
* 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`).
*/
export default function SendToAgentButton({
projectId,
body,
dropUp = false,
fullWidth = false,
}: Props) {
const { sessions, sendInput } = useTerminal();
const { projects, setActiveTabKey, requestTerminalFocus, pushToast } =
useAppState(
useShallow((s) => ({
projects: s.projects,
setActiveTabKey: s.setActiveTabKey,
requestTerminalFocus: s.requestTerminalFocus,
pushToast: s.pushToast,
})),
);
const [menuOpen, setMenuOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const targets = useMemo(
() =>
sessions.filter(
(s) => s.projectId === projectId && s.sessionType === "claude",
),
[sessions, projectId],
);
const project = projects.find((p) => p.id === projectId);
const hasBody = body.trim().length > 0;
const unavailable = targets.length === 0 || !hasBody;
// Same dismissal contract as `ui/OverflowMenu` and the tab context menu.
useEffect(() => {
if (!menuOpen) return;
const onDocClick = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMenuOpen(false);
};
document.addEventListener("mousedown", onDocClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("keydown", onKey);
};
}, [menuOpen]);
const send = useCallback(
async (sessionId: string) => {
setMenuOpen(false);
try {
// No trailing CR: the note lands in the prompt and the user presses
// Enter. Newlines become ESC+CR so it arrives as one message rather
// than one prompt per line.
await sendInput(sessionId, toClaudePayload(body));
// A courtesy, not part of the send: if the tab cannot be focused the
// text still went.
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) {
pushToast({
kind: "error",
message: "Could not send the note to the agent",
detail: String(e),
});
}
},
[body, sendInput, setActiveTabKey, requestTerminalFocus, pushToast],
);
const onClick = useCallback(() => {
// The target is resolved at click time and pinned for the whole send, the
// hazard `useSTT` guards against by capturing its session at record start:
// the list can change while the request is in flight.
if (targets.length === 1) {
void send(targets[0].id);
return;
}
setMenuOpen((open) => !open);
}, [targets, send]);
const title = !hasBody
? "Nothing to send — this note is empty"
: targets.length === 0
? "No running Claude session for this project"
: "Put this note into the agent's prompt (you press Enter)";
return (
<div
ref={rootRef}
className={`relative ${fullWidth ? "block w-full" : "inline-block"}`}
>
<Button
variant="secondary"
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}
aria-haspopup={targets.length > 1 ? "menu" : undefined}
aria-expanded={targets.length > 1 ? menuOpen : undefined}
title={title}
>
Send to agent
</Button>
{menuOpen && targets.length > 1 && (
<div
role="menu"
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)" }}
>
{targets.map((s) => (
<button
key={s.id}
type="button"
role="menuitem"
onClick={() => void send(s.id)}
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
{sessionDisplayName(s, project)}
</button>
))}
</div>
)}
</div>
);
}
+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,20 @@
import type { Project } from "../../../lib/types";
import NotesPanel from "../../notes/NotesPanel";
interface Props {
project: Project;
}
/**
* Notes as a Project Home sub-tab.
*
* The same panel the dock shows. This is the roomy view for writing; the dock
* is the one that stays visible while the agent works.
*/
export default function NotesTab({ project }: Props) {
return (
<div className="h-full min-h-0">
<NotesPanel projectId={project.id} />
</div>
);
}
@@ -18,6 +18,7 @@ import AutomationTab from "./AutomationTab";
import ConfigTab from "./ConfigTab";
import FilesTab from "./FilesTab";
import BrowserTab from "./BrowserTab";
import NotesTab from "./NotesTab";
import { formatUptime } from "./format";
import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport";
@@ -28,6 +29,7 @@ const TABS = [
{ id: "config", label: "Config" },
{ id: "files", label: "Files" },
{ id: "browser", label: "Browser" },
{ id: "notes", label: "Notes" },
] as const;
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
@@ -255,6 +257,7 @@ export default function ProjectHome({ projectId, active }: Props) {
{tab === "browser" && (
<BrowserTab project={project} active={active && tab === "browser"} />
)}
{tab === "notes" && <NotesTab project={project} />}
</div>
{showMigration && (
@@ -3,6 +3,7 @@ import { render, fireEvent, cleanup, act } from "@testing-library/react";
import TerminalView, { supersedes } from "./TerminalView";
import { useAppState } from "../../store/appState";
import { uploadHostFileToTerminal } from "../../lib/tauri-commands";
import { URL_TOAST_SELECTOR } from "./UrlToast";
/**
* The window-wide native drag-drop listener, captured at registration.
@@ -370,15 +371,34 @@ describe("TerminalView — where a dropped file lands", () => {
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1);
});
it("uploads a file dropped onto the always-present Following toggle", async () => {
// The regression this file could not see. The toggle is `absolute top-2
// right-4 z-50` and is rendered unconditionally, so `elementFromPoint`
// returns *it* for the terminal's top-right corner — and a gate asking
it("uploads a file dropped onto the chrome painted over the terminal", async () => {
// The regression this file could not see. Chrome like the URL toast is a
// *sibling* of the xterm host painted over the pane, so
// `elementFromPoint` returns it rather than the host — and a gate asking
// "is what is painted here inside the xterm host?" answered no, forever,
// with no message and no log line. jsdom never ran that branch.
const view = await mountWithLayout();
const toggle = view.getByTitle(/Auto-scroll/i);
stubElementFromPoint(toggle);
//
// The original fixture was the always-rendered "▼ Following" toggle. That
// control is retired and the mouse-release button that could have replaced
// it lives in the status bar now, so the toast is what stands in — it is
// real chrome over the pane, which is the only property under test.
await mountWithLayout();
const emit = ptyOutput.listeners.get("terminal-output-s1");
if (!emit) throw new Error("no terminal-output listener registered");
await act(async () => {
emit({
payload: Array.from(
new TextEncoder().encode(
`\x1b]7777;open;${btoa("https://example.com/x")}\x07`,
),
),
});
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
const toast = document.querySelector(URL_TOAST_SELECTOR);
if (!toast) throw new Error("URL toast not shown");
stubElementFromPoint(toast);
await drop(780, 10);
@@ -538,3 +558,144 @@ describe("TerminalView — reaching the URL prompt without a mouse", () => {
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));
});
});
describe("TerminalView — releasing a captured mouse", () => {
/** Feed raw bytes to the terminal as if the container had printed them, and
* let xterm drain its write queue (it parses asynchronously). */
async function emitBytes(text: string) {
const emit = ptyOutput.listeners.get("terminal-output-s1");
if (!emit) throw new Error("no terminal-output listener registered");
await act(async () => {
emit({ payload: Array.from(new TextEncoder().encode(text)) });
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
/** What the status bar would render from: the active terminal publishes the
* capture state, and the release action, into the store. The control itself
* lives in `StatusBar` deliberately, so it never sits on top of the TUI
* that is asking for the mouse. */
function captured(): boolean {
return useAppState.getState().terminalMouseCaptured;
}
it("shows nothing while the container has not grabbed the mouse", async () => {
mountSession("claude");
await act(async () => {});
expect(captured()).toBe(false);
});
it("surfaces a release control once the container turns mouse tracking on", async () => {
// `?1003h` is any-event tracking: every mouse *move* over the terminal is
// reported to the app. When the TUI that asked for it dies without
// resetting the mode, xterm keeps routing moves to the PTY and drops text
// selection — the freeze this control exists to break out of.
mountSession("claude");
await act(async () => {});
await emitBytes("\x1b[?1003h\x1b[?1006h");
expect(captured()).toBe(true);
});
it("clears the mode locally, without sending a byte to the container", async () => {
// The reset is written into xterm's own parser, not onto the wire. The
// program inside is usually gone; if it is not, it must not be told the
// user pulled the mouse back, or a live TUI would just re-grab it.
mountSession("claude");
await act(async () => {});
await emitBytes("\x1b[?1003h");
terminalInput.mockClear();
// Exactly what the status-bar button's onClick does.
const release = useAppState.getState().releaseActiveMouse;
await act(async () => {
release();
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
// The published flag is bound to the live mode, so it going false *is* the
// assertion that xterm's mouse tracking is back to "none".
expect(captured()).toBe(false);
expect(terminalInput).not.toHaveBeenCalled();
});
it("releases on Ctrl+Shift+X, for when the pointer itself is unusable", async () => {
const { container } = mountSession("claude");
await act(async () => {});
await emitBytes("\x1b[?1002h");
terminalInput.mockClear();
await act(async () => {
fireEvent.keyDown(helperTextarea(container), {
key: "X",
ctrlKey: true,
shiftKey: true,
});
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
expect(captured()).toBe(false);
// The chord must not also reach the container as input.
expect(terminalInput).not.toHaveBeenCalled();
});
});
+122 -126
View File
@@ -7,6 +7,7 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState";
import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput";
import {
awsSsoRefresh,
openPageInContainerBrowser,
@@ -98,8 +99,8 @@ export default function TerminalView({ sessionId, active }: Props) {
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
const setTerminalMouseCaptured = useAppState(s => s.setTerminalMouseCaptured);
const setReleaseActiveMouse = useAppState(s => s.setReleaseActiveMouse);
const ssoBufferRef = useRef("");
const ssoTriggeredRef = useRef(false);
@@ -218,14 +219,11 @@ export default function TerminalView({ sessionId, active }: Props) {
return () => document.removeEventListener("keydown", onKeyDown, true);
}, []);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [isAutoFollow, setIsAutoFollow] = useState(true);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const isAtBottomRef = useRef(true);
// Tracks user intent to follow output — only set to false by explicit user
// actions (mouse wheel up), not by xterm scroll events during writes.
const autoFollowRef = useRef(true);
const lastUserScrollTimeRef = useRef(0);
// True while the program in the container holds mouse reporting open (any of
// the DECSET ?1000/?1002/?1003 tracking modes). See `syncMouseCapture`.
const [mouseCaptured, setMouseCaptured] = useState(false);
const mouseCapturedRef = useRef(false);
// Keep latest `active` readable inside long-lived listeners (drag-drop below,
// and the unmount-cleanup effect further down).
@@ -250,10 +248,10 @@ export default function TerminalView({ sessionId, active }: Props) {
//
// The rect asked about is the **pane wrapper**, not the xterm host inside it:
// the pane is what the user sees as "the terminal", gutter included, and the
// chrome painted over it (the Following toggle, the URL toast) is a sibling
// of the host rather than a child. Nothing painted over the pane refuses a
// drop on its own account — asking "is this element mine?" once turned every
// pixel under that chrome into a permanent dead zone.
// chrome painted over it (the mouse-release badge, the URL toast) is a
// sibling of the host rather than a child. Nothing painted over the pane
// refuses a drop on its own account — asking "is this element mine?" once
// turned every pixel under that chrome into a permanent dead zone.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
@@ -314,12 +312,60 @@ export default function TerminalView({ sessionId, active }: Props) {
};
}, [sessionId, sendInput]);
/**
* Reconcile the badge with xterm's live mouse-tracking mode.
*
* There is no event for this, but there does not need to be a poll either:
* the mode only ever changes because the container printed a DECSET/DECRST
* sequence, so checking once per write covers every transition, exactly when
* it happens. The ref gate keeps the common case (mode unchanged, thousands
* of writes a second) down to one string comparison and no re-render.
*/
const syncMouseCapture = useCallback(() => {
const term = termRef.current;
if (!term) return;
const captured = term.modes.mouseTrackingMode !== "none";
if (captured === mouseCapturedRef.current) return;
mouseCapturedRef.current = captured;
setMouseCaptured(captured);
}, []);
/**
* Take the mouse back from a program that grabbed it and never let go.
*
* A TUI that dies mid-menu (or is killed, or detaches) leaves its mouse
* tracking modes set. xterm goes on routing clicks, drags and under
* `?1003` every pointer *move* to the PTY, which kills text selection and
* floods the prompt with escape bytes. The result reads as a frozen
* terminal, and until now the only exit was closing the tab.
*
* The reset is `term.write`, deliberately, not `sendInput`: it goes into
* xterm's own parser and never onto the wire. The program that asked for
* tracking is usually already gone; if it is not, telling it the user pulled
* the mouse back would only invite it to grab again on its next repaint.
*/
const releaseMouse = useCallback(() => {
const term = termRef.current;
if (!term) return;
// The three tracking modes, then the two encodings they report in. All
// five, because a program is free to have set any combination and a
// leftover encoding mode outlives the tracking mode that motivated it.
term.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l", syncMouseCapture);
}, [syncMouseCapture]);
useEffect(() => {
if (!containerRef.current) return;
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
// Let the user select text even while a program holds the mouse.
// xterm's force-selection modifier is Shift everywhere *except* macOS,
// where it is Option and is gated behind this option, which defaults to
// false — so without this line Mac users have no force-select at all and
// the only way to copy from a mouse-driven TUI is to take the mouse back
// first. `SelectionService.shouldForceSelection`.
macOptionClickForcesSelection: true,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
theme: {
background: "#0d1117",
@@ -390,6 +436,14 @@ export default function TerminalView({ sessionId, active }: Props) {
useAppState.getState().sttToggle();
return false;
}
// Ctrl+Shift+X hands the mouse back. Same action as the badge, bound to
// a key because the failure this recovers from is *the pointer not
// working* — a control you have to click can be unreachable in exactly
// the situation that calls for it.
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "X") {
releaseMouse();
return false;
}
// Shift+Enter inserts a newline in Claude Code's prompt instead of
// submitting it. xterm.js does not consult `shiftKey` for Enter
// (`Keyboard.ts`, `case 13`), so without this branch Shift+Enter is
@@ -415,7 +469,7 @@ export default function TerminalView({ sessionId, active }: Props) {
!event.isComposing &&
sessionTypeRef.current === "claude"
) {
sendInput(sessionId, "\x1b\r");
sendInput(sessionId, CLAUDE_SOFT_NEWLINE);
// **`preventDefault()` is what stops the submit, not the `return false`.**
//
// xterm's `_keyDown` returns the instant a custom handler says `false`
@@ -500,42 +554,6 @@ export default function TerminalView({ sessionId, active }: Props) {
);
});
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
// Captured during capture phase so it fires before xterm's own handler.
const handleWheel = (e: WheelEvent) => {
lastUserScrollTimeRef.current = Date.now();
if (e.deltaY < 0) {
autoFollowRef.current = false;
setIsAutoFollow(false);
isAtBottomRef.current = false;
setIsAtBottom(false);
}
};
containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true });
// Track scroll position to show "Jump to Current" button.
// Debounce state updates via rAF to avoid excessive re-renders during rapid output.
let scrollStateRafId: number | null = null;
const scrollDisposable = term.onScroll(() => {
const buf = term.buffer.active;
const atBottom = buf.viewportY >= buf.baseY;
isAtBottomRef.current = atBottom;
// Re-enable auto-follow only when USER scrolls to bottom (not write-triggered)
const isUserScroll = (Date.now() - lastUserScrollTimeRef.current) < 300;
if (atBottom && isUserScroll && !autoFollowRef.current) {
autoFollowRef.current = true;
setIsAutoFollow(true);
}
if (scrollStateRafId === null) {
scrollStateRafId = requestAnimationFrame(() => {
scrollStateRafId = null;
setIsAtBottom(isAtBottomRef.current);
});
}
});
// Track text selection to show copy hint in status bar
const selectionDisposable = term.onSelectionChange(() => {
setTerminalHasSelection(term.hasSelection());
@@ -598,15 +616,11 @@ export default function TerminalView({ sessionId, active }: Props) {
const outputPromise = onOutput(sessionId, (data) => {
if (aborted) return;
term.write(data, () => {
if (autoFollowRef.current) {
term.scrollToBottom();
if (!isAtBottomRef.current) {
isAtBottomRef.current = true;
setIsAtBottom(true);
}
}
});
// Scrolling on new output is xterm's own job, and it already gets it
// right: it follows the tail while the viewport is at the bottom and
// holds position while you are reading further up. The manual
// `scrollToBottom()` that used to live here fought that second half.
term.write(data, syncMouseCapture);
detector.feed(data);
// Scan for SSO refresh marker in terminal output
@@ -648,11 +662,18 @@ export default function TerminalView({ sessionId, active }: Props) {
resizeRafId = requestAnimationFrame(() => {
resizeRafId = null;
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
// Whether the viewport was following the tail has to be sampled
// *before* the fit: reflowing wrapped lines moves `baseY`, so asking
// afterwards cannot tell "was at the bottom" from "was pushed off it".
const wasAtBottom =
term.buffer.active.viewportY >= term.buffer.active.baseY;
fitAddon.fit();
resize(sessionId, term.cols, term.rows);
if (autoFollowRef.current) {
term.scrollToBottom();
}
// Only re-anchor a viewport that was already on the tail. This
// observer fires for any pane size change — opening the Notes dock,
// dragging the sidebar, resizing the window — and none of those are a
// reason to yank someone away from the scrollback they are reading.
if (wasAtBottom) term.scrollToBottom();
});
});
resizeObserver.observe(containerRef.current);
@@ -666,14 +687,11 @@ export default function TerminalView({ sessionId, active }: Props) {
osc52Disposable.dispose();
relayDisposable.dispose();
inputDisposable.dispose();
scrollDisposable.dispose();
selectionDisposable.dispose();
setTerminalHasSelection(false);
containerRef.current?.removeEventListener("wheel", handleWheel, { capture: true });
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
outputPromise.then((fn) => fn?.());
exitPromise.then((fn) => fn?.());
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
resizeObserver.disconnect();
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
@@ -722,14 +740,31 @@ export default function TerminalView({ sessionId, active }: Props) {
}
if (active) {
// Same rule as the resize observer: re-anchor only what was already
// anchored, so a tab left scrolled up comes back where it was left.
const wasAtBottom =
term.buffer.active.viewportY >= term.buffer.active.baseY;
fitRef.current?.fit();
if (autoFollowRef.current) {
term.scrollToBottom();
}
if (wasAtBottom) term.scrollToBottom();
term.focus();
}
}, [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.
// 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
@@ -810,39 +845,6 @@ export default function TerminalView({ sessionId, active }: Props) {
);
}, [urlPrompt, projectId, dismissUrlPrompt]);
const handleScrollToBottom = useCallback(() => {
const term = termRef.current;
if (term) {
autoFollowRef.current = true;
setIsAutoFollow(true);
fitRef.current?.fit();
term.scrollToBottom();
isAtBottomRef.current = true;
setIsAtBottom(true);
}
}, []);
// Surface this terminal's scroll state to the status bar's "Jump to Current"
// control, but only while it's the active (visible) terminal.
useEffect(() => {
if (!active) return;
setTerminalAtBottom(isAtBottom);
setScrollActiveToBottom(handleScrollToBottom);
}, [active, isAtBottom, handleScrollToBottom, setTerminalAtBottom, setScrollActiveToBottom]);
// On unmount, if this was the active terminal, clear the status-bar scroll
// state so it doesn't point at a disposed terminal. (Tab switches don't
// unmount — the deactivating terminal stays mounted but hidden — so this
// only fires when the active session is actually closed.)
useEffect(() => {
return () => {
if (activeRef.current) {
setTerminalAtBottom(true);
setScrollActiveToBottom(() => {});
}
};
}, [setTerminalAtBottom, setScrollActiveToBottom]);
const writeSelection = useCallback((mode: "trimmed" | "raw") => {
const term = termRef.current;
if (!term) return;
@@ -860,20 +862,26 @@ export default function TerminalView({ sessionId, active }: Props) {
setContextMenu({ x: e.clientX, y: e.clientY });
}, []);
const handleToggleAutoFollow = useCallback(() => {
const next = !autoFollowRef.current;
autoFollowRef.current = next;
setIsAutoFollow(next);
if (next) {
const term = termRef.current;
if (term) {
fitRef.current?.fit();
term.scrollToBottom();
isAtBottomRef.current = true;
setIsAtBottom(true);
// Surface the capture state and its escape hatch to the status bar, but only
// while this is the visible terminal.
useEffect(() => {
if (!active) return;
setTerminalMouseCaptured(mouseCaptured);
setReleaseActiveMouse(releaseMouse);
}, [active, mouseCaptured, releaseMouse, setTerminalMouseCaptured, setReleaseActiveMouse]);
// On unmount, if this was the active terminal, clear the status-bar state so
// it does not point at a disposed terminal. (Tab switches do not unmount —
// the deactivating terminal stays mounted but hidden — so this only fires
// when the active session is actually closed.)
useEffect(() => {
return () => {
if (activeRef.current) {
setTerminalMouseCaptured(false);
setReleaseActiveMouse(() => {});
}
}
}, []);
};
}, [setTerminalMouseCaptured, setReleaseActiveMouse]);
return (
<div
@@ -899,18 +907,6 @@ export default function TerminalView({ sessionId, active }: Props) {
{imagePasteMsg}
</div>
)}
{/* Auto-follow toggle - top right */}
<button
onClick={handleToggleAutoFollow}
className={`absolute top-2 right-4 z-50 px-2 py-1 rounded text-[10px] font-medium border shadow-sm transition-colors cursor-pointer ${
isAutoFollow
? "bg-[#1a2332] text-[#3fb950] border-[#238636] hover:bg-[#1f2d3d]"
: "bg-[#1f2937] text-[#8b949e] border-[#30363d] hover:bg-[#2d3748]"
}`}
title={isAutoFollow ? "Auto-scrolling to latest output (click to pause)" : "Auto-scroll paused (click to resume)"}
>
{isAutoFollow ? "▼ Following" : "▽ Paused"}
</button>
{/* Padding lives on this wrapper, NOT on the xterm host element. xterm's
FitAddon measures the host element it's mounted into; padding there
causes the grid to overhang and clip the rightmost column / bottom
+446
View File
@@ -0,0 +1,446 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useNotes } from "./useNotes";
import { useAppState } from "../store/appState";
import type { Note } from "../lib/types";
const listNotes = vi.fn();
const saveNote = vi.fn();
const deleteNote = vi.fn();
vi.mock("../lib/tauri-commands", () => ({
listNotes: (p: string) => listNotes(p),
saveNote: (p: string, n: Note) => saveNote(p, n),
deleteNote: (p: string, id: string) => deleteNote(p, id),
}));
const note = (over: Partial<Note> = {}): Note => ({
id: "n1",
title: "Deploy",
body: "one\ntwo",
pinned: false,
created_at: "2026-09-01T00:00:00Z",
updated_at: "2026-09-01T00:00:00Z",
...over,
});
/** The toasts the hook pushed. The store is real, so this is what a user sees. */
const toasts = () => useAppState.getState().toasts;
/**
* A stand-in for the Rust store: one list per project, upsert and delete
* applied to it, `list_notes` reading it back. Several of these tests are about
* what the *list* looks like after a sequence of writes, which a per-call
* `mockResolvedValueOnce` cannot express.
*/
function fakeBackend(initial: Record<string, Note[]> = {}) {
const files: Record<string, Note[]> = { ...initial };
listNotes.mockImplementation(async (p: string) => [...(files[p] ?? [])]);
saveNote.mockImplementation(async (p: string, n: Note) => {
const list = files[p] ?? (files[p] = []);
const at = list.findIndex((x) => x.id === n.id);
if (at === -1) list.unshift(n);
else list[at] = n;
return n;
});
deleteNote.mockImplementation(async (p: string, id: string) => {
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
});
return files;
}
beforeEach(() => {
vi.clearAllMocks();
// The cache is shared app state now, so it has to be reset like any other.
useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] });
listNotes.mockResolvedValue([note()]);
saveNote.mockImplementation(async (_p: string, n: Note) => n);
deleteNote.mockResolvedValue(undefined);
});
describe("useNotes", () => {
it("loads a project's notes on mount", async () => {
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(listNotes).toHaveBeenCalledWith("p1");
expect(result.current.notes).toHaveLength(1);
});
it("reports a failed save instead of swallowing it", async () => {
// Silent save failure is data loss: the user sees their text on screen and
// believes it is stored. Same reason `useSaveState` exists.
saveNote.mockRejectedValueOnce(new Error("disk full"));
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.saveNote(note({ body: "edited" }));
});
expect(ok).toBe(false);
expect(result.current.saveState.status).toBe("failed");
expect(toasts()).toHaveLength(1);
});
it("replaces the saved note in place rather than appending", async () => {
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
// Mock the re-read to return the edited note
listNotes.mockResolvedValueOnce([note({ body: "edited" })]);
await act(async () => {
await result.current.saveNote(note({ body: "edited" }));
});
expect(result.current.notes).toHaveLength(1);
expect(result.current.notes[0].body).toBe("edited");
});
it("drops a deleted note from the list", async () => {
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.deleteNote("n1");
});
expect(deleteNote).toHaveBeenCalledWith("p1", "n1");
expect(result.current.notes).toHaveLength(0);
});
it("does not load anything for an empty project id", async () => {
// The dock renders with no project selected; it must not fire a command
// for the empty string.
renderHook(() => useNotes(""));
await waitFor(() => expect(listNotes).not.toHaveBeenCalled());
});
it("clears the first project's notes when the projectId changes to another non-empty value", async () => {
const { result, rerender } = renderHook(
({ projectId }: { projectId: string }) => useNotes(projectId),
{ initialProps: { projectId: "p1" } },
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.notes).toHaveLength(1);
// Change to a different project before the new fetch resolves
listNotes.mockImplementationOnce(() => new Promise(() => {})); // never resolves
rerender({ projectId: "p2" });
// The old notes should be cleared immediately
expect(result.current.notes).toHaveLength(0);
});
it("leaves no stale notes on screen when a load fails", async () => {
listNotes.mockResolvedValueOnce([note()]);
const { result, rerender } = renderHook(
({ projectId }: { projectId: string }) => useNotes(projectId),
{ initialProps: { projectId: "p1" } },
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.notes).toHaveLength(1);
// Switch to a project whose load fails
listNotes.mockRejectedValueOnce(new Error("load failed"));
rerender({ projectId: "p2" });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.notes).toHaveLength(0);
expect(toasts()).toHaveLength(1);
});
it("ends with the list the backend returned when saving a new note", async () => {
// Initially one note
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.notes).toHaveLength(1);
// Saving a new note (not in the current list) re-reads and ends with the backend's list
const newNote = note({ id: "n2", title: "New" });
listNotes.mockResolvedValueOnce([newNote, note()]);
await act(async () => {
await result.current.saveNote(newNote);
});
expect(result.current.notes).toHaveLength(2);
expect(result.current.notes[0].id).toBe("n2");
});
it("re-reads the list after a successful save rather than patching in place", async () => {
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
const callCountBefore = listNotes.mock.calls.length;
listNotes.mockResolvedValueOnce([note({ body: "edited" })]);
await act(async () => {
await result.current.saveNote(note({ body: "edited" }));
});
// listNotes should be called again after the save
expect(listNotes).toHaveBeenCalledTimes(callCountBefore + 1);
});
it("does not overwrite the new project's notes when a stale save resolves", async () => {
const { result, rerender } = renderHook(
({ projectId }: { projectId: string }) => useNotes(projectId),
{ initialProps: { projectId: "p1" } },
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.notes[0].id).toBe("n1");
// Start a save for p1 that hangs
let resolveSave: ((note: Note) => void) | undefined;
saveNote.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSave = resolve;
}),
);
let savePromise: Promise<boolean> | undefined;
await act(async () => {
savePromise = result.current.saveNote(note({ id: "n1" }));
});
// Switch to p2 while the save is in flight
listNotes.mockResolvedValueOnce([note({ id: "n2", title: "Project 2 Note" })]);
rerender({ projectId: "p2" });
await waitFor(() => expect(result.current.loading).toBe(false));
// Now p2's note should be displayed
expect(result.current.notes).toHaveLength(1);
expect(result.current.notes[0].id).toBe("n2");
// Resolve the stale p1 save
listNotes.mockResolvedValueOnce([note({ id: "n1", body: "edited" })]);
await act(async () => {
resolveSave?.(note({ id: "n1", body: "edited" }));
await savePromise;
});
// p2's note should still be displayed, not p1's
expect(result.current.notes).toHaveLength(1);
expect(result.current.notes[0].id).toBe("n2");
});
it("keeps the notes already on screen when a refresh fails", async () => {
// The second surface mounting for a project is a refresh behind a list the
// user is already reading. One shared cache means a failed refresh would
// otherwise blank both panels.
fakeBackend({ p1: [note()] });
const tab = renderHook(() => useNotes("p1"));
await waitFor(() => expect(tab.result.current.loading).toBe(false));
listNotes.mockRejectedValueOnce(new Error("read failed"));
const dock = renderHook(() => useNotes("p1"));
await waitFor(() => expect(toasts()).toHaveLength(1));
expect(tab.result.current.notes).toHaveLength(1);
expect(dock.result.current.notes).toHaveLength(1);
});
it("does not report the old project's save on the new project's indicator", async () => {
// The indicator is per-panel and reads "Saved ✓". Firing it after a switch
// tells the user their *current* project was written when it was not.
const { result, rerender } = renderHook(
({ projectId }: { projectId: string }) => useNotes(projectId),
{ initialProps: { projectId: "p1" } },
);
await waitFor(() => expect(result.current.loading).toBe(false));
let resolveSave: ((n: Note) => void) | undefined;
saveNote.mockImplementationOnce(
() => new Promise((resolve) => (resolveSave = resolve)),
);
let savePromise: Promise<boolean> | undefined;
await act(async () => {
savePromise = result.current.saveNote(note({ body: "edited" }));
});
rerender({ projectId: "p2" });
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
resolveSave?.(note({ body: "edited" }));
await savePromise;
});
expect(result.current.saveState.status).toBe("idle");
});
it("still reports a save on the indicator of the project it was made for", async () => {
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.saveNote(note({ body: "edited" }));
});
expect(result.current.saveState.status).toBe("saved");
});
it("serialises a project's writes so an edit cannot be re-inserted after its delete", async () => {
// Clicking Delete while the textarea has focus fires blur first, so a save
// and a delete go out back to back. The Rust write lock stops them
// interleaving but does not order them: a delete that wins the lock is
// undone by the upsert behind it, and the note comes back on next load.
const files = fakeBackend({ p1: [note()] });
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
const order: string[] = [];
saveNote.mockImplementationOnce(async (p: string, n: Note) => {
await new Promise((r) => setTimeout(r, 20));
order.push("save");
files[p] = [n];
return n;
});
deleteNote.mockImplementationOnce(async (p: string, id: string) => {
order.push("delete");
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
});
await act(async () => {
const save = result.current.saveNote(note({ body: "typo fixed" }));
const del = result.current.deleteNote("n1");
await Promise.all([save, del]);
});
expect(order).toEqual(["save", "delete"]);
expect(files.p1).toHaveLength(0);
expect(result.current.notes).toHaveLength(0);
});
it("keeps a new note when another note is saved right after it", async () => {
// A purely local draft used to be wiped by the next re-read: two clicks of
// "New note", type in the second, blur, and the first row was gone.
const files = fakeBackend({ p1: [note()] });
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
let first: Note | null = null;
await act(async () => {
first = await result.current.createNote();
await result.current.createNote();
});
expect(result.current.notes).toHaveLength(3);
await act(async () => {
await result.current.saveNote(note({ body: "edited" }));
});
expect(result.current.notes).toHaveLength(3);
expect(result.current.notes.some((n) => n.id === first!.id)).toBe(true);
expect(files.p1).toHaveLength(3);
});
it("shares one cache between every hook watching the same project", async () => {
// The Project Home sub-tab and the dock both mount a panel for the same
// project. Two caches meant an edit in one was invisible to the other, and
// the other's next blur wrote its stale copy back over it.
fakeBackend({ p1: [note()] });
const tab = renderHook(() => useNotes("p1"));
const dock = renderHook(() => useNotes("p1"));
await waitFor(() => expect(tab.result.current.loading).toBe(false));
await waitFor(() => expect(dock.result.current.loading).toBe(false));
// One read for both — the in-flight flag is per project, not per hook.
expect(listNotes).toHaveBeenCalledTimes(1);
await act(async () => {
await dock.result.current.saveNote(note({ body: "written in the dock" }));
});
expect(tab.result.current.notes[0].body).toBe("written in the dock");
expect(tab.result.current.notes).toBe(dock.result.current.notes);
});
it("does not blank an already-loaded list when a second panel mounts", async () => {
fakeBackend({ p1: [note()] });
const tab = renderHook(() => useNotes("p1"));
await waitFor(() => expect(tab.result.current.loading).toBe(false));
const dock = renderHook(() => useNotes("p1"));
// No "Loading notes…" flash on the second surface.
expect(dock.result.current.loading).toBe(false);
expect(dock.result.current.notes).toHaveLength(1);
});
it("does not let a slow mount read overwrite a fresher post-save refresh", async () => {
// One gesture, two requests. The tab is already loaded; the user clicks the
// dock toggle with the textarea focused, so `blur` → `saveNote` and the
// dock's mount → `list_notes` are issued in the same tick. The save's
// re-read writes the post-save list; the mount's read — issued earlier,
// still in flight — must not then land its pre-save snapshot on top of it.
const files = fakeBackend({ p1: [note({ body: "before" })] });
const tab = renderHook(() => useNotes("p1"));
await waitFor(() => expect(tab.result.current.loading).toBe(false));
expect(tab.result.current.notes[0].body).toBe("before");
// The dock's mount read: it snapshots the list as it is *now* (pre-save)
// and hangs, standing in for a plain read that is slower than
// `save_note`'s double-fsync write plus the re-read behind it.
let releaseMountRead: (() => void) | undefined;
listNotes.mockImplementationOnce(async (p: string) => {
const preSave = [...(files[p] ?? [])];
await new Promise<void>((resolve) => {
releaseMountRead = resolve;
});
return preSave;
});
const dock = renderHook(() => useNotes("p1"));
expect(releaseMountRead).toBeDefined();
// The save and its re-read complete while that read is still out.
await act(async () => {
await tab.result.current.saveNote(note({ body: "after" }));
});
expect(tab.result.current.notes[0].body).toBe("after");
// Now the stale read lands.
await act(async () => {
releaseMountRead!();
await Promise.resolve();
});
expect(tab.result.current.notes[0].body).toBe("after");
expect(dock.result.current.notes[0].body).toBe("after");
});
it("does not let a slow mount read resurrect a note deleted while it was in flight", async () => {
// The other half of the same ordering rule: a confirmed delete is newer
// than any read issued before it finished, so the read's pre-delete list
// must not be written back over the shortened one.
const files = fakeBackend({ p1: [note()] });
const tab = renderHook(() => useNotes("p1"));
await waitFor(() => expect(tab.result.current.loading).toBe(false));
let releaseMountRead: (() => void) | undefined;
listNotes.mockImplementationOnce(async (p: string) => {
const preDelete = [...(files[p] ?? [])];
await new Promise<void>((resolve) => {
releaseMountRead = resolve;
});
return preDelete;
});
const dock = renderHook(() => useNotes("p1"));
expect(releaseMountRead).toBeDefined();
await act(async () => {
await tab.result.current.deleteNote("n1");
});
expect(tab.result.current.notes).toHaveLength(0);
await act(async () => {
releaseMountRead!();
await Promise.resolve();
});
expect(tab.result.current.notes).toHaveLength(0);
expect(dock.result.current.notes).toHaveLength(0);
});
});
+350
View File
@@ -0,0 +1,350 @@
import { useCallback, useEffect, useRef, useState } from "react";
import * as commands from "../lib/tauri-commands";
import type { Note } from "../lib/types";
import type { SaveState } from "./useSaveState";
import { useAppState } from "../store/appState";
/** A blank note, ordered to the top so the user can start typing immediately. */
function draft(): Note {
const now = new Date().toISOString();
return {
// The backend keeps whatever id it is handed for a note it has not seen,
// so this one is the note's real id from the first save onward.
id: crypto.randomUUID(),
title: "",
body: "",
pinned: false,
created_at: now,
updated_at: now,
};
}
/** Stable empty list, so a project with nothing cached does not re-render on identity. */
const NO_NOTES: Note[] = [];
/**
* Per-project mutation chain.
*
* A project's writes are serialised so that two of them cannot be in flight at
* once. The Rust `write_lock` stops an upsert and a delete *interleaving*; it
* does not order them, and the order is the part that matters here. Clicking
* Delete while the textarea has focus fires `blur` first, so `save_note` and
* `delete_note` are issued back to back and if the delete wins the lock, the
* upsert behind it re-inserts the note and it comes back on the next load.
* "Fix a typo, decide the note is useless, delete it" is an ordinary sequence.
*
* Module scope, not hook scope, for the reason `useTerminal`'s input queue is:
* several components call `useNotes` for the same project (the Project Home
* tab and the dock), and a per-hook chain would give each its own ordering and
* leave them racing each other which is the bug, not the fix.
*/
const mutationChains = new Map<string, Promise<unknown>>();
function enqueueMutation<T>(projectId: string, run: () => Promise<T>): Promise<T> {
const previous = mutationChains.get(projectId) ?? Promise.resolve();
// `run` on both arms: a failed mutation must not stall every later one.
const result = previous.then(run, run);
const tail = result.then(
() => {},
() => {},
);
mutationChains.set(projectId, tail);
void tail.then(() => {
// Drop the entry once idle, so closed projects do not accumulate.
if (mutationChains.get(projectId) === tail) mutationChains.delete(projectId);
});
return result;
}
/**
* Per-project write ordering for the shared notes cache.
*
* `mutationChains` orders a project's *writes* against each other. It says
* nothing about reads, and the mount load is a read that runs outside it so
* one gesture can put two requests in flight at once and let the slower one
* win. Clicking the dock toggle with the textarea focused fires `blur`
* `saveNote` and the dock's mount `list_notes` in the same tick: the save
* finishes, its re-read writes the post-save list, and then the mount's read
* issued earlier, still out lands its pre-save snapshot on top. Both panels
* show stale text until something else refreshes. It needs the plain read to
* be slower than `save_note`'s double-fsync write plus a second read, so it is
* narrow, but it was reproduced.
*
* The fix is a sequence number rather than a chain, because the two requests
* are not competing for a resource the loser's result is simply *older*, and
* the cheapest correct thing to do with it is throw it away. Every write
* claims a sequence when the request behind it is issued, and `commitNotes`
* drops one whose sequence predates what is already cached. That also closes a
* hole identity comparison cannot: on a `p1 → p2 → p1` switch a read from the
* *first* p1 era is indistinguishable from a current one by project id, and
* would land its stale list on the second era's.
*
* Note what this deliberately does **not** replace. `isCurrent()` asks whether
* this *panel* is still showing the project a save was made for, which governs
* a per-panel `SaveIndicator` and not the shared cache at all; a per-project
* counter cannot answer it. Ordering and panel identity are two questions, and
* they keep two guards.
*
* Entries are two integers per project and are never pruned: they must outlive
* every request that could still land, and the map is monotone, so a stale
* sequence can never be reissued.
*/
const notesSequences = new Map<string, { issued: number; committed: number }>();
function sequenceFor(projectId: string): { issued: number; committed: number } {
let seq = notesSequences.get(projectId);
if (!seq) notesSequences.set(projectId, (seq = { issued: 0, committed: 0 }));
return seq;
}
/**
* Claim the sequence for a write about to be issued.
*
* Called immediately before the request whose result it will commit, so that
* ordering is by *issue* time. Resolution order is exactly what cannot be
* trusted here.
*/
function issueNotesWrite(projectId: string): number {
const seq = sequenceFor(projectId);
seq.issued += 1;
return seq.issued;
}
/**
* Write a list into the cache under the sequence it was issued at, unless
* something newer has already been committed.
*
* A local patch the filter behind a confirmed delete, say is authoritative
* at the moment it applies rather than derived from an earlier read, so it
* claims its sequence here: `issued` is never below `committed`, so a freshly
* claimed one always wins, and anything still in flight behind it is correctly
* treated as stale.
*/
function commitNotes(projectId: string, seq: number, notes: Note[]): boolean {
const sequence = sequenceFor(projectId);
if (seq <= sequence.committed) return false;
sequence.committed = seq;
useAppState.getState().setProjectNotes(projectId, notes);
return true;
}
/**
* Re-read the canonical list into the shared cache.
*
* A successful save stamps a new `updated_at` and the backend sorts on it, so
* the record's position has changed and positional patching would disagree
* with what a reload would show. The backend owns the order; the webview never
* sorts. A failed re-read leaves the cache alone rather than clearing it.
*
* `true` means "the cache is current", which is why a superseded commit still
* returns it: whatever beat this read was issued later and therefore read the
* same write or a later one.
*/
async function refresh(projectId: string): Promise<boolean> {
const seq = issueNotesWrite(projectId);
try {
const reloaded = await commands.listNotes(projectId);
commitNotes(projectId, seq, reloaded);
return true;
} catch {
return false;
}
}
/**
* A project's notes, cached from the backend.
*
* The backend is the source of truth and the zustand slice is the cache
* every mutation goes through a command and the returned list replaces the
* cached one, so the list can never drift from the file. The cache lives in
* the store rather than in this hook because two surfaces show the same
* project's notes at once; see `notesByProject`.
*
* `saveState` is deliberately *not* shared: it is this panel's report of this
* panel's write, and `ui/SaveIndicator` is per-panel. A save that fails
* silently is a user staring at text they believe is stored.
*/
export function useNotes(projectId: string) {
const cached = useAppState((s) => s.notesByProject[projectId]);
const pushToast = useAppState((s) => s.pushToast);
const [saveState, setSaveState] = useState<SaveState>({ status: "idle", error: null });
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentProjectId = useRef(projectId);
currentProjectId.current = projectId;
useEffect(() => {
if (!projectId) return;
// Read through `getState` rather than through subscribed values: the
// effect must fire once per project, not again every time the flag it sets
// changes. Two panels mounting for the same project therefore make one
// read, and the second renders from the cache with no loading flash.
const store = useAppState.getState();
if (store.notesLoading[projectId]) return;
store.setNotesLoading(projectId, true);
const seq = issueNotesWrite(projectId);
commands
.listNotes(projectId)
.then((loaded) => {
commitNotes(projectId, seq, loaded);
})
.catch((e) => {
// A project that has never been read caches the empty list, so a panel
// does not sit on "Loading notes…" forever. One that *has* been read
// keeps what it has: this load is a refresh behind a list already on
// screen — the second surface mounting, say — and a failed refresh
// must not blank both of them. Same rule as `refresh()`. Neither
// branch has a stale-project hazard, because the write is keyed by the
// project it belongs to.
//
// The commit goes under this read's own sequence, not a fresh one: a
// *later* read still in flight has the newer answer and must not be
// dropped in favour of this failure's empty list.
if (useAppState.getState().notesByProject[projectId] === undefined) {
commitNotes(projectId, seq, []);
}
pushToast({
kind: "error",
message: "Could not load notes for this project",
detail: String(e),
});
})
.finally(() => {
useAppState.getState().setNotesLoading(projectId, false);
});
}, [projectId, pushToast]);
useEffect(
() => () => {
if (resetTimer.current) clearTimeout(resetTimer.current);
},
[],
);
// The indicator belongs to whatever project this panel is showing *now*.
// Without this, switching project mid-save leaves the new project's
// SaveIndicator stuck on the old project's "Saving…" — the same wrong-project
// report as flashing its "Saved ✓", just in the other direction.
useEffect(() => {
if (resetTimer.current) clearTimeout(resetTimer.current);
setSaveState({ status: "idle", error: null });
}, [projectId]);
/**
* Whether this hook is still looking at the project a queued mutation was
* issued for. Only the *reporting* is gated on it the cache write is not,
* because it is keyed by project and belongs to that project either way.
* Without this, the new project's SaveIndicator flashes "Saved ✓" for the
* old project's write.
*/
const isCurrent = useCallback(
() => currentProjectId.current === projectId,
[projectId],
);
const succeeded = useCallback(() => {
setSaveState({ status: "saved", error: null });
if (resetTimer.current) clearTimeout(resetTimer.current);
resetTimer.current = setTimeout(
() => setSaveState({ status: "idle", error: null }),
2500,
);
}, []);
const saveNote = useCallback(
(note: Note) =>
enqueueMutation(projectId, async () => {
if (isCurrent()) {
if (resetTimer.current) clearTimeout(resetTimer.current);
setSaveState({ status: "saving", error: null });
}
try {
await commands.saveNote(projectId, note);
await refresh(projectId);
if (isCurrent()) succeeded();
return true;
} catch (e) {
const message = String(e);
if (isCurrent()) setSaveState({ status: "failed", error: message });
// The toast is not project-scoped — it names the failure and stays
// readable after a switch — so it fires either way.
pushToast({ kind: "error", message: "Could not save note", detail: message });
return false;
}
}),
[projectId, pushToast, succeeded, isCurrent],
);
/**
* Create a note by persisting it, rather than holding it locally until the
* first blur.
*
* The draft used to live only in the list, which meant any *other* note
* being saved replaced the list with the backend's and the unsaved draft
* silently vanished click "New note" twice, type in the second, blur, and
* the first row is gone. Sharing one cache between two surfaces makes that
* worse rather than better: a local-only row would exist in whichever panel
* created it and nowhere else. Letting the backend own the row from the
* start removes the whole class: there is no such thing as a note in the
* list that the file does not have.
*/
const createNote = useCallback(
() =>
enqueueMutation(projectId, async () => {
const note = draft();
try {
const saved = await commands.saveNote(projectId, note);
if (!(await refresh(projectId))) {
// The note exists; only the re-read failed. Show it rather than
// leaving the user with a button that did nothing visible.
const store = useAppState.getState();
commitNotes(projectId, issueNotesWrite(projectId), [
saved,
...(store.notesByProject[projectId] ?? []),
]);
}
return saved;
} catch (e) {
pushToast({
kind: "error",
message: "Could not create note",
detail: String(e),
});
return null;
}
}),
[projectId, pushToast],
);
const deleteNote = useCallback(
(noteId: string) =>
enqueueMutation(projectId, async () => {
try {
await commands.deleteNote(projectId, noteId);
const store = useAppState.getState();
commitNotes(
projectId,
issueNotesWrite(projectId),
(store.notesByProject[projectId] ?? []).filter((n) => n.id !== noteId),
);
return true;
} catch (e) {
pushToast({ kind: "error", message: "Could not delete note", detail: String(e) });
return false;
}
}),
[projectId, pushToast],
);
return {
notes: cached ?? NO_NOTES,
// Only "loading" before the project has ever been read — never on a
// refresh behind a list that is already on screen, and never on the second
// panel to mount for a project the first one already fetched. A failed
// load caches the empty list, so this cannot latch on.
loading: Boolean(projectId) && cached === undefined,
saveState,
createNote,
saveNote,
deleteNote,
};
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import { CLAUDE_SOFT_NEWLINE, toClaudePayload } from "./claudeInput";
describe("toClaudePayload", () => {
it("is ESC+CR, the sequence Claude Code's own /terminal-setup installs", () => {
expect(CLAUDE_SOFT_NEWLINE).toBe("\x1b\r");
});
it("replaces every newline so the note arrives as one prompt", () => {
// Typed raw, each \n submits — the note would arrive as three truncated
// messages instead of one.
expect(toClaudePayload("one\ntwo\nthree")).toBe("one\x1b\rtwo\x1b\rthree");
});
it("normalises CRLF, which is what a paste from Windows carries", () => {
expect(toClaudePayload("one\r\ntwo")).toBe("one\x1b\rtwo");
});
it("normalises a lone CR, which would otherwise submit", () => {
// A bare \r is a carriage return: it submits in a Claude prompt and runs
// the line in a shell — the terminator this function promises not to
// append. A textarea cannot make one, but a notes file that was
// hand-edited or written by something else can, and `load_in` hands it
// straight back.
expect(toClaudePayload("one\rtwo")).toBe("one\x1b\rtwo");
expect(toClaudePayload("one\rtwo\r\nthree\nfour")).toBe(
"one\x1b\rtwo\x1b\rthree\x1b\rfour",
);
expect(toClaudePayload("text\r").endsWith("\r")).toBe(true);
// …but only as the tail of the soft-newline sequence, never bare.
expect(toClaudePayload("text\r")).toBe("text\x1b\r");
});
it("leaves single-line text untouched", () => {
expect(toClaudePayload("just one line")).toBe("just one line");
});
it("never appends a terminator", () => {
// The note lands in the prompt unsubmitted; the user presses Enter. An
// unsent prompt is recoverable, a sent one is not.
expect(toClaudePayload("text").endsWith("\r")).toBe(false);
expect(toClaudePayload("text\n")).toBe("text\x1b\r");
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* The bytes that insert a newline in Claude Code's prompt without submitting
* it: ESC then CR.
*
* These are the in-band bytes, not a guess they are exactly what Claude
* Code's own `/terminal-setup` writes into the VS Code, Cursor, Alacritty and
* Zed keymaps, and `TerminalView`'s Shift+Enter handler has sent them since
* that feature landed. **This must not be "simplified" to `\n`:** Claude Code
* accepts `\n` too, but a shell would *run* the line, so the two session types
* would quietly diverge.
*
* That last sentence is also why anything sending this must first check the
* session is a Claude one. `bash -l`'s readline has no binding for `\e\r` and
* answers with a bell.
*/
export const CLAUDE_SOFT_NEWLINE = "\x1b\r";
/**
* Turn multi-line text into something that arrives in a Claude prompt as one
* message.
*
* Sent as raw keystrokes, every `\n` submits, so an N-line note would arrive
* as N truncated prompts. Deliberately appends no terminator: the text lands
* in the prompt and the user presses Enter, which is what speech-to-text does
* for the same reason an unsent prompt is recoverable and a sent one is not.
*
* A **lone** `\r` is matched too, not only the one in a CRLF. It is a carriage
* return: it submits in a Claude prompt and runs the line in a shell, which is
* exactly the terminator this function promises never to append. A `<textarea>`
* cannot produce one, but a note body is read back from a JSON file that can be
* hand-edited or written by something else, so the guarantee has to hold for
* whatever `load_in` returns rather than for whatever the editor can type.
*/
export function toClaudePayload(text: string): string {
return text.replace(/\r\n|\r|\n/g, CLAUDE_SOFT_NEWLINE);
}
+6 -5
View File
@@ -243,11 +243,12 @@ describe("dropTarget", () => {
describe("chrome over a pane, with no dialog open", () => {
/** Everything that is painted over a pane and is not a blocker. */
const CHROME: Array<[string, () => HTMLElement]> = [
// `TerminalView`'s "▼ Following / ▽ Paused" toggle: `absolute top-2
// right-4 z-50`, rendered unconditionally, and a *sibling* of the xterm
// host — so "does the pane contain what is painted here?" made the
// terminal's top-right corner a dead zone no user action could clear.
["the Following/Paused toggle", () => document.createElement("button")],
// `TerminalView`'s mouse-release badge: `absolute top-2 right-4 z-50`,
// and a *sibling* of the xterm host — so "does the pane contain what is
// painted here?" made the terminal's top-right corner a dead zone no
// user action could clear. (The retired Following toggle held the same
// corner and produced the original bug.)
["the mouse-release badge", () => document.createElement("button")],
// `ToastHost`: `fixed bottom-4 right-4 z-[60]`, 24rem wide, over every
// pane, and its error cards stay until dismissed.
["a toast card", () => document.createElement("div")],
+2 -2
View File
@@ -26,8 +26,8 @@
*
* - Asking `el.contains(document.elementFromPoint(x, y))` "is the thing
* painted here mine?" refused drops onto anything painted *over* a pane
* that is not part of it: `TerminalView`'s always-rendered "▼ Following"
* toggle (a sibling of the xterm host), the URL toast, `ToastHost`'s stack.
* that is not part of it: `TerminalView`'s mouse-release badge (a sibling
* of the xterm host), the URL toast, `ToastHost`'s stack.
* Permanent dead zones no user action could clear.
* - Replacing that with "is a *blocking overlay* painted here?" removed the
* dead zones and opened a hole instead. `elementFromPoint` returns the
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { sessionDisplayName } from "./sessionName";
import type { Project, TerminalSession } from "./types";
const session = (over: Partial<TerminalSession> = {}): TerminalSession => ({
id: "s1",
projectId: "p1",
projectName: "api",
sessionType: "claude",
sessionName: null,
...over,
});
const project = (renamed: Record<string, string> = {}) =>
({ id: "p1", name: "api", renamed_session_names: renamed }) as unknown as Project;
describe("sessionDisplayName", () => {
it("prefers a user-set custom name, prefixed with the project", () => {
expect(sessionDisplayName(session(), project({ s1: "release work" }))).toBe(
"api: release work",
);
});
it("falls back to the session name when there is no custom one", () => {
expect(sessionDisplayName(session({ sessionName: "review" }), project())).toBe("review");
});
it("falls back to the project name when there is no session name", () => {
expect(sessionDisplayName(session(), project())).toBe("api");
});
it("marks bash sessions", () => {
expect(sessionDisplayName(session({ sessionType: "bash" }), project())).toBe("api (bash)");
});
it("works with no project, which is how a closing tab renders", () => {
expect(sessionDisplayName(session())).toBe("api");
});
it("does not mark bash when a custom name is set, matching the existing rule", () => {
expect(
sessionDisplayName(session({ sessionType: "bash" }), project({ s1: "logs" })),
).toBe("api: logs");
});
});
+27
View File
@@ -0,0 +1,27 @@
import type { Project, TerminalSession } from "./types";
/**
* What a terminal session is called on screen.
*
* The rule used to be written twice inside `MainTabs.tsx` once in `tabLabel`
* for the drag ghost, once inline in `renderTab` both local and neither
* exported, so the two could disagree the moment either was edited. It is here
* because a third caller (the note send-target picker) would have made that
* three.
*
* A user-set name wins and is prefixed with the project, because a custom name
* is usually about the work rather than the project and needs the context. The
* `(bash)` marker only appears on the fallback: a session someone bothered to
* name does not need to be told apart from its neighbours.
*/
export function sessionDisplayName(
session: TerminalSession,
project?: Project,
): string {
const custom = project?.renamed_session_names?.[session.id];
if (custom) return `${session.projectName}: ${custom}`;
return (
(session.sessionName ?? session.projectName) +
(session.sessionType === "bash" ? " (bash)" : "")
);
}
+25 -4
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types";
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -25,6 +25,15 @@ export const rebuildProjectContainer = (projectId: string) =>
export const reconcileProjectStatuses = () =>
invoke<Project[]>("reconcile_project_statuses");
// Notes — per-project, host-side, readable with the container stopped.
export const listNotes = (projectId: string) =>
invoke<Note[]>("list_notes", { projectId });
/** Insert or replace one note. `created_at` and `id` are owned by the backend. */
export const saveNote = (projectId: string, note: Note) =>
invoke<Note>("save_note", { projectId, note });
export const deleteNote = (projectId: string, noteId: string) =>
invoke<void>("delete_note", { projectId, noteId });
// Settings
export const getSettings = () => invoke<AppSettings>("get_settings");
export const updateSettings = (settings: AppSettings) =>
@@ -341,8 +350,8 @@ export const sweepClaudeTokenSnapshots = () =>
// without deleting its volumes. Reset is the destructive alternative: it wipes
// ~/.claude, the OAuth credential, installed skills and every transcript.
//
// Flow: getContainerStaleness (read-only, ~6s — two filesystem probes, so call
// it on demand rather than polling) → migrateProjectToBase → the project sits
// Flow: getContainerStaleness (~6s — two filesystem probes, so call it on demand
// rather than polling) → migrateProjectToBase → the project sits
// in "awaiting-confirmation" while the user tries it → confirmMigration or
// rollbackMigration.
//
@@ -352,7 +361,19 @@ export const sweepClaudeTokenSnapshots = () =>
//
// Progress arrives on the existing `container-progress` event.
/** Read-only. Runs two container/image filesystem probes; not for polling. */
/**
* Runs two container/image filesystem probes; not for polling.
*
* **Not read-only, despite only reporting.** When the container is *stopped*
* the backend has to commit its writable layer to a throwaway image before it
* can read anything `docker exec` needs a running container so this writes
* (and then removes) an image. The result is cached per stop, so repeat calls
* while the container stays stopped are cheap, but the first one after each stop
* pays for a commit of the whole layer: seconds on a small project, tens of
* seconds on a large one. Do not add a caller that fires more often than "the
* container settled into a new state" without re-reading
* `get_container_staleness`'s doc comment first.
*/
export const getContainerStaleness = (projectId: string) =>
invoke<ContainerStaleness>("get_container_staleness", { projectId });
+10
View File
@@ -565,6 +565,16 @@ export interface SchedulerNotification {
created_at: string;
}
/** One project note. Mirrors `models::Note` — field names are the Rust ones. */
export interface Note {
id: string;
title: string;
body: string;
pinned: boolean;
created_at: string;
updated_at: string;
}
// ── Auth bridge ──────────────────────────────────────────────────────────────
/** Which loopback family the container-side listener was found on.
+24
View File
@@ -112,3 +112,27 @@ describe("toasts", () => {
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");
});
});
+151 -11
View File
@@ -1,5 +1,12 @@
import { create } from "zustand";
import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo } from "../lib/types";
import type {
Project,
TerminalSession,
AppSettings,
UpdateInfo,
ImageUpdateInfo,
Note,
} from "../lib/types";
const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed";
@@ -19,6 +26,54 @@ function persistSidebarCollapsed(value: boolean) {
}
}
const NOTES_DOCK_KEY = "triple-c.notes.dock";
const NOTES_DOCK_WIDTH_KEY = "triple-c.notes.dock.width";
/** Wide enough for a note, narrow enough to leave a usable terminal. */
export const NOTES_DOCK_MIN_WIDTH = 260;
export const NOTES_DOCK_MAX_WIDTH = 720;
export const NOTES_DOCK_DEFAULT_WIDTH = 352;
function loadNotesDockOpen(): boolean {
try {
return localStorage.getItem(NOTES_DOCK_KEY) === "1";
} catch {
return false;
}
}
function persistNotesDockOpen(value: boolean) {
try {
localStorage.setItem(NOTES_DOCK_KEY, value ? "1" : "0");
} catch {
// ignore — storage may be unavailable
}
}
/** Clamped on the way in as well as out: a stored value can be anything a
* previous version, a hand edit, or a different screen left behind. */
export function clampDockWidth(value: number): number {
if (!Number.isFinite(value)) return NOTES_DOCK_DEFAULT_WIDTH;
return Math.min(NOTES_DOCK_MAX_WIDTH, Math.max(NOTES_DOCK_MIN_WIDTH, Math.round(value)));
}
function loadNotesDockWidth(): number {
try {
const raw = localStorage.getItem(NOTES_DOCK_WIDTH_KEY);
return raw === null ? NOTES_DOCK_DEFAULT_WIDTH : clampDockWidth(Number(raw));
} catch {
return NOTES_DOCK_DEFAULT_WIDTH;
}
}
function persistNotesDockWidth(value: number) {
try {
localStorage.setItem(NOTES_DOCK_WIDTH_KEY, String(value));
} catch {
// ignore — storage may be unavailable
}
}
/**
* The main area hosts two tab kinds terminals and Project Home views in a
* single ordered strip. Tabs are addressed by a string key so one array can
@@ -89,6 +144,21 @@ interface AppState {
/** Consumed once by `ProjectHome`, then cleared. */
pendingHomeTab: { projectId: string; tab: string } | null;
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;
setActiveTabKey: (key: string) => void;
cycleTab: (delta: number) => void;
@@ -98,6 +168,29 @@ interface AppState {
/** Nudge the active tab left/right — the keyboard route to the same thing. */
moveActiveTab: (delta: number) => void;
// Per-project notes, cached from the backend.
//
// Rust is the source of truth and this is a cache — but it has to be *one*
// cache. Notes are shown by two surfaces at once (the Project Home sub-tab
// and the dock, which resolves to the same project), and a hook-local
// `useState` in each gave them independent copies: an edit made in the dock
// was invisible to the tab, and the tab's next blur wrote its stale record
// back over it with no error and no indicator. Keyed by project id so a
// response that lands after the user has moved on updates the project it
// belongs to instead of whichever one is on screen.
//
// This is also the boundary a detached notes window would need: swap the
// transport for a `notes-changed` event and both windows feed the same slice.
notesByProject: Record<string, Note[]>;
/**
* Projects with a `list_notes` in flight, so two panels mounting for the
* same project make one read rather than two, and so a panel whose project
* has never been read can tell "loading" from "no notes".
*/
notesLoading: Record<string, boolean>;
setProjectNotes: (projectId: string, notes: Note[]) => void;
setNotesLoading: (projectId: string, loading: boolean) => void;
// Inline container progress, replacing the blocking progress modal.
containerProgress: Record<string, string>;
setContainerProgress: (projectId: string, message: string | null) => void;
@@ -112,21 +205,32 @@ interface AppState {
// UI state
terminalHasSelection: boolean;
setTerminalHasSelection: (has: boolean) => void;
// Whether a program in the active terminal is holding mouse reporting open,
// and how to take it back. Surfaced so the release control can live in the
// status bar: painted over the terminal it would sit on top of whatever TUI
// is asking for the mouse, and swallow clicks aimed at that program's own
// top-right corner for as long as it ran. Only the active TerminalView
// writes these.
terminalMouseCaptured: boolean;
setTerminalMouseCaptured: (captured: boolean) => void;
releaseActiveMouse: () => void;
setReleaseActiveMouse: (fn: () => void) => void;
// STT toggle for the active session, registered by App so the terminal's
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
sttToggle: () => void;
setSttToggle: (fn: () => void) => void;
// Active terminal scroll state, surfaced so the status bar can host the
// "Jump to Current" control. Only the active TerminalView writes these.
terminalAtBottom: boolean;
setTerminalAtBottom: (v: boolean) => void;
scrollActiveToBottom: () => void;
setScrollActiveToBottom: (fn: () => void) => void;
sidebarView: "projects" | "settings";
setSidebarView: (view: "projects" | "settings") => void;
sidebarCollapsed: boolean;
setSidebarCollapsed: (collapsed: boolean) => void;
toggleSidebarCollapsed: () => void;
/** The notes dock, visible over any tab including a terminal. */
notesDockOpen: boolean;
setNotesDockOpen: (open: boolean) => void;
toggleNotesDock: () => void;
/** Dock width in CSS px, clamped and persisted per machine. */
notesDockWidth: number;
setNotesDockWidth: (width: number) => void;
dockerAvailable: boolean | null;
setDockerAvailable: (available: boolean | null) => void;
imageExists: boolean | null;
@@ -267,6 +371,9 @@ export const useAppState = create<AppState>((set) => ({
}),
pendingHomeTab: null,
clearPendingHomeTab: () => set({ pendingHomeTab: null }),
pendingTerminalFocus: null,
requestTerminalFocus: (sessionId) => set({ pendingTerminalFocus: sessionId }),
clearPendingTerminalFocus: () => set({ pendingTerminalFocus: null }),
closeHomeTab: (projectId) =>
set((state) => {
const key = homeTabKey(projectId);
@@ -340,6 +447,22 @@ export const useAppState = create<AppState>((set) => ({
return { tabOrder };
}),
// Notes
notesByProject: {},
notesLoading: {},
setProjectNotes: (projectId, notes) =>
set((state) => ({
notesByProject: { ...state.notesByProject, [projectId]: notes },
})),
setNotesLoading: (projectId, loading) =>
set((state) => {
if ((state.notesLoading[projectId] ?? false) === loading) return {};
const next = { ...state.notesLoading };
if (loading) next[projectId] = true;
else delete next[projectId];
return { notesLoading: next };
}),
// Container progress
containerProgress: {},
setContainerProgress: (projectId, message) =>
@@ -377,12 +500,12 @@ export const useAppState = create<AppState>((set) => ({
// UI state
terminalHasSelection: false,
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
terminalMouseCaptured: false,
setTerminalMouseCaptured: (captured) => set({ terminalMouseCaptured: captured }),
releaseActiveMouse: () => {},
setReleaseActiveMouse: (fn) => set({ releaseActiveMouse: fn }),
sttToggle: () => {},
setSttToggle: (fn) => set({ sttToggle: fn }),
terminalAtBottom: true,
setTerminalAtBottom: (v) => set({ terminalAtBottom: v }),
scrollActiveToBottom: () => {},
setScrollActiveToBottom: (fn) => set({ scrollActiveToBottom: fn }),
sidebarView: "projects",
setSidebarView: (view) => set({ sidebarView: view }),
sidebarCollapsed: loadSidebarCollapsed(),
@@ -396,6 +519,23 @@ export const useAppState = create<AppState>((set) => ({
persistSidebarCollapsed(next);
return { sidebarCollapsed: next };
}),
notesDockOpen: loadNotesDockOpen(),
setNotesDockOpen: (open) => {
persistNotesDockOpen(open);
set({ notesDockOpen: open });
},
toggleNotesDock: () =>
set((state) => {
const open = !state.notesDockOpen;
persistNotesDockOpen(open);
return { notesDockOpen: open };
}),
notesDockWidth: loadNotesDockWidth(),
setNotesDockWidth: (width) => {
const clamped = clampDockWidth(width);
persistNotesDockWidth(clamped);
set({ notesDockWidth: clamped });
},
dockerAvailable: null,
setDockerAvailable: (available) => set({ dockerAvailable: available }),
imageExists: null,
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import {
clampDockWidth,
NOTES_DOCK_MIN_WIDTH,
NOTES_DOCK_MAX_WIDTH,
NOTES_DOCK_DEFAULT_WIDTH,
} from "./appState";
describe("clampDockWidth", () => {
it("keeps a sensible width", () => {
expect(clampDockWidth(400)).toBe(400);
});
it("refuses to squeeze the dock into uselessness", () => {
expect(clampDockWidth(10)).toBe(NOTES_DOCK_MIN_WIDTH);
});
it("refuses to squeeze the terminal into uselessness", () => {
expect(clampDockWidth(5000)).toBe(NOTES_DOCK_MAX_WIDTH);
});
it("falls back for a stored value that is not a number", () => {
// localStorage holds strings and can carry anything a previous version,
// a hand edit, or a different screen left behind.
expect(clampDockWidth(Number("banana"))).toBe(NOTES_DOCK_DEFAULT_WIDTH);
});
it("rounds, because a fractional px width blurs the border", () => {
expect(clampDockWidth(400.6)).toBe(401);
});
});
// The pure function above is only half the contract: the brief calls out that
// the clamp must guard the *read* path too, because localStorage can carry
// anything a previous version, a hand edit, or a different screen left
// behind. These tests exercise the real store initialization — seeding
// localStorage, then re-importing the module fresh so its top-level
// `loadNotesDockWidth()` call runs against the seeded value — rather than a
// function pulled out just to make this testable. A future refactor that
// dropped the clamp from the load path while keeping it on the write path
// would fail these.
describe("notesDockWidth store initialization", () => {
const WIDTH_KEY = "triple-c.notes.dock.width";
afterEach(() => {
localStorage.removeItem(WIDTH_KEY);
});
it("clamps an out-of-range stored value on load", async () => {
localStorage.setItem(WIDTH_KEY, "99999");
vi.resetModules();
const { useAppState } = await import("./appState");
expect(useAppState.getState().notesDockWidth).toBe(NOTES_DOCK_MAX_WIDTH);
});
it("falls back to the default for a non-numeric stored value on load", async () => {
localStorage.setItem(WIDTH_KEY, "banana");
vi.resetModules();
const { useAppState } = await import("./appState");
expect(useAppState.getState().notesDockWidth).toBe(NOTES_DOCK_DEFAULT_WIDTH);
});
});
+7 -1
View File
@@ -639,8 +639,14 @@ fi
# any terminal session launches `claude`. Runs as the claude user (the CLI is
# installed under /home/claude/.claude/bin). Non-fatal and time-bounded so a
# slow or offline network never blocks container readiness.
# The lock is shared with the per-session update that every Claude terminal
# runs before `exec claude` (commands/terminal_commands.rs, UPDATE_PRELUDE).
# "Container ready" is printed *after* this finishes, so a user who starts a
# project and immediately opens a tab would otherwise have two updaters
# rewriting ~/.claude/bin at once, and the session's `|| echo` would hide the
# damage right before it ran the result.
echo "entrypoint: checking for Claude Code updates..."
timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/home/claude/.local/bin:$PATH"; claude update' \
timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/home/claude/.local/bin:$PATH"; flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock claude update' \
&& echo "entrypoint: Claude Code is up to date" \
|| echo "entrypoint: warning — Claude Code update skipped or failed (continuing)"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,359 @@
# Project Notes — design
**Date:** 2026-09-01 · **Baseline:** v0.4 · Companion to [ROADMAP.md](../../../ROADMAP.md)
and [DESIGN-REVIEW.md](../../../DESIGN-REVIEW.md).
A per-project notes surface, with a per-note **Send to agent** action that puts the note
into a running Claude session's prompt.
---
## Why this earns a slot
DESIGN-REVIEW's coherence test says every screen answers exactly one question. Notes
answers *"what do I want to hand this agent, and what did I keep learning here?"* — and it
answers it **while the container is stopped**, which is the gap the stop/start container
model creates and the same reasoning that made Sessions/Resume the flagship.
Two things already do part of this job, and the design is shaped to avoid both:
- `Project.claude_instructions` (`models/project.rs:405`, editor at
`components/projects/ClaudeInstructionsEditor.tsx`) is per-project free text merged into
the container's `CLAUDE.md` on every start. It is **ambient** — always in context, never
addressed. Notes are **discrete and fired on demand**. If Notes drifts into a second
instructions box, it is redundant with a feature that already ships.
- A `NOTES.md` in the workspace is readable by the agent already, but unreadable by the
user when the container is stopped, and invisible to the fleet view.
What Notes uniquely adds is *addressable items with a fire-at-the-session action*.
## Decisions taken
| Decision | Choice | Rationale |
|---|---|---|
| Audience | Human scratchpad **and** agent prompts, one surface | See "no note types" below |
| Storage | Own file per project, host-side | Keeps prose out of `projects.json`; works with the container stopped |
| Send target | Project's own sessions; picker when >1 | Never guesses; mirrors STT's target-pinning guard |
| Surface | Side dock that takes space **inward**; never resizes the OS window | Phase 0 spike: growing corrupts under native Wayland, §6.1 |
| Formatting | Plain text, no markdown | It is a scratchpad; see §3 |
| Tab position | Last, after Browser | A companion to the work, not a step in it |
**No note *types*.** A note is a title plus a body. What makes one "for the agent" is that
you pressed the button, not a mode set at creation. The moment there is a "prompt note" vs
"scratch note" toggle, the pane is two features wearing one coat, and every note costs a
classification decision at the moment of writing — which is the moment the user is least
willing to make one.
---
## 1. Storage
New `app/src-tauri/src/storage/notes_store.rs`, modeled on `migration_store.rs` rather than
on `projects_store.rs`:
```
<data_dir>/triple-c/notes/{project_id}.json
```
- **`sanitize()` on the project id**, copied from `migration_store.rs:41-46`. The id arrives
over IPC; it must not be able to steer the write.
- **Atomic *and durable* write**`.tmp`, `sync_all()`, `rename()`, then fsync the
directory, per `migration_store.rs:203-261` rather than `projects_store.rs:167-179`. That
file's comment is explicit that write-temp-then-rename alone is only half of it: `fs::write`
returns once the bytes are in the page cache, so losing power in the window leaves the
rename applied and the data not written — a truncated file produced by the very code meant
to prevent one. Notes are user prose; that is the data least worth losing to a half-write.
- **Corrupt file is copied aside and left in place**, per `migration_store.rs:49-125`
timestamped, capped, and never overwriting an earlier copy, because the first copy is the
one taken before anything rewrote the file.
- **Path resolution is split for testability.** `dirs::data_dir()` is resolved in thin public
wrappers; the real work takes an explicit `&Path`. `ProjectsStore::new()` hardcodes
`dirs::data_dir()` and is therefore not constructible against a temp dir, which is why its
own tests only exercise free functions. The notes store should not inherit that limit.
```rust
struct Note {
id: String, // uuid v4
title: String,
body: String,
pinned: bool,
created_at: String, // RFC 3339
updated_at: String,
}
struct ProjectNotes { version: u32, notes: Vec<Note> }
```
Order is pinned-first then `updated_at` descending. Manual reordering is deliberately out.
**`pinned` is reserved, and nothing in v1 sets it.** The field is persisted and sorted on, but
there is no pin control and no pinned indicator anywhere in the UI, so in v1 every note sorts
by `updated_at` descending and the pinned-first half of the rule is inert. It is carried from
the start because it is a field in a file: adding one later means every reader has to tolerate
its absence forever, while an unused `bool` with a serde default costs nothing. Pinning itself
is out of scope — see §8.
### Why not a field on `Project`
`projects.json` is written on **every blur** by the debounced `useProjectSave` path
(`hooks/useSaveState.ts`, threaded through `ProjectHome.tsx:79-81` into Overview and
Config). Long user prose on that record means (a) the whole project list is rewritten every
time a note changes, and (b) a note edit and a Config edit can race, with the loser's write
clobbering the winner's. `migration_store.rs:1-12` already documents this exact reasoning
for why *it* is not in `projects.json`. Notes inherit it.
A per-project file also means a corrupt notes file loses notes for one project, not the
project list.
### Lifecycle
`remove_project` deletes the project's notes file. A failure there is logged, never fatal —
an orphaned notes file is harmless, a project that cannot be removed is not.
## 2. Commands and frontend state
Registered in `lib.rs` via `generate_handler!`. Per CLAUDE.md, application commands need
**no** entry in `capabilities/default.json`.
- `list_notes(projectId) -> Vec<Note>`
- `save_note(projectId, note) -> Note` — upsert; stamps `updated_at` backend-side
- `delete_note(projectId, noteId)`
There is deliberately **no whole-list setter**. Bulk writes are the clobbering mechanism the
storage choice above exists to avoid.
`notes_store` is a **free-function module** keyed by project id, exactly like
`migration_store` — no struct, nothing held in `AppState`, no in-memory copy of the notes.
`ProjectsStore`'s `Mutex` exists because it caches the project list in memory; a notes store
that reads and writes the file per call has nothing to cache and nothing to guard. What it
does need is that each upsert's read-modify-write is not interleaved with another's, so the
module holds one process-wide write lock (`OnceLock<Mutex<()>>`, the idiom already in
`browser_view/popout.rs`) taken for the read-modify-write, not for the read path.
Frontend: wrappers in `lib/tauri-commands.ts`, a `hooks/useNotes.ts`, and notes cached in
zustand keyed by project id. Rust is the source of truth; the cache is a cache.
The dock and the tab live in one webview, so zustand alone suffices. The store boundary is
drawn so that a future detached window (§8) only swaps the transport: Rust emits a
`notes-changed` event, both windows listen.
## 3. Editor — plain text, deliberately
A note is a title and a `<textarea>`, saved on blur, following
`ClaudeInstructionsEditor.tsx` (which saves in `onBlur` and holds no timer) and reporting
the outcome through `ui/SaveIndicator`. There is no debounce anywhere in the existing save
path — `useSaveState.ts`'s only timer is a 2500 ms reset of the "Saved ✓" label — and notes
add none. **No rich editor, no markdown library, and no markdown
rendering** — it is a scratchpad for reminders, and it stays one.
The body is stored and displayed exactly as typed. There is no view/edit mode split, so
there is no state to get wrong and no moment where the text the user is looking at is not
the text that would be sent.
This also keeps `renderMarkdown()` (`components/layout/HelpDialog.tsx:55-160`) where it is.
It was written for Help content, it entity-escapes before converting to make its
`dangerouslySetInnerHTML` sink safe, and `HelpDialog.test.tsx` asserts that escaping as a
security rule. Reusing it here would mean extracting it and giving a hand-rolled HTML
converter a second caller with different content — cost and risk, for formatting a
scratchpad. If notes later need rendering, that extraction is the change to make; it is not
this change.
One consequence worth stating: the text sent to the agent is byte-for-byte what is in the
box. Nothing is transformed on the way out except the newline substitution in §5.
## 4. The Notes tab
- One entry in the `TABS` registry (`components/projects/home/ProjectHome.tsx:24-33`), one
line in the panel switch (`:237-257`), one new `home/NotesTab.tsx` taking the sibling prop
shape `{ project: Project }`.
- Order: Notes goes **last**, after Browser — **Overview / Sessions / Automation / Config /
Files / Browser / Notes**. It is a companion to the work, not a step in it, and the
existing order runs roughly from "what is this" to "what is in it".
- Layout is master/detail: title list left, editor right.
Note that the active sub-tab is local `useState` (`ProjectHome.tsx:47`) and is not
persisted, so a closed and reopened home tab returns to Overview. Notes inherits that; it is
not worth changing here.
## 5. Send to agent
### The newline problem, and why it is already solved
A dictated STT phrase has no newlines. A note body does. Typed as raw keystrokes, every
`\n` in a body **submits a separate prompt** — the note would arrive as N truncated
messages.
The answer is in the codebase already. `components/terminal/TerminalView.tsx` (~:400-430)
handles Shift+Enter by sending `\x1b\r`, and its comment states these are "the in-band
bytes, not a guess," with an explicit warning **not** to simplify to `\n` because a shell
would *run* the line. So:
```
payload = note.body.replace(/\r?\n/g, "\x1b\r")
```
sent with **no trailing CR** — the user presses Enter. Same rationale as STT sending
without one: a note is longer than a dictated sentence, so the chance of wanting an edit
before firing is higher, and an unsent prompt is recoverable while a sent one is not.
Two consequences follow from that same comment:
1. **Only `sessionType === "claude"` sessions are offered as targets.** `bash -l`'s readline
has no binding for `\e\r` and answers with a bell. Bash tabs are not listed in the picker
at all.
2. **The sequence lives in one shared helper**, not a second `"\x1b\r"` literal. The
knowledge in that comment is hard-won and must not be duplicated away from it.
### Target resolution
`TerminalSession` (`lib/types.ts:228-234`) already carries `projectId`, `projectName`,
`sessionType` and `sessionName`, so no new plumbing is needed.
| Claude sessions for this project | Behavior |
|---|---|
| 0 | Button disabled, "no running session for this project" |
| 1 | Send |
| >1 | Menu of session display names (`Project.renamed_session_names` where set) |
The display-name rule is currently written **twice**, both copies non-exported and local to
`MainTabs.tsx``tabLabel` (:192-203) and inline in `renderTab` (:362-367). The picker would
be a third copy of a rule that already disagrees with itself the moment one copy is edited,
so it is extracted once to a shared helper and both existing sites call it. That is a
targeted improvement to code this feature depends on, not unrelated refactoring.
- **The target is pinned at click time**, per the hazard `useSTT.ts:20,30` guards against
(it pins at record-start so text does not land in whatever tab is active at stop time).
- Transport is `useTerminal`'s module-scoped ordered queue (`hooks/useTerminal.ts:32-85`,
exposed as `sendInput` at `:135-141`) → `terminal_input``exec_manager.send_input`. That
queue exists because parallel `invoke`s raced the session mutex and reordered keystrokes
(`useTerminal.ts:7-31`); a multi-line note is exactly the payload that would expose it.
- After sending, switch the active tab to that terminal so the user watches it land. This is
a courtesy, not a correctness requirement: if it cannot be delivered, the send still
succeeded.
- **Body only, not the title.** The title is an index label for the list, not content.
Explicitly *not* reused: `useProjectActions.ts:104-124`'s `openTerminalWithCommand`, which
opens a shell then types after a `setTimeout(700)`. Starting a container as a side effect of
clicking a note is too large an implicit action, and that timing hack should not spread.
## 6. Surface — a dock that takes space inward
`components/layout/NotesDock.tsx`, a flex sibling of the tab panels in `App.tsx:139-160`, so
it is visible over **any** top-level tab including Terminal. This is the point of the dock:
Project Home and Terminal are sibling top-level tabs (`layout/MainTabs.tsx`), so a
Notes-only-as-sub-tab design hides notes exactly when the agent is running.
Opening the dock **takes space from inside the window**. The terminal narrows and reflows;
the OS window is never resized or moved. `TerminalView.tsx:643-656` already has a
rAF-throttled `ResizeObserver` that calls `fitAddon.fit()` then `resize(sessionId, cols,
rows)` → `terminal_resize`, so narrowing reflows xterm *and* resizes the container PTY
correctly, with no new code.
- Width is drag-resizable, persisted to a `triple-c.notes.dock` localStorage key. Precedent:
`triple-c.sidebar.collapsed` (`store/appState.ts:4-20`) is the app's only such key today.
- **The dock follows the active tab's project** — terminal tab → that session's project,
home tab → that project, nothing active → empty state. `activeTabKey`/`tabKeyId` plus
`TerminalSession.projectId` already provide this.
- **No window geometry code at all.** No `set_size`, no `set_position`, no monitor work-area
arithmetic, no platform checks. §6.1 is why.
### 6.1 Why the dock does not widen the window — Phase 0 spike
The original design had the dock "expand outward" by widening the OS window, so the terminal
kept its size. A throwaway Tauri app (`geo-spike`) was built and run on the target desktop —
KDE Plasma, Wayland session, 2026-09-01 — because the app contains no window-geometry code
today and the behavior could not be predicted. It was run twice, once per GDK backend,
which turned out to matter more than the platform.
**Under XWayland** (what every Tauri AppImage gets, because `linuxdeploy-plugin-gtk` exports
`GDK_BACKEND=x11` in `AppRun`, citing
[tauri-apps/tauri#8541](https://github.com/tauri-apps/tauri/issues/8541)) everything worked:
| Test | Result |
|---|---|
| Grow while floating | asked +420, got +420 — exact |
| Shrink back | asked -420, got -420 — exact |
| `outer_position()` | readable, correct |
| `work_area` | 3840x2099 — correctly excludes the 61px Plasma panel |
| Grow while maximized / fullscreen | ignored, as designed |
**Under native Wayland** (what the `.deb` and `.rpm` builds get, since they carry no such
hook) the same binary failed — and failed *silently*, which is the part that decided this:
| Test | Result |
|---|---|
| Grow while floating | asked +420, got **+600**; height moved **+276 unrequested** |
| Shrink back | asked -420, got **-240**; height **+276** again |
| After unmaximize | window reports **5400x2900 on a 4800x2700 monitor** |
| `outer_position()` | returned `Ok(0,0)` — for a window that was not at 0,0 |
| Grow while maximized / fullscreen | ignored, as designed |
| `set_position` | ignored, as expected |
Two independent failures, either one sufficient:
1. **Resize compounds.** Under Wayland GTK owns the frame and shadows; both `outer` and
`inner` report a 0x0 decoration, so every read-back is inflated by a fixed offset and
every write built on a read-back compounds it. There is no size that can be read and
safely written back. Three calls in, the window is larger than the display.
2. **Position is a confident lie, not an honest failure.** `outer_position()` returned
`Ok(0,0)` rather than an error. A design that treats "cannot determine position" as
"do not grow" never triggers, because the value looks perfectly valid. The room check
duly reported `slack: 2820px, VERDICT: Grow` from a false origin.
The second point is what rules out a runtime fallback. A clean failure could have been
handled; a plausible wrong answer cannot be detected from the value itself.
Growing therefore works on one packaging channel and corrupts on another — the split is by
**packaging, not platform**, which is worse than a platform split because two users on
identical hardware and OS would see different behavior. A dock that takes space inward
behaves identically on every backend, OS and package, needs no detection, and reuses a
resize path that is already exercised by every terminal in the app.
**Kept as evidence, not as guidance:** `set_position` was honoured under XWayland. The design
does not move the window and must not start.
## 7. Testing
Vitest + jsdom + React Testing Library for the frontend, `#[cfg(test)]` for Rust, per
CLAUDE.md's Testing section.
**Rust (`notes_store.rs`)**
- `sanitize()` rejects traversal and separator characters in a project id
- atomic write leaves no `.tmp` behind; a crash mid-write leaves the previous file intact
- a corrupt file is moved to `.bak` and the store opens empty rather than erroring
- removing a project deletes its notes file; a delete failure does not fail removal
**Frontend**
- send-target resolution at 0 / 1 / N claude sessions, and that bash sessions are excluded
- the newline transform: a multi-line body becomes `\x1b\r`-joined, with no trailing CR
- the dock's project resolution: terminal tab, home tab, and nothing active
- save-on-blur persists, and dock width round-trips through localStorage
Note the limit `TerminalView.tsx`'s own comment records: jsdom never synthesizes the
follow-up keypress, so keyboard-path bugs of that family are invisible to unit tests. The
send path is a direct `sendInput` call rather than a synthetic keystroke, which sidesteps
that — but anything touching real key handling needs a manual check in Chromium.
## 8. Out of scope for v1
- A detached second window for a second monitor. The store boundary in §2 is drawn so it is
an additive follow-up: emit `notes-changed` from the store's write path, and add a second
narrowly scoped capability granting the notes window `core:event:allow-listen` /
`allow-unlisten``capabilities/default.json` scopes those to `"windows": ["main"]` today,
and cross-window sync needs them. Application commands need no ACL entry (CLAUDE.md, Key
Conventions), so only the events require it. `lib.rs:379-387`'s main-window-only close
handler would need review at that point.
- Syncing notes into the workspace as `.md` for the agent to read unprompted. There is no
generic write-a-file-to-container command today (only `write_file_to_container` for image
paste and `upload_bytes_to_container` for migration), and a second storage path with a
sync direction is a v2 conversation.
- Pinning. `Note.pinned` exists on both sides of the IPC boundary and the backend sorts on
it, but no UI sets it and none indicates it — see §1. A pin control is a user-facing
affordance and belongs in the change that adds it, not in the storage that anticipates it.
- Tags, full-text search, manual reordering, note history.
- Any change to `claude_instructions`. The two features stay distinct: ambient context
versus fired-on-demand items.
## 9. Open questions
None. The Phase 0 spike settled the surface (§6.1); every other decision is recorded in the
table above.
@@ -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>
+317
View File
@@ -0,0 +1,317 @@
#!/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"
# The channel pair lives in its own directory. Left beside the versioned image
# they are picked up by the release job's `*.AppImage` glob, and every release
# then carries an eighty-megabyte byte-identical duplicate under a second name
# — which is exactly as confusing on a downloads page as it sounds.
CHANNEL_DIR="update-channel"
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"
# appimagetool looks for `<desktop basename>.appdata.xml` and warns the
# metadata is missing under any other name — while the script cheerfully
# reported it present. The AppStream id inside the file is unchanged and is
# what actually identifies the component; only the filename follows the tool.
appdata_installed_as="Triple-C.appdata.xml"
dir="${1:?usage: finalize-appimage.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
# Refused here rather than after the repack: with two present the old position
# let the script download appimagetool, repack, overwrite the versioned
# artifact and write the channel pair, *then* fail — and it silently picked
# images[0], which is glob order, i.e. the older version.
if [ ${#images[@]} -ne 1 ]; then
echo "Expected 1 AppImage in $dir, found ${#images[@]}: ${images[*]}" >&2
exit 1
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"
# The demotion and the metadata are independent jobs, and an absent library
# must not skip the second. An early exit here also left `update-channel/`
# uncreated, which killed the publish step on a missing directory and took the
# tag and mirror jobs down with it — a half-published release.
demoted=false
if [ -e "$root/usr/lib/$LIB" ]; then
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
demoted=true
echo "Demoted $LIB to $FALLBACK_DIR."
else
echo "$LIB is not bundled — nothing to demote."
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/$appdata_installed_as"
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.
#
# The AppDir root entry is a **symlink** into usr/share/applications, so a
# plain `sed -i` replaces the link with a regular file and leaves the real entry
# untouched — two divergent copies, of which the empty one is the one that
# actually ships and the filled one is the only one a root-only guard can see.
# `--follow-symlinks` writes through. Both locations are globbed because the
# layout is linuxdeploy's, not ours, and it is free to stop symlinking.
for desktop in "$root"/*.desktop "$root"/usr/share/applications/*.desktop; do
[ -e "$desktop" ] || continue
if grep -q "^Categories=$" "$desktop"; then
sed -i --follow-symlinks "s/^Categories=$/Categories=$CATEGORIES/" "$desktop"
echo "Filled in Categories for ${desktop#"$root"/}."
fi
done
echo "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.
rm -rf "$CHANNEL_DIR"
mkdir -p "$CHANNEL_DIR"
ARCH=x86_64 "$tool" --appimage-extract-and-run \
-u "$UPDATE_INFO" "$root" "$CHANNEL_DIR/$STABLE_NAME" >/dev/null
chmod +x "$CHANNEL_DIR/$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, two places.
# zsyncmake writes the .zsync into the working directory, not beside the image
# it describes, so it has to be collected rather than assumed in place.
[ -e "$STABLE_NAME.zsync" ] && mv "$STABLE_NAME.zsync" "$CHANNEL_DIR/"
cp "$CHANNEL_DIR/$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; }
if [ "$demoted" = true ]; then
[ -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."
fi
[ -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.
# Asserted positively, over every entry: the earlier form checked only that no
# *root* file held an empty value, which passed while the real entry under
# usr/share/applications shipped empty, and also passed on a missing key.
desktops=0
for desktop in "$out"/*.desktop "$out"/usr/share/applications/*.desktop; do
[ -e "$desktop" ] || continue
desktops=$((desktops + 1))
grep -q "^Categories=$CATEGORIES$" "$desktop" \
|| fail "${desktop#"$out"/} does not carry Categories=$CATEGORIES."
done
[ "$desktops" -gt 0 ] || fail "the image contains no .desktop entry at all."
[ -f "$appdata_src" ] && { [ -e "$out/usr/share/metainfo/$appdata_installed_as" ] \
|| 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 "$CHANNEL_DIR/$STABLE_NAME" ] || fail "the stable-named image is missing."
[ -e "$CHANNEL_DIR/$STABLE_NAME.zsync" ] || fail "appimagetool wrote no .zsync."
readelf -p .upd_info "$CHANNEL_DIR/$STABLE_NAME" 2>/dev/null | grep -qF "$UPDATE_INFO" \
|| fail "the image does not carry exactly the expected update information."
grep -aq "^Filename: $STABLE_NAME$" "$CHANNEL_DIR/$STABLE_NAME.zsync" \
|| fail "the .zsync names something other than $STABLE_NAME."
# The versioned release must carry one AppImage, not two. This is the guard
# for the duplicate that shipped in 0.4.20 and 0.4.21.
shopt -s nullglob
beside=(*.AppImage)
shopt -u nullglob
[ "${#beside[@]}" -eq 1 ] \
|| fail "expected 1 AppImage beside the release, found ${#beside[@]}."
if [ "$demoted" = true ]; then
echo "OK: $appimage prefers the host $LIB (fallback kept) and carries"
else
echo "OK: $appimage had no bundled $LIB to demote, and carries"
fi
echo " AppStream metadata. Channel pair in $CHANNEL_DIR/, updating from $UPDATE_TAG."
+258
View File
@@ -0,0 +1,258 @@
#!/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.
#
# **The tag has to exist in Gitea, not just on GitHub, and that is the whole
# reason this script touches Gitea at all.** Gitea push-mirrors this repo to
# GitHub, and a mirror push deletes remote refs that have no local counterpart.
# A tag created only by GitHub's release API therefore survives until the next
# mirror run and then vanishes — which is exactly what happened to 0.4.20 and
# 0.4.21: the release was created and both URLs verified 200 at 00:38, and the
# 13:04 mirror deleted the tag, leaving every installed copy checking a 404.
# Versioned tags never had this problem because `create-tag` creates them in
# Gitea first. So does this one, now, and before the GitHub release rather than
# after, so there is no window where the two disagree.
#
# Note what this means for verification: publishing correctly is not evidence
# the channel still works hours later. The Gitea tag is what makes it durable,
# so its absence is treated as a failure rather than a warning.
#
# Usage: GH_PAT=... GITEA_TOKEN=... GITEA_SHA=... publish-update-channel.sh <dir>
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")
GITEA_API="${GITEA_API:-https://repo.anhonesthost.net/api/v1}"
GITEA_REPO="${GITEA_REPO:-CyberCoveLLC/Triple-C}"
: "${GH_PAT:?GH_PAT is required to publish the update channel}"
: "${GITEA_TOKEN:?GITEA_TOKEN is required to anchor the $TAG tag against the mirror}"
: "${GITEA_SHA:?GITEA_SHA is required to point the $TAG tag at this build}"
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" "$@"; }
tea() { curl -sf -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" "$@"; }
# Status, not a boolean. `curl -sf` fails identically for "404, the tag is
# genuinely absent" and "503, Gitea is briefly unreachable", and treating the
# second as the first means POSTing over a tag that already exists, taking a
# 409, and aborting the last step of build-linux — which `create-tag` and
# `sync-to-github` both depend on. A transient blip would cost the release, not
# just the channel update. Same `case`-on-code idiom as `Upload to Gitea
# release` two steps above in the workflow. A refused connection reports 000
# and lands in the catch-all.
tea_code() { curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $GITEA_TOKEN" "$@"; }
# Anchor the tag in Gitea — see the header. **Created if absent, never moved.**
#
# An earlier version deleted and recreated it so the tag would name the current
# build. That was worse than useless: nothing about the channel depends on
# which commit the tag points at — the update string resolves the tag by *name*
# and the assets hang off the release object — while a DELETE followed by a
# failed POST destroys a working anchor and leaves a window in which a mirror
# run prunes GitHub's copy. A transient Gitea error would have converted a
# healthy channel into a dead one, which is strictly worse than this step not
# existing. Gitea's POST /tags has no force semantics, so the DELETE was only
# ever there to get around a 409; asking first removes the need.
echo "==> Anchoring the $TAG tag in Gitea"
anchor_probe="$(tea_code "$GITEA_API/repos/$GITEA_REPO/tags/$TAG")"
case "$anchor_probe" in
200)
echo " already anchored — left alone"
;;
404)
echo " creating it at ${GITEA_SHA:0:9}"
tea -X POST "$GITEA_API/repos/$GITEA_REPO/tags" \
-d "{\"tag_name\": \"$TAG\", \"target\": \"$GITEA_SHA\", \"message\": \"Rolling Linux update channel\"}" \
>/dev/null
;;
*)
echo "FAILED: Gitea answered $anchor_probe asking whether the $TAG tag exists." >&2
echo " Refusing to guess — creating it blindly would 409 over an" >&2
echo " existing tag and abort the release." >&2
exit 1
;;
esac
# Not best-effort. Without this tag the mirror removes GitHub's and the
# channel dies silently somewhere between now and four hours from now. Reported
# by code, so "Gitea was unreachable" cannot masquerade as "the tag is gone".
anchor_code="$(tea_code "$GITEA_API/repos/$GITEA_REPO/tags/$TAG")"
[ "$anchor_code" = "200" ] || {
echo "FAILED: the $TAG tag is not readable in Gitea (HTTP $anchor_code);" >&2
echo " without it the mirror would delete GitHub's copy." >&2
exit 1
}
# Look through the authenticated list rather than /releases/tags/, which never
# returns drafts. That matters here specifically: GitHub demotes a published
# release to a draft when its tag is deleted, which is the state every mirror
# run left behind, so the by-tag lookup reports "absent" while orphaned drafts
# sit there holding 86 MB each. Reuse the newest and delete the rest, or they
# accumulate one per release forever.
echo "==> Looking for the $TAG release (drafts included)"
all_releases="$(gh "$API/releases?per_page=100")"
mapfile -t existing < <(printf '%s' "$all_releases" | python3 -c '
import sys, json
tag = sys.argv[1]
rs = [r for r in json.load(sys.stdin) if r.get("tag_name") == tag]
rs.sort(key=lambda r: r.get("created_at",""), reverse=True)
for r in rs:
print(r["id"])
' "$TAG")
release_id="${existing[0]:-}"
for stale in "${existing[@]:1}"; do
echo " deleting orphaned duplicate release $stale"
gh -X DELETE "$API/releases/$stale" >/dev/null || true
done
if [ -n "$release_id" ]; then
# A draft has no tag and serves no download URL, so it has to be republished.
echo " reusing release $release_id"
# `make_latest` is not optional here even though this release already exists.
# Publishing a draft is a publish transition, where the API's documented
# default is `true` — so omitting it would quietly promote this channel to
# the repository's "Latest release" and bury the versioned release a person
# actually wants from the releases page.
#
# `tag_name` is re-sent deliberately, and must be: the API removes the tag
# when a PATCH omits it. Given this whole change exists because a tag
# disappeared, that is an expensive line to tidy away.
gh -X PATCH "$API/releases/$release_id" \
-d "{\"tag_name\": \"$TAG\", \"draft\": false, \"make_latest\": \"false\"}" >/dev/null
release="$(gh "$API/releases/$release_id")"
fi
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.
body_json="$(python3 -c '
import json
print(json.dumps({
"tag_name": "'"$TAG"'",
"name": "Linux update channel",
"body": "Rolling AppImage build that Triple-C\u2019s 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",
}))')"
# `already_exists` is a benign, recoverable answer, not a reason to abort the
# last step of build-linux and lose the release with it. It means a release
# for this tag exists but the listing above did not show it — a draft that has
# sunk past the first page, since a draft's created_at is frozen while newer
# releases push it down. Re-ask by tag and carry on.
create_body="$(mktemp)"
create_code="$(curl -s -o "$create_body" -w '%{http_code}' \
-H "Authorization: Bearer $GH_PAT" -H "Accept: application/vnd.github+json" \
-X POST "$API/releases" -d "$body_json")"
case "$create_code" in
201)
release="$(cat "$create_body")"
;;
422)
if grep -q "already_exists" "$create_body"; then
echo " a release for $TAG already exists but was not listed — reusing it"
release="$(gh "$API/releases/tags/$TAG")"
else
echo "FAILED: GitHub rejected the release (422):" >&2
cat "$create_body" >&2
rm -f "$create_body"
exit 1
fi
;;
*)
echo "FAILED: creating the $TAG release returned $create_code:" >&2
cat "$create_body" >&2
rm -f "$create_body"
exit 1
;;
esac
rm -f "$create_body"
release_id="$(printf '%s' "$release" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')"
fi
# One asset at a time, delete immediately followed by upload. Deleting both up
# front leaves the channel holding a fresh AppImage and no .zsync if the second
# upload fails, and a client that cannot fetch the .zsync simply stops updating
# — no error anyone here would see.
asset_ids="$(printf '%s' "$release" | python3 -c '
import sys, json
keep = set(sys.argv[1:])
out = {}
for a in json.load(sys.stdin).get("assets", []):
if a["name"] in keep:
out[a["name"]] = a["id"]
print(json.dumps(out))
' "${ASSETS[@]}")"
# --retry/--max-time/--http1.1 for the reason the Gitea upload steps in this
# repo carry them: real mid-stream failures on large assets (curl 92 and 28).
for asset in "${ASSETS[@]}"; do
stale_id="$(printf '%s' "$asset_ids" | python3 -c 'import sys,json;print(json.load(sys.stdin).get(sys.argv[1],""))' "$asset")"
if [ -n "$stale_id" ]; then
echo "==> Replacing $asset (dropping superseded asset $stale_id)"
gh -X DELETE "$API/releases/assets/$stale_id" >/dev/null || true
fi
echo "==> Uploading $asset ($(du -h "$asset" | cut -f1))"
curl -sf --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 900 \
-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.
# Size as well as status: a 200 only proves something is served at the
# address, not that it is this build. GitHub accepting a truncated upload
# would pass a status-only check and then fail every client's checksum.
echo "==> Verifying the published URLs"
for asset in "${ASSETS[@]}"; do
url="https://github.com/$REPO/releases/download/$TAG/$asset"
local_size="$(stat -c %s "$asset")"
headers="$(curl -sIL "$url" | tr -d '\r')"
code="$(printf '%s\n' "$headers" | awk '/^HTTP\//{c=$2} END{print c}')"
served="$(printf '%s\n' "$headers" | awk 'tolower($1)=="content-length:"{n=$2} END{print n}')"
[ "$code" = "200" ] || { echo "FAILED: $url returned ${code:-no status}" >&2; exit 1; }
[ "$served" = "$local_size" ] \
|| { echo "FAILED: $url serves ${served:-unknown} bytes, built $local_size." >&2; exit 1; }
echo " $code $served bytes $url"
done
echo "OK: $TAG updated, and anchored in Gitea so the mirror preserves it."