Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

752 tests pass, 62 files.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-02 09:08:50 -07:00
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
jknapp ed1dc8502c Merge pull request 'Retire the Arch package, document AppImage desktop integration' (#47) from chore/retire-arch-packaging into main
Secret Scan / scan (push) Successful in 5s
2026-08-28 20:21:01 +00:00
jknapp bd08ce8be2 Merge pull request 'Fix terminal input reordering and Linux terminal rendering' (#46) from fix/terminal-input-ordering-and-linux-rendering into main
Build App / compute-version (push) Successful in 3s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 2m43s
Build App / build-windows (push) Successful in 4m56s
Build App / build-linux (push) Successful in 5m28s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
2026-08-28 20:20:54 +00:00
shadowdaoandClaude Opus 5 7a5c0c1f13 Retire the Arch package, document AppImage desktop integration
Secret Scan / scan (push) Successful in 8s
Secret Scan / scan (pull_request) Successful in 8s
The `triple-c-bin` package was never on the AUR, so installing it meant
downloading a file and running `pacman -U` — the same gesture as making an
AppImage executable, for a second artifact to keep building. And being
`workflow_dispatch`-only it reached 1 release in 28 (only v0.4.16 has a
`.pkg.tar.zst`), while HOW-TO-USE.md told Arch and CachyOS users to download
it from every release. A distribution channel that is absent 27 times out of
28 is worse than not promising one.

`packaging/arch/` and `.gitea/workflows/publish-arch-package.yml` are
preserved whole on `hold/arch-packaging`, the same way the disk panel and
drag-out work were held rather than deleted. What would make an Arch package
worth having is an AUR account and its SSH key as a repo secret — both
one-time manual steps that never happened; the workflow's own header already
said as much about its AUR push step.

This also closes the gap that prompted the review: nothing validated the
PKGBUILD until someone manually dispatched the workflow, making it the only
packaging path with no CI coverage. Removing it removes the untested surface
rather than adding a job to test something nobody installs.

In its place, `scripts/install-appimage.sh` does what a package manager's
install hooks would. An AppImage carries a `.desktop` entry and icons inside
itself, but nothing on the host reads them, so it never appears in the app
launcher. The script extracts the bundled icons into the user's icon theme
and writes a launcher entry — no sudo, nothing outside `~/.local/share`, and
the AppImage itself is never copied or moved.

Two details it gets right on purpose:

  * The `Exec` line is rewritten, not copied. The bundled entry says
    `Exec=triple-c`, which resolves only inside the running AppImage's own
    mount — a verbatim copy gives a launcher entry that starts nothing.
  * Extraction uses `--appimage-extract`, which needs no FUSE, so the script
    works on a machine where *running* the AppImage would first need
    `fuse2` installed. That requirement is now documented too: Arch and
    CachyOS do not ship FUSE 2 by default.

Verified against the real artifact — the AppImage from this repo's own
preview-3a49a67 release: 4 icon sizes install, `desktop-file-validate` passes
with no warnings, `--uninstall` leaves nothing behind, and shellcheck is
clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApLYH6ybHwQFkMCtKuHrrV
2026-08-28 13:11:26 -07:00
shadowdaoandClaude Opus 5 3a49a67c1f Fix terminal input reordering and Linux terminal rendering
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 4s
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 2m41s
Build App (Preview) / build-linux (pull_request) Successful in 5m25s
Build App (Preview) / build-windows (pull_request) Successful in 5m32s
Build App (Preview) / prune-previews (pull_request) Successful in 8s
Two separate defects behind the same report: typing in a container terminal
is sluggish on Linux, and a backspace can land *after* the characters typed
behind it.

The web terminal was the control that separated them. It shares the Docker
exec, the PTY, `exec_manager`, the input channel and its serial writer task,
and xterm.js itself — and it does not exhibit either symptom. Only three
things differ, and each accounts for part of the report.

**Input ordering.** Every keystroke was its own `invoke("terminal_input")`.
That command is `async`, so Tauri spawns each one as an independent task, and
those tasks then race for the session mutex in `ExecSessionManager::send_input`
— nothing preserved the order the bytes were typed in. The serial writer
downstream cannot help, because the order is already lost before anything
reaches the channel. The web terminal gets ordering for free by awaiting
`send_input` inline in a single WebSocket reader loop.

`useTerminal` now holds a per-session queue: one write in flight at a time,
the next only after the previous resolves. Anything typed meanwhile coalesces
into the next chunk, which also collapses a burst of typing into a couple of
IPC round trips rather than one per key. The queue is module scope, not hook
scope, because `useTerminal()` is called from several components — a per-hook
queue would leave speech-to-text, image paste and typing racing each other.
Each caller's promise still settles only when its own bytes have gone, so
`await sendInput(...)` keeps its meaning.

**The DMA-BUF escape hatch did not exist.** `apply_webkit_wayland_workaround`
left any pre-set value alone, including `0`, on a stated assumption that
WebKitGTK reads the variable as a boolean. It reads presence, so
`WEBKIT_DISABLE_DMABUF_RENDERER=0` disabled DMA-BUF exactly like `=1`, and no
value a user could set got the accelerated path back. `0`/`false`/`no`/empty
now remove the variable, which is the only thing WebKitGTK reads as enabled.
The default is unchanged: unset still means disabled on Linux.

**WebGL does not degrade to canvas here.** The comment on that workaround
assumed `@xterm/addon-webgl` would fall back to the canvas renderer once
DMA-BUF was off. Its constructor throws only when WebGL is *absent*, and with
DMA-BUF disabled WebGL is still present — served by software rasterisation.
So the addon loads and every frame is rendered on the CPU, slower than the
canvas renderer it was assumed to fall back to. `AppSettings::terminal_gpu_
rendering` decides whether it loads at all: `None` is auto (on for macOS and
Windows, off on Linux), `Some(_)` forces it either way from Settings →
Terminal. `Option<bool>` rather than `bool` so the zero value means "we
choose" instead of pinning every existing settings file to one answer.

Verified: 643 frontend tests and 530 Rust tests pass, clippy clean, secret
scan clean. The Linux rendering half needs confirming on a real desktop —
neither symptom reproduces in a headless container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApLYH6ybHwQFkMCtKuHrrV
2026-08-28 12:51:18 -07:00
jknapp 88d6bed6db Merge pull request 'Document the Wayland icon-cache-needs-relogin gotcha' (#45) from docs/wayland-icon-cache-note into main
Secret Scan / scan (push) Successful in 6s
2026-08-27 23:15:00 +00:00
shadow-test 6cc48b3266 Document the Wayland icon-cache-needs-relogin gotcha
Secret Scan / scan (push) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
A user hit this after installing the new Arch/CachyOS package (triple-c#34):
icon missing in the app menu, taskbar, and titlebar alike, with no error
in the app's own log. Root cause has nothing to do with the app or its
packaging — GNOME/KDE cache the installed-app list and resolved icons in
the shell process's memory at startup, and Wayland has no equivalent to
X11's soft shell-restart trick to force a live reload. Logging out and
back in fixed it for them.
2026-08-27 15:53:19 -07:00
jknapp 0fad306c25 Merge pull request 'Add an Installation section to HOW-TO-USE.md' (#43) from docs/installation-instructions into main
Secret Scan / scan (push) Successful in 6s
2026-08-27 22:37:19 +00:00
jknapp 8beb62b12c Merge pull request 'Mirror the Arch package to the Gitea release too' (#44) from fix/arch-package-mirror-to-gitea into main
Secret Scan / scan (push) Successful in 4s
2026-08-27 22:21:58 +00:00
shadow-test f2cfc0be8f Also attach the Arch package to the matching Gitea release
Secret Scan / scan (push) Successful in 10s
Secret Scan / scan (pull_request) Successful in 7s
The workflow only ever uploaded to the GitHub release — the Gitea release
for the same version (the plain, unsuffixed vX.Y.Z tag build-app.yml's
Linux job creates, which already holds the .deb/.rpm/.AppImage) never got
it, so it looked missing to anyone checking releases on Gitea instead of
GitHub.

New step mirrors build-app.yml's own Gitea upload step exactly: same
get-or-create-by-tag, delete-existing-asset, upload-as-octet-stream shape,
same REGISTRY_TOKEN secret. Verified the read side (release lookup, asset
listing) against the real v0.4.16 release before writing this — resolves
to the correct release id and correctly finds no existing asset yet.
2026-08-27 15:14:54 -07:00
shadow-test 99c9dd3cc2 Add an Installation section — nothing told a new user how to get the app
Secret Scan / scan (push) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
HOW-TO-USE.md's Prerequisites jumped straight to Docker and a Claude Code
account, assuming Triple-C was already installed; the app itself had no
download/install instructions anywhere in the docs. Covers all six release
assets, including the new Arch/CachyOS .pkg.tar.zst (triple-c#34) that
publish-arch-package.yml now attaches to each release.
2026-08-27 15:06:43 -07:00
67 changed files with 7826 additions and 514 deletions
+12 -3
View File
@@ -319,14 +319,23 @@ jobs:
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }} TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
run: | run: |
export PATH="$HOME/.cargo/bin:$PATH" export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build # AppImage only: the .deb and .rpm were dropped in favour of the one
# artifact that runs everywhere, and building them is pure cost.
# Left as "all" in tauri.conf.json so macOS and Windows are unaffected.
npx tauri build --bundles appimage
# linuxdeploy bundles a libwayland-client.so.0 that shadows the host's
# and breaks Mesa's EGL on systems newer than the build runner, so the
# window comes up blank. It has to come from the host; see the script
# header for the evidence and the trade.
- name: Finalize the AppImage
run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage
- name: Collect artifacts - name: Collect artifacts
run: | run: |
mkdir -p artifacts mkdir -p artifacts
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true cp app/src-tauri/target/release/bundle/appimage/*.zsync artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
ls -la artifacts/ ls -la artifacts/
# Assets, not workflow artifacts — see the note at the top of this file. # Assets, not workflow artifacts — see the note at the top of this file.
+21 -3
View File
@@ -185,14 +185,23 @@ jobs:
working-directory: ./app working-directory: ./app
run: | run: |
export PATH="$HOME/.cargo/bin:$PATH" export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build # AppImage only: the .deb and .rpm were dropped in favour of the one
# artifact that runs everywhere, and building them is pure cost.
# Left as "all" in tauri.conf.json so macOS and Windows are unaffected.
npx tauri build --bundles appimage
# linuxdeploy bundles a libwayland-client.so.0 that shadows the host's
# and breaks Mesa's EGL on systems newer than the build runner, so the
# window comes up blank. It has to come from the host; see the script
# header for the evidence and the trade.
- name: Finalize the AppImage
run: bash scripts/finalize-appimage.sh app/src-tauri/target/release/bundle/appimage
- name: Collect artifacts - name: Collect artifacts
run: | run: |
mkdir -p artifacts mkdir -p artifacts
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true cp app/src-tauri/target/release/bundle/appimage/*.zsync artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
ls -la artifacts/ ls -la artifacts/
- name: Upload to Gitea release - name: Upload to Gitea release
@@ -270,6 +279,15 @@ jobs:
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
done done
# The fixed tag every installed AppImage checks for updates. Separate
# from the versioned release above because the updater's URL must never
# move, and `releases/latest` does.
- name: Publish the Linux update channel
if: gitea.event_name == 'push'
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: bash scripts/publish-update-channel.sh artifacts
build-macos: build-macos:
runs-on: macos-latest runs-on: macos-latest
needs: [compute-version] needs: [compute-version]
-298
View File
@@ -1,298 +0,0 @@
name: Publish Arch Package
# Builds the `triple-c-bin` Arch package (packaging/arch/PKGBUILD) for a
# given release, or the latest one if none is given, and attaches the built
# .pkg.tar.zst to that release on GitHub as a downloadable asset. Manual
# dispatch only — deliberately not triggered by `release` or `push`, for the
# same reason sync-release.yml (removed in triple-c#32) never worked safely
# as an automatic trigger: this repo's releases are assembled by
# build-app.yml across three separate platform jobs, and there is no single
# automatic event that fires only once everything (including the Linux .deb
# this workflow needs) is actually uploaded. A human deciding "this release
# is ready, go package it" is the correct trigger, the same reasoning
# backfill-releases.yml already uses for its own manual-only GitHub sync.
#
# ## What this does and does not do
#
# It renders `packaging/arch/PKGBUILD` for one specific version (real
# download URL, real sha256sums — never guessed; see the resolve-asset step),
# validates it with `makepkg`/`namcap` in a real Arch container, and uploads
# the resulting `.pkg.tar.zst` to the GitHub release it was built from —
# installable by hand with `pacman -U`. It does NOT commit anything back to
# this repo — `packaging/arch/PKGBUILD` stays a hand-maintained template with
# a placeholder version, and the workflow never starts from or writes to it.
#
# ## Not published to the AUR (yet)
#
# This originally also pushed the rendered PKGBUILD to an AUR git repo, which
# needs a maintainer AUR account and its SSH key registered as a secret here
# — both manual, one-time steps neither this workflow nor anyone but a
# maintainer can do. Until that setup happens, a downloadable release asset
# gets the same package to users without it. The AUR push step is still in
# this file's git history (see the commit that added this comment) if that
# setup is ever done and it's worth reinstating.
on:
workflow_dispatch:
inputs:
version:
description: >-
Release version to package, without a leading "v" (e.g. "0.4.14").
Leave empty to use the latest published GitHub release.
required: false
env:
GITHUB_REPO: shadowdao/triple-c
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve version and find the Linux asset
id: resolve
env:
VERSION_INPUT: ${{ inputs.version }}
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
# Authenticated when the secret is available (it is, everywhere
# else in this repo's workflows) to avoid the unauthenticated
# 60-requests/hour-per-IP cap; still works without it, just at that
# lower limit, since this hits nothing but a public repo's public
# releases.
AUTH=()
[ -n "${GH_PAT}" ] && AUTH=(-H "Authorization: Bearer ${GH_PAT}")
if [ -z "${VERSION_INPUT}" ]; then
echo "No version given — resolving the latest GitHub release"
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/latest")
else
echo "Using requested version ${VERSION_INPUT}"
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${VERSION_INPUT}")
fi
TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name')
VERSION="${TAG#v}"
echo "Resolved to ${TAG}"
# Discovered from the real release, not assumed: Tauri names the
# asset after `productName` verbatim ("Triple-C"), not the
# lowercase Cargo binary name, and asset naming is exactly the kind
# of thing that silently drifts if a future Tauri upgrade changes
# bundler defaults — a hardcoded pattern here would then 404
# forever until someone noticed. `head -1` guards against a release
# somehow carrying more than one matching asset, which would
# otherwise pass the emptiness check below and then break the
# download step with two URLs on one line.
DEB_URL=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .browser_download_url' | head -1)
DEB_NAME=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .name' | head -1)
if [ -z "$DEB_URL" ] || [ "$DEB_URL" = "null" ]; then
echo "No *_amd64.deb asset found on release ${TAG}" >&2
exit 1
fi
echo "Found asset: ${DEB_NAME}"
# For attaching the built package to this same release later —
# every release object carries its own `upload_url` regardless of
# whether it was just created or (as here) already existed, and
# the `{?name,label}` URI-template suffix has to come off before
# this is usable as a plain URL to POST to.
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
UPLOAD_URL=$(echo "$RELEASE_JSON" | jq -r '.upload_url' | sed 's/{?name,label}//')
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "deb_url=${DEB_URL}" >> "$GITHUB_OUTPUT"
echo "deb_name=${DEB_NAME}" >> "$GITHUB_OUTPUT"
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
echo "upload_url=${UPLOAD_URL}" >> "$GITHUB_OUTPUT"
- name: Download the release asset and compute real checksums
id: checksums
env:
DEB_URL: ${{ steps.resolve.outputs.deb_url }}
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
TAG: ${{ steps.resolve.outputs.tag }}
run: |
set -euo pipefail
curl -fsSL -o "${DEB_NAME}" "${DEB_URL}"
curl -fsSL -o LICENSE "https://raw.githubusercontent.com/${GITHUB_REPO}/${TAG}/LICENSE"
echo "deb_sha256=$(sha256sum "${DEB_NAME}" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "license_sha256=$(sha256sum LICENSE | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- name: Render PKGBUILD
id: render
env:
VERSION: ${{ steps.resolve.outputs.version }}
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
DEB_SHA256: ${{ steps.checksums.outputs.deb_sha256 }}
LICENSE_SHA256: ${{ steps.checksums.outputs.license_sha256 }}
run: |
set -euo pipefail
mkdir -p rendered
cp packaging/arch/PKGBUILD rendered/PKGBUILD
cd rendered
# Plain string replacement throughout, not sed — the source URL
# contains slashes and the repo name does too, and getting a sed
# delimiter choice AND its escaping right for that is exactly the
# kind of thing that looks correct, passes review, and breaks the
# next time someone touches it. `re.sub` with `count=1` and an
# exact `.format`-free literal match is boring and that's the
# point: every substitution below fails loudly (an assertion /
# the checks after) rather than silently no-op'ing if the
# template's shape ever drifts from what this expects.
#
# pkgrel resets to 1 for a new pkgver — a packaging-only fix to the
# same upstream version (a dependency bump, say) is what pkgrel is
# for, and this workflow always republishes the current PKGBUILD
# verbatim rather than incrementing anything, so 1 is always
# correct for what this workflow does. It is NOT correct for a
# dependency-only fix republished at the *same* pkgver: pkgrel
# would be forced back to 1, and no existing installation sees an
# upgrade. That case needs a manual pkgrel bump in the template
# before dispatching, which this workflow has no input for.
python3 - "$VERSION" "$DEB_NAME" "$DEB_SHA256" "$LICENSE_SHA256" "$GITHUB_REPO" <<'PY'
import re, sys
version, deb_name, deb_sha, license_sha, github_repo = sys.argv[1:6]
with open("PKGBUILD") as f:
text = f.read()
text, n = re.subn(r"(?m)^pkgver=.*$", f"pkgver={version}", text, count=1)
assert n == 1, "pkgver=... line not found"
text, n = re.subn(r"(?m)^pkgrel=.*$", "pkgrel=1", text, count=1)
assert n == 1, "pkgrel=... line not found"
# Built with a "$" variable and plain "+" concatenation rather than
# an f-string's double-brace escape for a literal brace: writing
# this as an f-string put a dollar sign directly against two open
# braces, right here in this workflow's own YAML text — and this
# runner's own expression templating scans a run: block for that
# exact two-character opening sequence and tries to evaluate
# whatever sits inside as one of ITS OWN expressions (a step
# output, a secret, ...) before the shell ever sees this script.
# "pkgver" isn't one of those, so that lookup failed and silently
# emptied this whole step rather than raising anything here.
# Spelling the dollar sign out of a variable instead means this
# file's own text never contains that trigger sequence.
DOLLAR = "$"
old_source = (
"source=(\"Triple-C_" + DOLLAR + "{pkgver}_amd64.deb::"
+ "https://github.com/" + github_repo + "/releases/download/v" + DOLLAR + "{pkgver}/"
+ "Triple-C_" + DOLLAR + "{pkgver}_amd64.deb\""
)
new_source = (
f'source=("{deb_name}::'
f'https://github.com/{github_repo}/releases/download/v{version}/{deb_name}"'
)
assert old_source in text, "source=() line does not match the expected template shape"
text = text.replace(old_source, new_source, 1)
old_sums = "sha256sums=('SKIP'\n 'SKIP')"
assert old_sums in text, "sha256sums=() placeholders not found"
text = text.replace(old_sums, f"sha256sums=('{deb_sha}'\n '{license_sha}')", 1)
with open("PKGBUILD", "w") as f:
f.write(text)
PY
grep -q "pkgver=${VERSION}$" PKGBUILD
! grep -q "SKIP" PKGBUILD
- name: Validate with makepkg and namcap
id: build
run: |
set -euo pipefail
# A bind mount (`docker run -v "$PWD/...":/work`) is the more
# obvious way to write this, and was the first draft — but on a
# containerized Gitea act_runner job, `$PWD` is a path inside this
# job's own container, which the daemon's host cannot resolve; the
# mount would silently attach an empty directory instead of failing
# loudly. `docker cp` moves real bytes across that boundary
# regardless of where the daemon actually lives, which is what
# makes this work under both a bind-mount-capable runner and a
# containerized one.
docker pull archlinux:latest
CID=$(docker create -w /work archlinux:latest bash -c '
set -euo pipefail
pacman -Syu --noconfirm --needed base-devel namcap sudo git openssh >/dev/null
useradd -m builder
chown -R builder:builder /work
echo "builder ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/builder
sudo -u builder bash -c "cd /work && makepkg --printsrcinfo > .SRCINFO"
sudo -u builder bash -c "cd /work && makepkg -s --noconfirm"
# Named once here, inside the container, rather than guessed
# from options=(!strip !debug) plus pkgver/pkgrel/arch on the
# host after the fact — makepkg is the one place that actually
# knows its own output name, and `!debug` already guarantees
# this glob can only ever match the one real package (no
# -debug split package gets produced).
basename /work/*.pkg.tar.* > /work/.pkgfile
echo "--- namcap ---"
NAMCAP_OUT=$(sudo -u builder bash -c "cd /work && namcap PKGBUILD *.pkg.tar.*" || true)
echo "$NAMCAP_OUT"
# Matches "triple-c-bin E:", "PKGBUILD (triple-c-bin) E:" and any
# split-package variant ("triple-c-bin-debug E:") alike — namcap
# uses more than one line shape for its two rule families, and
# namcap itself exits 0 regardless of what it reports, so this
# grep is the only thing standing between an E: and a green job.
if echo "$NAMCAP_OUT" | grep -q " E: "; then
echo "namcap reported an error — see above" >&2
exit 1
fi
')
mkdir -p rendered
docker cp rendered/. "${CID}:/work"
# `docker start -a` streams output and its exit code is the
# container's own — the same failure this would have hit with a
# bind mount still fails the job the same way.
docker start -a "${CID}"
docker cp "${CID}:/work/.SRCINFO" rendered/.SRCINFO
docker cp "${CID}:/work/.pkgfile" rendered/.pkgfile
PKG_FILE=$(cat rendered/.pkgfile)
docker cp "${CID}:/work/${PKG_FILE}" "rendered/${PKG_FILE}"
docker rm -f "${CID}" >/dev/null
echo "pkg_file=${PKG_FILE}" >> "$GITHUB_OUTPUT"
- name: Attach the package to the GitHub release
env:
GH_PAT: ${{ secrets.GH_PAT }}
TAG: ${{ steps.resolve.outputs.tag }}
RELEASE_ID: ${{ steps.resolve.outputs.release_id }}
UPLOAD_URL: ${{ steps.resolve.outputs.upload_url }}
PKG_FILE: ${{ steps.build.outputs.pkg_file }}
run: |
set -euo pipefail
if [ -z "${GH_PAT}" ]; then
echo "GH_PAT is not set — this step needs it to attach a release asset." >&2
exit 1
fi
# A manual re-dispatch for a version that's already been packaged
# would otherwise hit GitHub's 422 "already_exists" here instead
# of just replacing the stale build with this one.
EXISTING_ID=$(curl -fsS -H "Authorization: Bearer ${GH_PAT}" -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}/assets" \
| jq -r --arg name "$PKG_FILE" '.[] | select(.name == $name) | .id')
if [ -n "$EXISTING_ID" ]; then
echo "Replacing the existing ${PKG_FILE} (asset id ${EXISTING_ID}) already on ${TAG}"
curl -fsS -X DELETE -H "Authorization: Bearer ${GH_PAT}" -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/assets/${EXISTING_ID}"
fi
curl -fsS -X POST \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/octet-stream" \
--data-binary "@rendered/${PKG_FILE}" \
"${UPLOAD_URL}?name=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "${PKG_FILE}")" \
> /dev/null
echo "Attached ${PKG_FILE} to ${TAG}"
+38
View File
@@ -677,6 +677,44 @@ deliberately out of scope — this is not a project backup.
specifically to make them unmissable — the frontend's `<li>`/warning boxes also get `break-all` specifically to make them unmissable — the frontend's `<li>`/warning boxes also get `break-all`
as a second layer against the same failure mode. as a second layer against the same failure mode.
## Packaging
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
the AppImage, for a second artifact to keep working. Being `workflow_dispatch`-only it also
reached 1 release in 28, while `HOW-TO-USE.md` told Arch users to download it from every release.
An AUR account and its SSH key as a repo secret are what would make it worth having; until then
the AppImage is the Arch story.
`scripts/install-appimage.sh` is the desktop-integration half, and it exists because an AppImage
has no installer: it extracts the bundled icons into `~/.local/share/icons/hicolor` and writes a
`.desktop` entry. It **rewrites** the `Exec` line rather than copying the bundled entry — the
bundled one is `Exec=triple-c`, which resolves only inside the AppImage's own mount, so a
verbatim copy yields a launcher entry that starts nothing. It keeps `StartupWMClass` exactly as
the bundle sets it, which is what lets the shell match the window to the entry. Extraction uses
`--appimage-extract`, which needs no FUSE, so the script works before `fuse2` is installed.
## Testing ## Testing
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file: Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
+66
View File
@@ -6,6 +6,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
## Table of Contents ## Table of Contents
- [Installation](#installation)
- [Prerequisites](#prerequisites) - [Prerequisites](#prerequisites)
- [First Launch](#first-launch) - [First Launch](#first-launch)
- [The Interface](#the-interface) - [The Interface](#the-interface)
@@ -32,6 +33,65 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
--- ---
## Installation
Download the build for your platform from [GitHub Releases](https://github.com/shadowdao/triple-c/releases/latest).
| Platform | File | Install |
|----------|------|---------|
| **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. |
| **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
An AppImage is a single executable file and nothing else. It carries a `.desktop`
entry and icons *inside* itself, but nothing on your system ever reads them,
because nothing installed it — so it will not show up in your app launcher, and
running it from a file manager gives you a generic icon in the taskbar.
Put the AppImage somewhere stable first — `~/Apps` or `~/.local/bin`, not
`~/Downloads` — because the launcher entry points at wherever the file is:
```bash
mkdir -p ~/Apps
mv ~/Downloads/Triple-C_*_amd64.AppImage ~/Apps/
./scripts/install-appimage.sh ~/Apps/Triple-C_0.4.17_amd64.AppImage
```
That copies the bundled icons into `~/.local/share/icons/hicolor` and writes
`~/.local/share/applications/triple-c.desktop` pointing at the file you named.
No sudo, nothing outside your home directory, and the AppImage itself is never
copied or moved. To remove the entry again:
```bash
./scripts/install-appimage.sh --uninstall
```
The script rewrites the `Exec` line rather than reusing the bundled `.desktop`
verbatim: the bundled one says `Exec=triple-c`, which resolves only inside the
running AppImage's own mount, so a launcher entry copied straight out of the
bundle would appear in the menu and then fail to start anything.
Two follow-ups worth knowing:
- **Upgrading.** The entry names one specific file. If you replace the AppImage
with a newer version under a different filename, re-run the script against the
new one. Keeping a stable name (`~/Apps/Triple-C.AppImage`) avoids this.
- **The icon may not appear until you log out.** That is the desktop shell's
icon cache, not a failed install — see
[App Icon Missing After Installing (Linux)](#app-icon-missing-after-installing-linux).
## Prerequisites ## Prerequisites
### Docker ### Docker
@@ -1536,3 +1596,9 @@ cp ~/.claude.json ~/.claude.json.bak && jq 'with_entries(select(.key | startswit
``` ```
This backs up your config and removes the corrupted marketplace entries. Claude Code will re-download them cleanly on the next startup. This backs up your config and removes the corrupted marketplace entries. Claude Code will re-download them cleanly on the next startup.
### App Icon Missing After Installing (Linux)
If Triple-C's icon shows as generic or blank right after installing — in the app menu, taskbar, and window titlebar alike — **log out and back in.**
Desktop shells (GNOME Shell, KDE Plasma) cache the list of installed apps and their resolved icons in memory when the shell starts, for performance. A freshly installed package's icon files land on disk correctly and its install hooks do rebuild the on-disk icon cache, but an already-running shell doesn't always notice — on X11 there used to be a way to soft-restart just the shell (GNOME's Alt+F2 → `r`) to force a reload, but under Wayland the shell *is* the compositor, so restarting it means ending the session. Logging out and back in starts a fresh shell that reads the current on-disk state, which picks the icon up.
-6
View File
@@ -418,12 +418,6 @@ triple-c/
│ ├── build-stt.yml # Build the STT image │ ├── build-stt.yml # Build the STT image
│ ├── backfill-releases.yml # Bulk copy releases to GitHub │ ├── backfill-releases.yml # Bulk copy releases to GitHub
│ ├── cleanup-releases.yml # Prune old releases │ ├── cleanup-releases.yml # Prune old releases
│ └── publish-arch-package.yml # Build triple-c-bin, attach it to the GitHub release (packaging/arch/)
├── packaging/
│ └── arch/ # triple-c-bin Arch package — see packaging/arch/README.md
│ ├── PKGBUILD
│ └── README.md
└── app/ # Tauri v2 desktop application └── app/ # Tauri v2 desktop application
├── package.json # React, xterm.js, zustand, tailwindcss ├── package.json # React, xterm.js, zustand, tailwindcss
+1
View File
@@ -8,6 +8,7 @@ pub mod help_commands;
pub mod inspect_commands; pub mod inspect_commands;
pub mod install_helper_commands; pub mod install_helper_commands;
pub mod migration_commands; pub mod migration_commands;
pub mod notes_commands;
pub mod project_commands; pub mod project_commands;
pub mod settings_commands; pub mod settings_commands;
pub mod settings_export_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. // holding an entire snapshot image that nothing will ever reference again.
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await; 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` // Stop and remove container if it exists. Everything named in `report`
// below is what will be unreachable the moment this function drops the // below is what will be unreachable the moment this function drops the
// project record — see [`ProjectRemovalReport`] and // project record — see [`ProjectRemovalReport`] and
+4
View File
@@ -470,6 +470,10 @@ pub fn run() {
commands::project_commands::stop_project_container, commands::project_commands::stop_project_container,
commands::project_commands::rebuild_project_container, commands::project_commands::rebuild_project_container,
commands::project_commands::reconcile_project_statuses, 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 // Container base-image migration
commands::migration_commands::get_container_staleness, commands::migration_commands::get_container_staleness,
commands::migration_commands::migrate_project_to_base, commands::migration_commands::migrate_project_to_base,
+103 -12
View File
@@ -3,10 +3,19 @@
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path /// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
/// since 2.42) fails outright on some Mesa/driver/compositor combinations /// since 2.42) fails outright on some Mesa/driver/compositor combinations
/// under Wayland, printing `Could not create default EGL display: /// under Wayland, killing the webview and leaving a blank window — see
/// EGL_BAD_PARAMETER. Aborting.` straight to stderr from WebKitGTK's own C /// triple-c#34, reported on CachyOS/Arch with Wayland.
/// code and killing the webview before Triple-C's own logging even starts — ///
/// see triple-c#34, reported on CachyOS/Arch with Wayland. /// **This is not the only cause of a blank window, and the error text alone
/// does not tell them apart.** An earlier version of this comment quoted
/// `Could not create default EGL display: EGL_BAD_PARAMETER. Aborting.` as
/// the error this fixes. The AppImage produces that same string for an
/// entirely unrelated reason: it bundled a `libwayland-client.so.0` that
/// shadowed the host's, and the host's `libEGL_mesa.so.0` has a hard
/// DT_NEEDED on that library, so the EGL driver failed to load before any
/// renderer choice was reachable. This flag was set, and correctly, and made no difference —
/// which cost a round of debugging that started from the comment rather than
/// from the evidence. See `scripts/unbundle-wayland-client.sh`.
/// ///
/// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that /// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that
/// variable is exported into an XWayland client's environment too, so a /// variable is exported into an XWayland client's environment too, so a
@@ -26,12 +35,27 @@
/// their own init time, which happens inside the Tauri builder that /// their own init time, which happens inside the Tauri builder that
/// function calls into, not at binary load. /// function calls into, not at binary load.
/// ///
/// A user who has already set this themselves is left alone. That includes /// A user who has already set this themselves is left alone — with one
/// setting it to `0`, on the assumption WebKitGTK treats it as a boolean /// correction. The earlier version of this function left *any* pre-set value
/// rather than presence-only — not verified against WebKitGTK's own source, /// alone, including `0`, on the assumption WebKitGTK reads the variable as a
/// so if it turns out to be presence-only, `=0` still reads as "set" here /// boolean. WebKitGTK reads it as presence-only, so `WEBKIT_DISABLE_DMABUF_
/// and disables DMA-BUF the same as any other value, which is at least the /// RENDERER=0` disabled DMA-BUF exactly like `=1` did, and there was no value
/// safe direction to be wrong in. /// at all a user could set to get the accelerated path back: the escape hatch
/// the comment described did not exist. `0`, `false` and empty are now treated
/// as an explicit opt-out and the variable is *removed*, which is the only
/// thing WebKitGTK reads as "enabled". The default is unchanged — unset still
/// means disabled on Linux, so nobody who was not deliberately overriding this
/// sees any difference.
///
/// That matters more than it looks, because the trade described above is not
/// the trade actually being made. `@xterm/addon-webgl` does not fall back to
/// the canvas renderer here: its constructor throws only when WebGL is
/// *absent*, and with DMA-BUF disabled WebGL is still present — served by
/// software rasterisation. So the addon loads happily and every terminal frame
/// is rendered on the CPU and copied, which is slower than the canvas renderer
/// this comment assumed it would degrade to, not faster. See
/// `terminal_gpu_rendering` in `AppSettings` for the switch that decides
/// whether the addon is loaded at all.
/// ///
/// This env var also leaks to whatever the app spawns afterwards — notably /// This env var also leaks to whatever the app spawns afterwards — notably
/// a cold-launched default browser via the `opener` plugin's `xdg-open` /// a cold-launched default browser via the `opener` plugin's `xdg-open`
@@ -39,10 +63,77 @@
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but /// URL; most non-WebKitGTK browsers ignore the variable entirely), but
/// worth knowing before chasing the "links don't open" half of triple-c#34 /// worth knowing before chasing the "links don't open" half of triple-c#34
/// as a separate, unrelated cause. /// as a separate, unrelated cause.
#[cfg(target_os = "linux")]
const DMABUF_VAR: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
/// What to do with `WEBKIT_DISABLE_DMABUF_RENDERER`, given whatever it is
/// already set to. Split from the mutation so it can be tested without
/// touching process-wide environment state from a parallel test runner.
#[cfg(target_os = "linux")]
#[derive(Debug, PartialEq, Eq)]
enum DmabufAction {
/// Not set by the user — apply the workaround.
Disable,
/// Explicitly opted out. WebKitGTK reads presence, not value, so the only
/// way to express "enabled" is for the variable not to exist.
Remove,
/// Set to something meaning "disabled". Already what we want; leave it.
LeaveAlone,
}
#[cfg(target_os = "linux")]
fn dmabuf_action(current: Option<&str>) -> DmabufAction {
match current {
None => DmabufAction::Disable,
Some(value) => match value.trim().to_ascii_lowercase().as_str() {
"" | "0" | "false" | "no" => DmabufAction::Remove,
_ => DmabufAction::LeaveAlone,
},
}
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn apply_webkit_wayland_workaround() { fn apply_webkit_wayland_workaround() {
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { let current = std::env::var(DMABUF_VAR).ok();
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); match dmabuf_action(current.as_deref()) {
DmabufAction::Disable => std::env::set_var(DMABUF_VAR, "1"),
DmabufAction::Remove => std::env::remove_var(DMABUF_VAR),
DmabufAction::LeaveAlone => {}
}
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::{dmabuf_action, DmabufAction};
#[test]
fn unset_gets_the_workaround() {
assert_eq!(dmabuf_action(None), DmabufAction::Disable);
}
#[test]
fn falsey_values_opt_out_by_removing_the_variable() {
// The bug this replaces: these all previously read as "user set it,
// leave it alone", and WebKitGTK then disabled DMA-BUF anyway because
// it only checks presence. There was no way to ask for the GPU path.
for value in ["0", "false", "no", "", " 0 ", "FALSE", "No"] {
assert_eq!(
dmabuf_action(Some(value)),
DmabufAction::Remove,
"{value:?} should opt out"
);
}
}
#[test]
fn other_values_are_left_alone() {
for value in ["1", "true", "yes", "anything"] {
assert_eq!(
dmabuf_action(Some(value)),
DmabufAction::LeaveAlone,
"{value:?} should be left alone"
);
}
} }
} }
+21
View File
@@ -135,6 +135,26 @@ pub struct AppSettings {
pub gateway: GatewaySettings, pub gateway: GatewaySettings,
#[serde(default)] #[serde(default)]
pub global_claude_code_settings: Option<ClaudeCodeSettings>, pub global_claude_code_settings: Option<ClaudeCodeSettings>,
/// Whether the terminal loads `@xterm/addon-webgl`.
///
/// `None` is "auto", and auto is not the same answer on every platform.
/// On Linux the app disables WebKitGTK's DMA-BUF renderer at startup (see
/// `apply_webkit_wayland_workaround` in `main.rs`, and triple-c#34), which
/// does not remove WebGL — it leaves it backed by software rasterisation.
/// The addon therefore loads successfully and then renders every frame on
/// the CPU, which is slower than the canvas renderer it would otherwise
/// have fallen back to. So auto means enabled on macOS and Windows, and
/// disabled on Linux.
///
/// `Some(true)` / `Some(false)` force it either way on any platform. A
/// Linux user running X11, or one whose driver stack is unaffected, can
/// turn it back on; anyone seeing terminal lag can turn it off without
/// waiting for a release. Deliberately `Option<bool>` rather than `bool`:
/// the zero value has to mean "we choose", not "off", or every existing
/// settings file would silently pin the answer at whatever the default was
/// the day it was written.
#[serde(default)]
pub terminal_gpu_rendering: Option<bool>,
} }
fn default_stt_model() -> String { fn default_stt_model() -> String {
@@ -226,6 +246,7 @@ impl Default for AppSettings {
stt: SttSettings::default(), stt: SttSettings::default(),
gateway: GatewaySettings::default(), gateway: GatewaySettings::default(),
global_claude_code_settings: None, global_claude_code_settings: None,
terminal_gpu_rendering: None,
} }
} }
} }
+6 -4
View File
@@ -1,15 +1,17 @@
pub mod project;
pub mod container_config;
pub mod app_settings; pub mod app_settings;
pub mod container_config;
pub mod gateway_settings; pub mod gateway_settings;
pub mod migration; pub mod migration;
pub mod note;
pub mod project;
pub mod settings_export; pub mod settings_export;
pub mod update_info; pub mod update_info;
pub use project::*;
pub use container_config::*;
pub use app_settings::*; pub use app_settings::*;
pub use container_config::*;
pub use gateway_settings::*; pub use gateway_settings::*;
pub use migration::*; pub use migration::*;
pub use note::*;
pub use project::*;
pub use settings_export::*; pub use settings_export::*;
pub use update_info::*; 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 migration_store;
pub mod notes_store;
pub mod pending_cleanup; pub mod pending_cleanup;
pub mod projects_store; pub mod projects_store;
pub mod secure; 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();
}
}
+2
View File
@@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event";
import Sidebar from "./components/layout/Sidebar"; import Sidebar from "./components/layout/Sidebar";
import TopBar from "./components/layout/TopBar"; import TopBar from "./components/layout/TopBar";
import StatusBar from "./components/layout/StatusBar"; import StatusBar from "./components/layout/StatusBar";
import NotesDock from "./components/layout/NotesDock";
import TerminalView from "./components/terminal/TerminalView"; import TerminalView from "./components/terminal/TerminalView";
import DockerInstallDialog from "./components/DockerInstallDialog"; import DockerInstallDialog from "./components/DockerInstallDialog";
import ProjectHome from "./components/projects/home/ProjectHome"; import ProjectHome from "./components/projects/home/ProjectHome";
@@ -161,6 +162,7 @@ export default function App() {
</div> </div>
)} )}
</main> </main>
<NotesDock />
</div> </div>
<StatusBar stt={stt} /> <StatusBar stt={stt} />
<ToastHost /> <ToastHost />
+6 -12
View File
@@ -10,6 +10,7 @@ import {
} from "../../store/appState"; } from "../../store/appState";
import { effectivePermissionMode } from "../projects/PermissionModeControl"; import { effectivePermissionMode } from "../projects/PermissionModeControl";
import { ProjectStatusIndicator } from "../ui/StatusIndicator"; import { ProjectStatusIndicator } from "../ui/StatusIndicator";
import { sessionDisplayName } from "../../lib/sessionName";
import type { PermissionMode } from "../../lib/types"; import type { PermissionMode } from "../../lib/types";
interface ContextMenuState { interface ContextMenuState {
@@ -195,11 +196,10 @@ export default function MainTabs() {
} }
const session = sessions.find((s) => s.id === tabKeyId(key)); const session = sessions.find((s) => s.id === tabKeyId(key));
if (!session) return ""; if (!session) return "";
const custom = getCustomName(session.projectId, session.id); return sessionDisplayName(
return custom session,
? `${session.projectName}: ${custom}` projects.find((p) => p.id === session.projectId),
: (session.sessionName ?? session.projectName) + );
(session.sessionType === "bash" ? " (bash)" : "");
}; };
const endDrag = () => { const endDrag = () => {
@@ -358,13 +358,7 @@ export default function MainTabs() {
const session = sessions.find((s) => s.id === sessionId); const session = sessions.find((s) => s.id === sessionId);
if (!session) return null; if (!session) return null;
const project = projects.find((p) => p.id === session.projectId); const project = projects.find((p) => p.id === session.projectId);
const customName = getCustomName(session.projectId, session.id); const displayLabel = sessionDisplayName(session, project);
const baseLabel =
(session.sessionName ?? session.projectName) +
(session.sessionType === "bash" ? " (bash)" : "");
const displayLabel = customName
? `${session.projectName}: ${customName}`
: baseLabel;
const isRenaming = renamingId === session.id; const isRenaming = renamingId === session.id;
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null; 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>
);
}
+11 -1
View File
@@ -10,7 +10,7 @@ interface Props {
export default function StatusBar({ stt }: Props) { export default function StatusBar({ stt }: Props) {
const { const {
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled, projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
terminalAtBottom, scrollActiveToBottom, terminalAtBottom, scrollActiveToBottom, notesDockOpen, toggleNotesDock,
} = useAppState( } = useAppState(
useShallow(s => ({ useShallow(s => ({
projects: s.projects, projects: s.projects,
@@ -20,6 +20,8 @@ export default function StatusBar({ stt }: Props) {
sttEnabled: s.appSettings?.stt?.enabled, sttEnabled: s.appSettings?.stt?.enabled,
terminalAtBottom: s.terminalAtBottom, terminalAtBottom: s.terminalAtBottom,
scrollActiveToBottom: s.scrollActiveToBottom, scrollActiveToBottom: s.scrollActiveToBottom,
notesDockOpen: s.notesDockOpen,
toggleNotesDock: s.toggleNotesDock,
})) }))
); );
const running = projects.filter((p) => p.status === "running").length; const running = projects.filter((p) => p.status === "running").length;
@@ -69,6 +71,14 @@ export default function StatusBar({ stt }: Props) {
Jump to Current Jump to Current
</button> </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 && ( {sttEnabled && activeSessionId && (
<SttButton <SttButton
state={stt.state} 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,118 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import AddProjectDialog from "./AddProjectDialog";
const add = vi.fn();
vi.mock("../../hooks/useProjects", () => ({
useProjects: () => ({ add }),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(async () => null),
}));
/** A promise whose resolution this test controls, so `loading` can be held open. */
function deferred() {
let resolve!: (v: unknown) => void;
const promise = new Promise((r) => {
resolve = r;
});
return { promise, resolve };
}
function fillValidForm() {
fireEvent.change(screen.getByLabelText("Project name"), {
target: { value: "my-project" },
});
fireEvent.change(screen.getByLabelText("Folder 1 host path"), {
target: { value: "/home/user/my-project" },
});
}
function submitButton() {
return screen.getByRole("button", { name: /Add Project|Adding/ });
}
describe("AddProjectDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("adds the project with the name and folder entered", async () => {
add.mockResolvedValue({ id: "p1" });
const onClose = vi.fn();
render(<AddProjectDialog onClose={onClose} />);
fillValidForm();
fireEvent.click(submitButton());
await waitFor(() =>
expect(add).toHaveBeenCalledWith("my-project", [
{ host_path: "/home/user/my-project", mount_name: "my-project" },
]),
);
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("keeps the submit button announced, and explains why, while adding", async () => {
const { promise, resolve } = deferred();
add.mockReturnValue(promise);
render(<AddProjectDialog onClose={vi.fn()} />);
fillValidForm();
fireEvent.click(submitButton());
// Native `disabled` would remove the button from the accessibility tree
// exactly when it has something to say.
await waitFor(() =>
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
);
expect(submitButton()).not.toBeDisabled();
expect(submitButton()).toHaveAccessibleDescription(/being added/i);
await act(async () => resolve({ id: "p1" }));
});
it("ignores clicks and Enter/Space on the submit button while adding", async () => {
const { promise, resolve } = deferred();
add.mockReturnValue(promise);
render(<AddProjectDialog onClose={vi.fn()} />);
fillValidForm();
fireEvent.click(submitButton());
await waitFor(() =>
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
);
fireEvent.click(submitButton());
fireEvent.keyDown(submitButton(), { key: "Enter" });
fireEvent.keyDown(submitButton(), { key: " " });
expect(add).toHaveBeenCalledTimes(1);
await act(async () => resolve({ id: "p1" }));
});
it("ignores a form submit raised from elsewhere while adding", async () => {
const { promise, resolve } = deferred();
add.mockReturnValue(promise);
render(<AddProjectDialog onClose={vi.fn()} />);
fillValidForm();
fireEvent.click(submitButton());
await waitFor(() =>
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
);
// Enter in a text field submits a form regardless of the submit button's
// state, so the handler has to guard itself too.
// Modal portals to document.body, so the form is not under `container`.
const form = document.querySelector("form");
expect(form).not.toBeNull();
fireEvent.submit(form!);
expect(add).toHaveBeenCalledTimes(1);
await act(async () => resolve({ id: "p1" }));
});
it("leaves the submit button plainly available when idle", () => {
render(<AddProjectDialog onClose={vi.fn()} />);
expect(submitButton()).not.toHaveAttribute("aria-disabled");
expect(submitButton()).toHaveAccessibleDescription("");
});
});
@@ -55,6 +55,10 @@ export default function AddProjectDialog({ onClose }: Props) {
const handleSubmit = async (e?: React.FormEvent) => { const handleSubmit = async (e?: React.FormEvent) => {
if (e) e.preventDefault(); if (e) e.preventDefault();
// The submit button is `aria-disabled` rather than `disabled` while an add
// is in flight, and Enter inside a text field submits the form without
// touching the button at all. Both routes end here, so the guard does too.
if (loading) return;
if (!name.trim()) { if (!name.trim()) {
setError("Project name is required"); setError("Project name is required");
return; return;
@@ -97,7 +101,19 @@ export default function AddProjectDialog({ onClose }: Props) {
<Button size="md" variant="ghost" onClick={onClose}> <Button size="md" variant="ghost" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button size="md" variant="primary" type="submit" form={formId} disabled={loading}> <Button
size="md"
variant="primary"
type="submit"
form={formId}
unavailable={loading}
unavailableReason="The project is being added. Wait for it to finish."
title={
loading
? "The project is being added. Wait for it to finish."
: undefined
}
>
{loading ? "Adding…" : "Add Project"} {loading ? "Adding…" : "Add Project"}
</Button> </Button>
</> </>
@@ -122,14 +122,6 @@ describe("ProjectRow", () => {
}); });
it("only allows opening a terminal while the container runs", () => { it("only allows opening a terminal while the container runs", () => {
const { unmount } = render(<ProjectRow project={baseProject} />);
expect(
screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
}),
).toBeDisabled();
unmount();
render(<ProjectRow project={{ ...baseProject, status: "running" }} />); render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
fireEvent.click( fireEvent.click(
screen.getByRole("button", { screen.getByRole("button", {
@@ -139,6 +131,38 @@ describe("ProjectRow", () => {
expect(mockOpenClaudeTerminal).toHaveBeenCalled(); expect(mockOpenClaudeTerminal).toHaveBeenCalled();
}); });
it("keeps the terminal button announced, and explains why, while stopped", () => {
render(<ProjectRow project={baseProject} />);
const button = screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
});
// Native `disabled` would drop the button out of the accessibility tree
// and out of the tab order, taking the reason with it.
expect(button).not.toBeDisabled();
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAccessibleDescription(/is not running/i);
});
it("ignores clicks and Enter/Space on the terminal button while stopped", () => {
render(<ProjectRow project={baseProject} />);
const button = screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
});
fireEvent.click(button);
fireEvent.keyDown(button, { key: "Enter" });
fireEvent.keyDown(button, { key: " " });
expect(mockOpenClaudeTerminal).not.toHaveBeenCalled();
});
it("drops aria-disabled once the container is running", () => {
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
const button = screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
});
expect(button).not.toHaveAttribute("aria-disabled");
expect(button).not.toHaveAccessibleDescription(/is not running/i);
});
it("shows container progress inline rather than in a blocking modal", () => { it("shows container progress inline rather than in a blocking modal", () => {
setStore({ containerProgress: { "test-1": "Pulling image…" } }); setStore({ containerProgress: { "test-1": "Pulling image…" } });
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />); render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
+18 -4
View File
@@ -3,6 +3,7 @@ import type { Project } from "../../lib/types";
import { useAppState, homeTabKey } from "../../store/appState"; import { useAppState, homeTabKey } from "../../store/appState";
import { useProjectActions } from "../../hooks/useProjectActions"; import { useProjectActions } from "../../hooks/useProjectActions";
import { ProjectStatusIndicator } from "../ui/StatusIndicator"; import { ProjectStatusIndicator } from "../ui/StatusIndicator";
import { useUnavailable } from "../ui/unavailable";
interface Props { interface Props {
project: Project; project: Project;
@@ -31,6 +32,15 @@ export default function ProjectRow({ project }: Props) {
const isTransitioning = const isTransitioning =
project.status === "starting" || project.status === "stopping"; project.status === "starting" || project.status === "stopping";
// A terminal needs a running container. Saying so out loud beats a `disabled`
// attribute that hides the button — and the reason — from anyone not using a
// mouse and eyes.
const terminal = useUnavailable({
unavailable: !isRunning,
reason: `${project.name} is not running. Start it to open a terminal.`,
onClick: () => openClaudeTerminal(),
});
return ( return (
<div <div
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${ className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
@@ -113,11 +123,14 @@ export default function ProjectRow({ project }: Props) {
</button> </button>
<button <button
type="button" type="button"
disabled={!isRunning} {...terminal.controlProps}
onClick={() => openClaudeTerminal()} title={
title={`Open a Claude terminal for ${project.name}`} isRunning
? `Open a Claude terminal for ${project.name}`
: `${project.name} is not running. Start it to open a terminal.`
}
aria-label={`Open a Claude terminal for ${project.name}`} aria-label={`Open a Claude terminal for ${project.name}`}
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors" className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent aria-disabled:cursor-not-allowed transition-colors"
> >
<svg <svg
className="w-3.5 h-3.5" className="w-3.5 h-3.5"
@@ -134,6 +147,7 @@ export default function ProjectRow({ project }: Props) {
<line x1="13" y1="15" x2="17" y2="15" /> <line x1="13" y1="15" x2="17" y2="15" />
</svg> </svg>
</button> </button>
{terminal.reasonNode}
</div> </div>
</div> </div>
); );
@@ -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 ConfigTab from "./ConfigTab";
import FilesTab from "./FilesTab"; import FilesTab from "./FilesTab";
import BrowserTab from "./BrowserTab"; import BrowserTab from "./BrowserTab";
import NotesTab from "./NotesTab";
import { formatUptime } from "./format"; import { formatUptime } from "./format";
import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport"; import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport";
@@ -28,6 +29,7 @@ const TABS = [
{ id: "config", label: "Config" }, { id: "config", label: "Config" },
{ id: "files", label: "Files" }, { id: "files", label: "Files" },
{ id: "browser", label: "Browser" }, { id: "browser", label: "Browser" },
{ id: "notes", label: "Notes" },
] as const; ] as const;
export type ProjectHomeTabId = (typeof TABS)[number]["id"]; export type ProjectHomeTabId = (typeof TABS)[number]["id"];
@@ -255,6 +257,7 @@ export default function ProjectHome({ projectId, active }: Props) {
{tab === "browser" && ( {tab === "browser" && (
<BrowserTab project={project} active={active && tab === "browser"} /> <BrowserTab project={project} active={active && tab === "browser"} />
)} )}
{tab === "notes" && <NotesTab project={project} />}
</div> </div>
{showMigration && ( {showMigration && (
@@ -15,6 +15,8 @@ import type { EnvVar } from "../../lib/types";
import Tooltip from "../ui/Tooltip"; import Tooltip from "../ui/Tooltip";
import AccordionSection from "../ui/AccordionSection"; import AccordionSection from "../ui/AccordionSection";
import Toggle from "../ui/Toggle"; import Toggle from "../ui/Toggle";
import SegmentedControl from "../ui/SegmentedControl";
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
import WebTerminalSettings from "./WebTerminalSettings"; import WebTerminalSettings from "./WebTerminalSettings";
import SttSettings from "./SttSettings"; import SttSettings from "./SttSettings";
import SharedAuthSettings from "./SharedAuthSettings"; import SharedAuthSettings from "./SharedAuthSettings";
@@ -67,6 +69,14 @@ export default function SettingsPanel() {
} }
}; };
const handleGpuRenderingChange = async (value: "auto" | "on" | "off") => {
if (!appSettings) return;
await saveSettings({
...appSettings,
terminal_gpu_rendering: value === "auto" ? null : value === "on",
});
};
const handleAutoCheckToggle = async () => { const handleAutoCheckToggle = async () => {
if (!appSettings) return; if (!appSettings) return;
await saveSettings({ ...appSettings, auto_check_updates: !appSettings.auto_check_updates }); await saveSettings({ ...appSettings, auto_check_updates: !appSettings.auto_check_updates });
@@ -242,6 +252,45 @@ export default function SettingsPanel() {
<SttSettings /> <SttSettings />
</AccordionSection> </AccordionSection>
<AccordionSection id="terminal" title="Terminal" defaultOpen={false}>
<div className="space-y-2">
<label className="text-xs text-[var(--text-secondary)]">GPU rendering</label>
<SegmentedControl
label="Terminal GPU rendering"
value={
appSettings?.terminal_gpu_rendering == null
? "auto"
: appSettings.terminal_gpu_rendering
? "on"
: "off"
}
onChange={handleGpuRenderingChange}
segments={[
{
value: "auto",
label: "Auto",
hint: resolveTerminalGpuRendering(null, navigator.userAgent)
? "On for this platform."
: "Off on Linux — the DMA-BUF workaround leaves WebGL on software rendering, which is slower than the canvas renderer.",
},
{
value: "on",
label: "On",
hint: "Always load the WebGL renderer.",
},
{
value: "off",
label: "Off",
hint: "Always use xterm's canvas renderer. Try this if typing feels laggy.",
},
]}
/>
<p className="text-xs text-[var(--text-secondary)]">
Takes effect when a terminal tab is next switched to.
</p>
</div>
</AccordionSection>
<AccordionSection id="updates" title="Updates" defaultOpen={false}> <AccordionSection id="updates" title="Updates" defaultOpen={false}>
<div className="space-y-2"> <div className="space-y-2">
{appVersion && ( {appVersion && (
@@ -538,3 +538,59 @@ describe("TerminalView — reaching the URL prompt without a mouse", () => {
expect(document.activeElement).toBe(before); expect(document.activeElement).toBe(before);
}); });
}); });
describe("TerminalView — focus on request", () => {
/** Mount, then deliberately give focus away, so what the assertions below
* observe is the *request* taking effect and never the focus `active`
* already grants on mount. That distinction is the whole point: the notes
* dock sends to a terminal whose tab is already active, where nothing
* changes and no `active` effect re-runs. */
async function mountAndBlur() {
const view = mountSession("claude");
await act(async () => {});
const elsewhere = document.createElement("button");
document.body.appendChild(elsewhere);
elsewhere.focus();
expect(document.activeElement).toBe(elsewhere);
return view;
}
it("focuses the terminal named by the request", async () => {
const view = await mountAndBlur();
await act(async () => {
useAppState.getState().requestTerminalFocus("s1");
});
expect(document.activeElement).toBe(helperTextarea(view.container));
});
it("ignores a request meant for another session", async () => {
const view = await mountAndBlur();
const before = document.activeElement;
await act(async () => {
useAppState.getState().requestTerminalFocus("s2");
});
expect(document.activeElement).toBe(before);
expect(document.activeElement).not.toBe(helperTextarea(view.container));
});
it("clears the request, so a second send focuses again", async () => {
const view = await mountAndBlur();
await act(async () => {
useAppState.getState().requestTerminalFocus("s1");
});
expect(useAppState.getState().pendingTerminalFocus).toBeNull();
const elsewhere = document.querySelector("button");
(elsewhere as HTMLButtonElement).focus();
await act(async () => {
useAppState.getState().requestTerminalFocus("s1");
});
expect(document.activeElement).toBe(helperTextarea(view.container));
});
});
+43 -10
View File
@@ -7,6 +7,7 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput";
import { import {
awsSsoRefresh, awsSsoRefresh,
openPageInContainerBrowser, openPageInContainerBrowser,
@@ -28,6 +29,7 @@ import UrlToast, {
URL_TOAST_SHORTCUT, URL_TOAST_SHORTCUT,
} from "./UrlToast"; } from "./UrlToast";
import { trimSelection } from "./trimSelection"; import { trimSelection } from "./trimSelection";
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
import TerminalContextMenu from "./TerminalContextMenu"; import TerminalContextMenu from "./TerminalContextMenu";
interface Props { interface Props {
@@ -95,6 +97,7 @@ export default function TerminalView({ sessionId, active }: Props) {
const webglRef = useRef<WebglAddon | null>(null); const webglRef = useRef<WebglAddon | null>(null);
const detectorRef = useRef<UrlDetector | null>(null); const detectorRef = useRef<UrlDetector | null>(null);
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal(); const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection); const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom); const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom); const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
@@ -413,7 +416,7 @@ export default function TerminalView({ sessionId, active }: Props) {
!event.isComposing && !event.isComposing &&
sessionTypeRef.current === "claude" sessionTypeRef.current === "claude"
) { ) {
sendInput(sessionId, "\x1b\r"); sendInput(sessionId, CLAUDE_SOFT_NEWLINE);
// **`preventDefault()` is what stops the submit, not the `return false`.** // **`preventDefault()` is what stops the submit, not the `return false`.**
// //
// xterm's `_keyDown` returns the instant a custom handler says `false` // xterm's `_keyDown` returns the instant a custom handler says `false`
@@ -491,7 +494,11 @@ export default function TerminalView({ sessionId, active }: Props) {
// Handle user input -> backend // Handle user input -> backend
const inputDisposable = term.onData((data) => { const inputDisposable = term.onData((data) => {
sendInput(sessionId, data); // Ordered and coalesced by the queue in `useTerminal`; a rejection here
// means the session is gone, which the exit listener already reports.
sendInput(sessionId, data).catch((e) =>
console.error("Failed to send terminal input:", e)
);
}); });
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow. // Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
@@ -684,7 +691,16 @@ export default function TerminalView({ sessionId, active }: Props) {
const term = termRef.current; const term = termRef.current;
if (!term) return; if (!term) return;
if (active) { // Auto on macOS/Windows, off on Linux, overridable either way — see
// `resolveTerminalGpuRendering`. Loading the addon under a software-GL
// WebKitGTK is slower than xterm's canvas renderer, not faster.
const useGpu = resolveTerminalGpuRendering(gpuRenderingSetting, navigator.userAgent);
// The renderer and the activation work are independent: a terminal with
// GPU rendering switched off still has to fit and take focus when its tab
// becomes active. Keeping these in one branch made "GPU off" silently mean
// "never re-fit, never focus".
if (active && useGpu) {
// Attach WebGL renderer // Attach WebGL renderer
if (!webglRef.current) { if (!webglRef.current) {
try { try {
@@ -699,19 +715,36 @@ export default function TerminalView({ sessionId, active }: Props) {
// WebGL not available, canvas renderer is fine // WebGL not available, canvas renderer is fine
} }
} }
} else if (webglRef.current) {
// Release the context — for inactive terminals, and when the setting
// turns GPU rendering off while this terminal is on screen.
try { webglRef.current.dispose(); } catch { /* ignore */ }
webglRef.current = null;
}
if (active) {
fitRef.current?.fit(); fitRef.current?.fit();
if (autoFollowRef.current) { if (autoFollowRef.current) {
term.scrollToBottom(); term.scrollToBottom();
} }
term.focus(); term.focus();
} else {
// Release WebGL context for inactive terminals
if (webglRef.current) {
try { webglRef.current.dispose(); } catch { /* ignore */ }
webglRef.current = null;
}
} }
}, [active]); }, [active, gpuRenderingSetting]);
// Focus on demand, for the caller that cannot rely on the effect above.
// That one keys off `active`, so it covers switching *to* a terminal and
// nothing else — and the notes dock sends to the terminal already on screen,
// where `active` never changes. Consumed once and cleared, so asking twice
// for the same terminal works.
const pendingTerminalFocus = useAppState((s) => s.pendingTerminalFocus);
const clearPendingTerminalFocus = useAppState(
(s) => s.clearPendingTerminalFocus,
);
useEffect(() => {
if (pendingTerminalFocus !== sessionId) return;
termRef.current?.focus();
clearPendingTerminalFocus();
}, [pendingTerminalFocus, sessionId, clearPendingTerminalFocus]);
// Auto-dismiss toast after 30 seconds — unless the user is standing in it. // Auto-dismiss toast after 30 seconds — unless the user is standing in it.
// A keyboard user who has just jumped into the toast is mid-decision, and // A keyboard user who has just jumped into the toast is mid-decision, and
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import Button from "./Button";
const onClick = vi.fn();
const onKeyDown = vi.fn();
describe("Button", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("still supports the native disabled attribute", () => {
render(
<Button disabled onClick={onClick}>
Save
</Button>,
);
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
});
it("stays in the accessibility tree when unavailable, and says why", () => {
render(
<Button unavailable unavailableReason="Stop the container first.">
Save
</Button>,
);
const button = screen.getByRole("button", { name: "Save" });
expect(button).not.toBeDisabled();
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAccessibleDescription("Stop the container first.");
// The reason is a description, not part of the name.
expect(button).toHaveAccessibleName("Save");
});
it("guards clicks and Enter/Space while unavailable", () => {
render(
<Button unavailable unavailableReason="Stop the container first." onClick={onClick}>
Save
</Button>,
);
const button = screen.getByRole("button", { name: "Save" });
fireEvent.click(button);
fireEvent.keyDown(button, { key: "Enter" });
fireEvent.keyDown(button, { key: " " });
expect(onClick).not.toHaveBeenCalled();
});
it("still forwards keys that are not activation keys", () => {
render(
<Button
unavailable
unavailableReason="Stop the container first."
onKeyDown={onKeyDown}
>
Save
</Button>,
);
fireEvent.keyDown(screen.getByRole("button", { name: "Save" }), {
key: "Escape",
});
expect(onKeyDown).toHaveBeenCalled();
});
it("behaves like an ordinary button when available", () => {
render(
<Button unavailable={false} unavailableReason="Stop the container first." onClick={onClick}>
Save
</Button>,
);
const button = screen.getByRole("button", { name: "Save" });
expect(button).not.toHaveAttribute("aria-disabled");
expect(button).toHaveAccessibleDescription("");
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
});
+41 -11
View File
@@ -1,4 +1,5 @@
import type { ButtonHTMLAttributes, ReactNode } from "react"; import type { ButtonHTMLAttributes, ReactNode } from "react";
import { useUnavailable } from "./unavailable";
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost"; export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
export type ButtonSize = "sm" | "md"; export type ButtonSize = "sm" | "md";
@@ -7,22 +8,37 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant; variant?: ButtonVariant;
size?: ButtonSize; size?: ButtonSize;
children: ReactNode; children: ReactNode;
/**
* Unavailable, but still announced. Renders `aria-disabled` and wires
* `unavailableReason` to `aria-describedby` instead of using the native
* `disabled` attribute, which would take the button out of the tab order and
* out of the accessibility tree reason and all. Clicks and Enter/Space are
* guarded for you. Prefer this over `disabled` whenever there is a reason
* worth telling the user.
*/
unavailable?: boolean;
/** Why the button cannot be used. Required for `unavailable` to say anything. */
unavailableReason?: string;
} }
/** /**
* Real buttons with visible bounds and a 24px hit target. * Real buttons with visible bounds and a 24px hit target.
* Filled variants use the *-emphasis tokens so white text clears WCAG AA; * Filled variants use the *-emphasis tokens so white text clears WCAG AA;
* `--accent` stays reserved for foreground/link use. * `--accent` stays reserved for foreground/link use.
*
* The `aria-disabled:` class mirrors below exist because Tailwind's
* `disabled:` variant only matches the native attribute, which `unavailable`
* deliberately does not set. Keep the two lists in step.
*/ */
const VARIANTS: Record<ButtonVariant, string> = { const VARIANTS: Record<ButtonVariant, string> = {
primary: primary:
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)]", "bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] aria-disabled:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
secondary: secondary:
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)]", "bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
danger: danger:
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent", "bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-transparent",
ghost: ghost:
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent", "bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent",
}; };
const SIZES: Record<ButtonSize, string> = { const SIZES: Record<ButtonSize, string> = {
@@ -35,16 +51,30 @@ export default function Button({
size = "sm", size = "sm",
className = "", className = "",
type = "button", type = "button",
unavailable = false,
unavailableReason = "",
children, children,
...rest ...rest
}: Props) { }: Props) {
const { controlProps, reasonNode } = useUnavailable({
unavailable,
reason: unavailableReason,
onClick: rest.onClick,
onKeyDown: rest.onKeyDown,
});
return ( return (
<button <>
type={type} <button
{...rest} type={type}
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`} {...rest}
> {...controlProps}
{children} className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed aria-disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
</button> >
{children}
</button>
{/* Outside the button: inside, the reason would join its accessible name. */}
{reasonNode}
</>
); );
} }
+87
View File
@@ -0,0 +1,87 @@
import {
useId,
type KeyboardEventHandler,
type MouseEventHandler,
type ReactNode,
} from "react";
/** Keys a native `<button>` turns into a click. */
const ACTIVATION_KEYS = new Set([" ", "Spacebar", "Enter"]);
export interface UnavailableControlProps {
"aria-disabled"?: true;
"aria-describedby"?: string;
onClick?: MouseEventHandler<HTMLButtonElement>;
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
}
export interface UnavailableControl {
/** Spread onto the control. Carries the guarded handlers. */
controlProps: UnavailableControlProps;
/**
* Render as a *sibling* of the control inside it the reason would be
* appended to the accessible name instead of the description.
*/
reasonNode: ReactNode;
}
/**
* Makes a control unavailable without hiding it from assistive technology.
*
* `disabled` takes an element out of the tab order *and* out of the
* accessibility tree, so the `title` explaining why it cannot be used is
* announced to nobody and shown only to a sighted user with a mouse. That is
* backwards: the people who most need the reason are the ones who never get
* it. `aria-disabled` keeps the control focusable and announced, and
* `aria-describedby` hands over the reason.
*
* The catch is that `aria-disabled` is advisory it does not block clicks or
* Enter/Space the way `disabled` does. This hook therefore returns the guards
* along with the attributes, so a call site cannot take the announcement
* without the guard. Handlers that a form can reach without going through the
* control (Enter inside a text field submits the form) still have to guard
* themselves.
*/
export function useUnavailable({
unavailable,
reason,
onClick,
onKeyDown,
}: {
unavailable: boolean;
reason: string;
onClick?: MouseEventHandler<HTMLButtonElement>;
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
}): UnavailableControl {
const reasonId = `${useId()}unavailable`;
if (!unavailable) {
return { controlProps: { onClick, onKeyDown }, reasonNode: null };
}
return {
controlProps: {
"aria-disabled": true,
"aria-describedby": reasonId,
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
},
onKeyDown: (e) => {
if (!ACTIVATION_KEYS.has(e.key)) {
onKeyDown?.(e);
return;
}
// Suppress the default action before it can become a click, submit a
// form, or scroll the page.
e.preventDefault();
e.stopPropagation();
},
},
reasonNode: (
<span id={reasonId} className="sr-only">
{reason}
</span>
),
};
}
+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,
};
}
+83
View File
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// The queue lives at module scope in useTerminal, so the command layer is
// mocked and the hook's `sendInput` is exercised through `renderHook`.
const terminalInput = vi.fn<(sessionId: string, data: number[]) => Promise<void>>();
vi.mock("../lib/tauri-commands", () => ({
terminalInput: (sessionId: string, data: number[]) => terminalInput(sessionId, data),
openTerminalSession: vi.fn(),
closeTerminalSession: vi.fn(),
terminalResize: vi.fn(),
pasteImageToTerminal: vi.fn(),
updateProject: vi.fn(),
}));
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() }));
import { renderHook } from "@testing-library/react";
import { useTerminal } from "./useTerminal";
const decode = (bytes: number[]) => new TextDecoder().decode(new Uint8Array(bytes));
describe("useTerminal input ordering", () => {
beforeEach(() => {
terminalInput.mockReset();
});
it("preserves order even when the underlying invokes resolve out of order", async () => {
// Make the *first* call the slowest, which is exactly the race that put a
// backspace behind the characters typed after it.
const resolvers: Array<() => void> = [];
terminalInput.mockImplementation(
() => new Promise<void>((resolve) => resolvers.push(resolve)),
);
const { result } = renderHook(() => useTerminal());
const first = result.current.sendInput("s1", "\x7f"); // backspace
const rest = ["a", "b", "c"].map((ch) => result.current.sendInput("s1", ch));
// Only one write may be in flight at a time.
expect(terminalInput).toHaveBeenCalledTimes(1);
expect(decode(terminalInput.mock.calls[0][1])).toBe("\x7f");
resolvers.shift()!();
await first;
// The three queued keystrokes coalesce into one ordered write.
expect(terminalInput).toHaveBeenCalledTimes(2);
expect(decode(terminalInput.mock.calls[1][1])).toBe("abc");
resolvers.shift()!();
await Promise.all(rest);
const sent = terminalInput.mock.calls.map((c) => decode(c[1])).join("");
expect(sent).toBe("\x7fabc");
});
it("settles each caller's promise and does not drop later writes on failure", async () => {
terminalInput.mockRejectedValueOnce(new Error("boom")).mockResolvedValue(undefined);
const { result } = renderHook(() => useTerminal());
await expect(result.current.sendInput("s2", "x")).rejects.toThrow("boom");
await expect(result.current.sendInput("s2", "y")).resolves.toBeUndefined();
expect(decode(terminalInput.mock.calls[1][1])).toBe("y");
});
it("keeps separate sessions independent", async () => {
terminalInput.mockResolvedValue(undefined);
const { result } = renderHook(() => useTerminal());
await Promise.all([
result.current.sendInput("a", "1"),
result.current.sendInput("b", "2"),
]);
const bySession = terminalInput.mock.calls.map((c) => [c[0], decode(c[1])]);
expect(bySession).toContainEqual(["a", "1"]);
expect(bySession).toContainEqual(["b", "2"]);
});
});
+82 -1
View File
@@ -4,6 +4,86 @@ import { listen } from "@tauri-apps/api/event";
import { useAppState } from "../store/appState"; import { useAppState } from "../store/appState";
import * as commands from "../lib/tauri-commands"; import * as commands from "../lib/tauri-commands";
/**
* Per-session ordered write queue.
*
* Every keystroke used to be its own `invoke("terminal_input")`, and because
* that command is `async` on the Rust side Tauri spawns each one as an
* independent task. Those tasks then race for the session mutex in
* `ExecSessionManager::send_input`, so nothing preserved the order the bytes
* were typed in the visible symptom was a backspace landing *after* the
* characters typed behind it. The serial writer task downstream cannot help,
* because the order is already lost by the time anything reaches the channel.
*
* The queue restores ordering the same way the web terminal gets it for free:
* one write in flight at a time, the next only after the previous resolves.
* Anything typed while a write is in flight coalesces into the next chunk,
* which also collapses a burst of typing into a couple of IPC round trips
* rather than one per key. Concatenating the byte arrays is safe a PTY
* cannot tell one write of "ab" from writes of "a" then "b" and each
* caller's promise still settles only when its own bytes have gone, so
* `await sendInput(...)` keeps the meaning it had.
*
* Module scope, not hook scope, because `useTerminal()` is called from several
* components (App for speech-to-text, TerminalView for typing and image paste,
* useProjectActions for tile commands). A per-hook queue would give each caller
* its own ordering and leave them racing against each other.
*/
type PendingWrite = {
bytes: number[];
resolve: () => void;
reject: (reason: unknown) => void;
};
const inputQueues = new Map<string, { pending: PendingWrite[]; draining: boolean }>();
async function drainInputQueue(sessionId: string): Promise<void> {
const q = inputQueues.get(sessionId);
if (!q || q.draining) return;
q.draining = true;
try {
while (q.pending.length > 0) {
// Take everything queued so far as one batch, preserving order.
const batch = q.pending.splice(0, q.pending.length);
const bytes = batch.flatMap((w) => w.bytes);
try {
await commands.terminalInput(sessionId, bytes);
batch.forEach((w) => w.resolve());
} catch (err) {
// Reject only the writes in this batch. Anything queued while it was
// in flight is still pending and gets its own attempt on the next lap.
batch.forEach((w) => w.reject(err));
}
}
} finally {
q.draining = false;
// Drop the entry once idle so closed sessions do not accumulate.
if (q.pending.length === 0) inputQueues.delete(sessionId);
}
}
function enqueueInput(sessionId: string, bytes: number[]): Promise<void> {
return new Promise<void>((resolve, reject) => {
let q = inputQueues.get(sessionId);
if (!q) {
q = { pending: [], draining: false };
inputQueues.set(sessionId, q);
}
q.pending.push({ bytes, resolve, reject });
void drainInputQueue(sessionId);
});
}
/** Drop any queued input for a session that is going away. */
function discardInputQueue(sessionId: string): void {
const q = inputQueues.get(sessionId);
if (!q) return;
const dropped = q.pending.splice(0, q.pending.length);
dropped.forEach((w) => w.reject(new Error(`Session ${sessionId} closed`)));
if (!q.draining) inputQueues.delete(sessionId);
}
export function useTerminal() { export function useTerminal() {
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } = const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
useAppState( useAppState(
@@ -33,6 +113,7 @@ export function useTerminal() {
const session = currentSessions.find((s) => s.id === sessionId); const session = currentSessions.find((s) => s.id === sessionId);
const project = session ? projects.find((p) => p.id === session.projectId) : undefined; const project = session ? projects.find((p) => p.id === session.projectId) : undefined;
discardInputQueue(sessionId);
await commands.closeTerminalSession(sessionId); await commands.closeTerminalSession(sessionId);
removeSession(sessionId); removeSession(sessionId);
@@ -54,7 +135,7 @@ export function useTerminal() {
const sendInput = useCallback( const sendInput = useCallback(
async (sessionId: string, data: string) => { async (sessionId: string, data: string) => {
const bytes = Array.from(new TextEncoder().encode(data)); const bytes = Array.from(new TextEncoder().encode(data));
await commands.terminalInput(sessionId, bytes); await enqueueInput(sessionId, bytes);
}, },
[], [],
); );
+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);
}
+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)" : "")
);
}
+10 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core"; 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 // Docker
export const checkDocker = () => invoke<boolean>("check_docker"); export const checkDocker = () => invoke<boolean>("check_docker");
@@ -25,6 +25,15 @@ export const rebuildProjectContainer = (projectId: string) =>
export const reconcileProjectStatuses = () => export const reconcileProjectStatuses = () =>
invoke<Project[]>("reconcile_project_statuses"); 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 // Settings
export const getSettings = () => invoke<AppSettings>("get_settings"); export const getSettings = () => invoke<AppSettings>("get_settings");
export const updateSettings = (settings: AppSettings) => export const updateSettings = (settings: AppSettings) =>
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { isLinuxWebview, resolveTerminalGpuRendering } from "./terminalRenderer";
const LINUX = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 Safari/605.1.15";
const MAC = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15";
const WINDOWS = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36";
const ANDROID = "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36";
describe("isLinuxWebview", () => {
it("recognises desktop Linux", () => {
expect(isLinuxWebview(LINUX)).toBe(true);
});
it("does not count Android as desktop Linux", () => {
expect(isLinuxWebview(ANDROID)).toBe(false);
});
it("rejects the other desktop platforms", () => {
expect(isLinuxWebview(MAC)).toBe(false);
expect(isLinuxWebview(WINDOWS)).toBe(false);
});
});
describe("resolveTerminalGpuRendering", () => {
it("auto is off on Linux, where WebGL falls back to software rendering", () => {
expect(resolveTerminalGpuRendering(null, LINUX)).toBe(false);
expect(resolveTerminalGpuRendering(undefined, LINUX)).toBe(false);
});
it("auto is on elsewhere", () => {
expect(resolveTerminalGpuRendering(null, MAC)).toBe(true);
expect(resolveTerminalGpuRendering(null, WINDOWS)).toBe(true);
});
it("an explicit setting wins on every platform", () => {
expect(resolveTerminalGpuRendering(true, LINUX)).toBe(true);
expect(resolveTerminalGpuRendering(false, MAC)).toBe(false);
});
});
+28
View File
@@ -0,0 +1,28 @@
/**
* Decides whether the terminal loads `@xterm/addon-webgl`.
*
* Split out of `TerminalView` so it can be unit-tested without standing up a
* terminal, and so the platform rule lives in exactly one place.
*/
/** True when the webview is running on Linux (WebKitGTK), excluding Android. */
export function isLinuxWebview(userAgent: string): boolean {
return /\bLinux\b/.test(userAgent) && !/\bAndroid\b/.test(userAgent);
}
/**
* Resolve the effective WebGL setting.
*
* `setting` is `AppSettings.terminal_gpu_rendering`: `true`/`false` force the
* answer, `null`/`undefined` mean auto. Auto is on everywhere except Linux
* there the app disables WebKitGTK's DMA-BUF renderer at startup (triple-c#34),
* which leaves WebGL present but software-rasterised, so loading the addon is
* slower than the canvas renderer it would otherwise have fallen back to.
*/
export function resolveTerminalGpuRendering(
setting: boolean | null | undefined,
userAgent: string,
): boolean {
if (typeof setting === "boolean") return setting;
return !isLinuxWebview(userAgent);
}
+16
View File
@@ -290,6 +290,12 @@ export interface AppSettings {
stt: SttSettings; stt: SttSettings;
gateway: GatewaySettings; gateway: GatewaySettings;
global_claude_code_settings: ClaudeCodeSettings | null; global_claude_code_settings: ClaudeCodeSettings | null;
/** Whether the terminal loads the WebGL renderer. `null` is auto: on
* everywhere except Linux, where the DMA-BUF workaround leaves WebGL
* backed by software rasterisation and the addon ends up slower than the
* canvas renderer it would otherwise fall back to. See
* `resolveTerminalGpuRendering` in `lib/terminalRenderer.ts`. */
terminal_gpu_rendering: boolean | null;
} }
/** What `preview_settings_import` returns before anything is applied /** What `preview_settings_import` returns before anything is applied
@@ -559,6 +565,16 @@ export interface SchedulerNotification {
created_at: string; 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 ────────────────────────────────────────────────────────────── // ── Auth bridge ──────────────────────────────────────────────────────────────
/** Which loopback family the container-side listener was found on. /** Which loopback family the container-side listener was found on.
+24
View File
@@ -112,3 +112,27 @@ describe("toasts", () => {
expect(toasts()).toHaveLength(2); expect(toasts()).toHaveLength(2);
}); });
}); });
describe("terminal focus requests", () => {
beforeEach(() => useAppState.setState({ pendingTerminalFocus: null }));
const pending = () => useAppState.getState().pendingTerminalFocus;
it("names the session that should take focus", () => {
useAppState.getState().requestTerminalFocus("s1");
expect(pending()).toBe("s1");
});
// Consumed once, exactly like `pendingHomeTab`. Without the clear, the
// second send to a terminal already holding the request would set the same
// value, no state would change, and no effect would re-run — which is the
// failure this whole mechanism exists to fix.
it("is cleared once consumed, so the same terminal can be asked again", () => {
useAppState.getState().requestTerminalFocus("s1");
useAppState.getState().clearPendingTerminalFocus();
expect(pending()).toBeNull();
useAppState.getState().requestTerminalFocus("s1");
expect(pending()).toBe("s1");
});
});
+137 -1
View File
@@ -1,5 +1,12 @@
import { create } from "zustand"; 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"; 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 * 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 * 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. */ /** Consumed once by `ProjectHome`, then cleared. */
pendingHomeTab: { projectId: string; tab: string } | null; pendingHomeTab: { projectId: string; tab: string } | null;
clearPendingHomeTab: () => void; clearPendingHomeTab: () => void;
/**
* Ask a terminal to take keyboard focus.
*
* `TerminalView` already focuses when its tab *becomes* active, which covers
* switching to a terminal. It cannot cover being asked to focus the terminal
* that is already on screen nothing changes, so no effect re-runs and
* that is the ordinary case for the notes dock, which sits beside the
* terminal it sends to.
*
* Consumed once and cleared, like `pendingHomeTab`: holding the id would
* make a second request for the same terminal a no-op state write.
*/
pendingTerminalFocus: string | null;
requestTerminalFocus: (sessionId: string) => void;
clearPendingTerminalFocus: () => void;
closeHomeTab: (projectId: string) => void; closeHomeTab: (projectId: string) => void;
setActiveTabKey: (key: string) => void; setActiveTabKey: (key: string) => void;
cycleTab: (delta: number) => void; cycleTab: (delta: number) => void;
@@ -98,6 +168,29 @@ interface AppState {
/** Nudge the active tab left/right — the keyboard route to the same thing. */ /** Nudge the active tab left/right — the keyboard route to the same thing. */
moveActiveTab: (delta: number) => void; 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. // Inline container progress, replacing the blocking progress modal.
containerProgress: Record<string, string>; containerProgress: Record<string, string>;
setContainerProgress: (projectId: string, message: string | null) => void; setContainerProgress: (projectId: string, message: string | null) => void;
@@ -127,6 +220,13 @@ interface AppState {
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
setSidebarCollapsed: (collapsed: boolean) => void; setSidebarCollapsed: (collapsed: boolean) => void;
toggleSidebarCollapsed: () => 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; dockerAvailable: boolean | null;
setDockerAvailable: (available: boolean | null) => void; setDockerAvailable: (available: boolean | null) => void;
imageExists: boolean | null; imageExists: boolean | null;
@@ -267,6 +367,9 @@ export const useAppState = create<AppState>((set) => ({
}), }),
pendingHomeTab: null, pendingHomeTab: null,
clearPendingHomeTab: () => set({ pendingHomeTab: null }), clearPendingHomeTab: () => set({ pendingHomeTab: null }),
pendingTerminalFocus: null,
requestTerminalFocus: (sessionId) => set({ pendingTerminalFocus: sessionId }),
clearPendingTerminalFocus: () => set({ pendingTerminalFocus: null }),
closeHomeTab: (projectId) => closeHomeTab: (projectId) =>
set((state) => { set((state) => {
const key = homeTabKey(projectId); const key = homeTabKey(projectId);
@@ -340,6 +443,22 @@ export const useAppState = create<AppState>((set) => ({
return { tabOrder }; 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 // Container progress
containerProgress: {}, containerProgress: {},
setContainerProgress: (projectId, message) => setContainerProgress: (projectId, message) =>
@@ -396,6 +515,23 @@ export const useAppState = create<AppState>((set) => ({
persistSidebarCollapsed(next); persistSidebarCollapsed(next);
return { sidebarCollapsed: 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, dockerAvailable: null,
setDockerAvailable: (available) => set({ dockerAvailable: available }), setDockerAvailable: (available) => set({ dockerAvailable: available }),
imageExists: null, 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);
});
});
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>
-86
View File
@@ -1,86 +0,0 @@
# Maintainer: Triple-C Contributors
#
# This file is regenerated by .gitea/workflows/publish-arch-package.yml on every
# publish — pkgver, the source URL and sha256sums are rewritten from the real,
# already-uploaded release asset, never guessed. Editing pkgver/source/
# sha256sums by hand here only matters until the next automated run overwrites
# them; everything else (depends, pkgdesc, package()) is meant to be hand-
# maintained normally.
#
# "-bin" rather than building from source: this repackages the same .deb
# build-app.yml already produces and publishes, so a user gets exactly the
# binary the project ships and tests, and `makepkg` never needs a Rust
# toolchain, Node, or the dozen -dev packages CLAUDE.md lists for building
# Triple-C itself. The trade-off is the one every "-bin" package makes: it
# assumes the glibc the CI runner (Ubuntu 24.04) linked against is compatible
# with the installing system's — true for essentially every currently
# supported Arch install, since Arch tracks glibc newer than Ubuntu 24.04
# ships, and forward compatibility is the direction that holds.
pkgname=triple-c-bin
pkgver=0.4.0
pkgrel=1
pkgdesc="Sandbox Claude Code inside Docker containers"
arch=('x86_64')
url="https://github.com/shadowdao/triple-c"
license=('MIT')
# Verified against a real release asset (v0.4.14), not Tauri's generic docs:
# downloaded Triple-C_0.4.14_amd64.deb, installed each of these into a real
# Arch container, and re-ran `ldd` on the actual binary until nothing came
# back "not found". `pango` and `libayatana-appindicator` were both in an
# earlier draft — pango isn't directly linked (gtk3 already pulls it in
# transitively, and namcap correctly flags declaring it as redundant), and
# libayatana-appindicator is in Tauri's own linux dependency list but this
# binary never links it at all: there is no tray icon or menu in this app
# (see CLAUDE.md's note that `core:menu`/`core:tray` are dropped for the
# same reason), so it was never a real dependency to begin with.
depends=('cairo' 'desktop-file-utils' 'gdk-pixbuf2' 'glib2' 'gtk3'
'hicolor-icon-theme' 'libsoup3' 'webkit2gtk-4.1')
optdepends=('docker: to actually run the sandboxed containers'
'xdg-utils: opening links from the app in your default browser')
provides=('triple-c')
conflicts=('triple-c')
# !strip: the upstream .deb's binary is already the release build Tauri
# produced and tested; re-stripping a prebuilt binary is unnecessary risk for
# no benefit. It's also what actually suppresses makepkg's debug-package
# machinery here (debug-package extraction requires strip; verified in a
# real build — with !strip alone, no debug package is produced at all).
# !debug is kept anyway, explicit about intent rather than relying on that
# side effect. Without either, makepkg built a usr/src/debug/triple-c-bin
# tree containing a dangling .build-id symlink, which is a real namcap
# error (not just the empty-directory warning it looks like) — there is no
# debug info in this release binary for the machinery to have extracted in
# the first place.
options=('!strip' '!debug')
# Tauri names the asset after `productName` verbatim ("Triple-C"), not the
# lowercase Cargo binary name — verified against the real release, not
# assumed; a lowercase guess here would 404. The LICENSE fetch is separate
# because the .deb itself carries no license file — namcap flags an MIT
# package with nothing under /usr/share/licenses/ as an error, correctly.
source=("Triple-C_${pkgver}_amd64.deb::https://github.com/shadowdao/triple-c/releases/download/v${pkgver}/Triple-C_${pkgver}_amd64.deb"
"LICENSE::https://raw.githubusercontent.com/shadowdao/triple-c/v${pkgver}/LICENSE")
sha256sums=('SKIP'
'SKIP')
package() {
cd "$srcdir"
# A .deb is an ar archive of debian-binary, control.tar.*, data.tar.* — `ar`
# (part of base-devel's binutils) pulls just the payload out. Extracting
# that tar directly into $pkgdir works here with no path rewriting at all:
# verified against the real archive, whose entire payload is
# usr/bin/triple-c, usr/share/applications/Triple-C.desktop and
# usr/share/icons/hicolor/*/apps/triple-c.png — Tauri's Linux bundle for
# this app carries no separate resource directory under usr/lib/, so there
# is nothing that could disagree between Debian's and Arch's package trees
# for it to land in the wrong place.
#
# Globbed rather than named literally: the publish workflow discovers the
# real asset name from the release itself specifically so a Tauri bundler
# naming change can't silently break this — naming the file again here
# would throw that away and fail this one line with an opaque "No such
# file or directory" instead. `source=()` above guarantees exactly one
# `*_amd64.deb` entry, so the glob can only ever match that one file.
ar x ./*_amd64.deb
tar xf data.tar.* -C "$pkgdir"
install -Dm644 "$srcdir/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
-52
View File
@@ -1,52 +0,0 @@
# Arch / CachyOS package
`PKGBUILD` here is the `triple-c-bin` package's template — see triple-c#34
(the "I would like to also have an Arch/CachyOS native version" part of it).
It's written to AUR conventions (and may go there eventually — see
"Publishing" below) but isn't published to the AUR yet.
## Why "-bin"
It repackages the same `.deb` `build-app.yml` already produces, rather than
building from source. That means `makepkg` never needs a Rust toolchain,
Node, or the dozen `-dev` packages CLAUDE.md lists for building Triple-C
itself — and a user gets exactly the binary the project ships and tests,
built on Ubuntu 24.04 in CI. Verified end to end against a real release
(v0.4.14): downloaded the actual `.deb`, confirmed every `depends` entry
against a real `ldd` of the actual binary (two packages that looked right
from Tauri's own docs — `pango`, `libayatana-appindicator` — turned out not
to be real dependencies of *this* binary and were dropped), and ran a real
`makepkg`/`namcap`/`pacman -U` cycle rather than guessing at the shape.
## Publishing
`.gitea/workflows/publish-arch-package.yml` does the actual work: given a
version (or "latest" if none is given), it finds that release's real Linux
asset on GitHub, downloads it, computes real checksums, renders this
template into a version-specific PKGBUILD, validates it with `makepkg` and
`namcap` inside a real Arch container, and attaches the resulting
`.pkg.tar.zst` to that same GitHub release as a downloadable asset —
installable by hand with `sudo pacman -U`.
It is `workflow_dispatch`-only, deliberately — see the workflow file's own
header comment for why an automatic trigger isn't safe here (the same reason
`sync-release.yml` didn't work and was removed in triple-c#32).
**Not on the AUR yet.** Publishing there would need a maintainer AUR account
and its SSH key added as a secret on this repo — both manual, one-time steps
on https://aur.archlinux.org that only a maintainer can do. The workflow's
git history still has the AUR-push step from before this was descoped, if
that setup happens later and it's worth reinstating.
## What's hand-maintained vs. generated
`pkgver`/`pkgrel`/`source`/`sha256sums` in this file are placeholders —
the workflow rewrites them for every real publish and never commits the
result back here, so don't read this file's `pkgver` as "the last published
version." Everything else (`depends`, `pkgdesc`, `package()`) is meant to be
edited by hand normally, the same as any other PKGBUILD.
**A hand-edit made to the rendered PKGBUILD attached to a GitHub release is
not this file.** Every run renders fresh from *this* repo's template, so a
packaging fix belongs here, not in a downloaded copy — the next dispatch for
that version would just overwrite it anyway.
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env bash
#
# Post-process a built AppImage: make it start on modern Mesa, and make it
# adoptable and updatable by an AppImage manager.
#
# Tauri hands off to linuxdeploy, which offers no hook between building the
# AppDir and packing it, so both jobs are done by unpacking the finished image
# and repacking it. That is also why the update information is embedded here
# rather than passed to the bundler.
#
# ---------------------------------------------------------------------------
# 1. The bundled Wayland client
# ---------------------------------------------------------------------------
#
# linuxdeploy-plugin-gtk bundles libwayland-client.so.0 as a dependency of
# GTK, and `AppRun.wrapped` puts the bundled lib directory ahead of the host's
# on the loader path. The host's Mesa then resolves its Wayland EGL platform
# against *our* copy instead of the system one it was built against, and when
# ours is older than Mesa needs, EGL initialisation fails outright:
#
# Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
#
# WebKitGTK prints that from its own C code and kills the webview, so the
# window comes up blank. Measured on CachyOS with wayland 1.26 / Mesa 26.2.1
# against an AppImage built on Ubuntu 22.04 (wayland 1.20): eleven symbols
# Mesa can ask for are missing from the bundled copy, `wl_proxy_get_display`,
# `wl_proxy_get_queue`, `wl_display_create_queue_with_name` and
# `wl_fixes_interface` among them. Removing this one file from the AppDir
# fixes it; removing libwayland-egl or libepoxy does not.
#
# **Building on a newer runner would not fix this.** libwayland-client is a
# host-coupled library in the same way libGL, libEGL and libdrm are: it has to
# match the compositor and Mesa actually running, not the ones the build
# machine had. Any pinned version is wrong on a system newer than the builder,
# so the only correct version is the host's. That is what AppImage excludelists
# are for; this library simply is not on linuxdeploy's.
#
# Bundling a *newer* wayland instead would not fix this either, only defer it.
# The version floor is set by the host's Mesa: `libEGL_mesa.so.0` — the driver
# libglvnd's `libEGL.so.1` dlopens — carries a hard DT_NEEDED on
# libwayland-client.so.0. If those symbols will not resolve, the driver never
# loads, glvnd is left with none, and `eglGetDisplay` reports no display. That
# is why forcing GDK_BACKEND=x11 does not dodge it, and why the symptom is a
# bad-parameter error rather than a link failure. Their Mesa updates independently of our releases, so any version
# we pick is one wayland release away from being too old again.
#
# So the copy is not deleted, it is demoted. It moves to a directory that is
# not on the loader path, and a hook puts that directory on the path only when
# the host has no libwayland-client of its own. Hosts with one — which is
# every host with a graphical desktop, since Mesa itself depends on it — get
# theirs, matching their Mesa. A host without one still gets a working app.
#
# The ordering works because `AppRun.wrapped` appends the inherited
# LD_LIBRARY_PATH after its own AppDir entries, so anything the hook exports
# lands last: a fallback, never an override.
#
# ---------------------------------------------------------------------------
# 2. Metadata an AppImage manager needs
# ---------------------------------------------------------------------------
#
# Two things, neither of which the bundler produces:
#
# * AppStream metadata, so a manager can show what the app is rather than a
# bare filename. appimagetool warns about its absence on every build.
# * Update information embedded in the image — the string that tells a
# manager where to look for a newer build. Without it the app can be
# adopted but never updated, which is the whole point.
#
# The update URL is a **fixed** tag on the GitHub mirror, which is where
# updates are pulled from, rather than `releases/latest`. `latest` follows
# whatever release is newest, and the Gitea-to-GitHub backfill creates one
# GitHub release per Gitea tag — including the `-win` and `-mac` tags, which
# carry no AppImage. A fixed tag cannot be pointed at a release that has none,
# and is equally immune to a release marked prerelease.
#
# The output is named for the fixed tag too. zsync records the filename it was
# generated for and a client resolves it relative to the .zsync URL, so a
# versioned name would send every client looking for the version it already
# has. The versioned copy is written afterwards for the normal release.
#
# It also fills in `Categories=`, which linuxdeploy leaves empty — that is what
# a desktop menu and most managers use to file the application.
#
# Usage: finalize-appimage.sh <directory holding the .AppImage>
set -euo pipefail
LIB="libwayland-client.so.0"
FALLBACK_DIR="usr/lib/wayland-fallback"
HOOK="apprun-hooks/triple-c-wayland-fallback.sh"
APPIMAGE_TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
APP_ID="com.triple-c.desktop"
STABLE_NAME="Triple-C_x86_64.AppImage"
UPDATE_TAG="linux-latest"
UPDATE_INFO="zsync|https://github.com/shadowdao/triple-c/releases/download/${UPDATE_TAG}/${STABLE_NAME}.zsync"
CATEGORIES="Development;Utility;"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
appdata_src="$repo_root/packaging/appimage/$APP_ID.appdata.xml"
dir="${1:?usage: unbundle-wayland-client.sh <bundle/appimage directory>}"
cd "$dir"
shopt -s nullglob
images=(*.AppImage)
shopt -u nullglob
if [ ${#images[@]} -eq 0 ]; then
echo "No .AppImage in $dir — nothing to do." >&2
exit 0
fi
appimage="${images[0]}"
here="$PWD"
work="$(mktemp -d)"
check="$(mktemp -d)"
trap 'rm -rf "$work" "$check"' EXIT
echo "Inspecting $appimage"
( cd "$work" && "$here/$appimage" --appimage-extract >/dev/null )
root="$work/squashfs-root"
if [ ! -e "$root/usr/lib/$LIB" ]; then
# Not a failure: linuxdeploy may have stopped bundling it, which is the
# outcome this script exists to produce.
echo "$LIB is not bundled — leaving $appimage alone."
exit 0
fi
mkdir -p "$root/$FALLBACK_DIR"
mv "$root/usr/lib/$LIB" "$root/$FALLBACK_DIR/$LIB"
cat > "$root/$HOOK" <<'HOOK_EOF'
#! /usr/bin/env bash
# Fall back to the bundled libwayland-client only when the host has none.
#
# The host's copy is the correct one whenever it exists: its Mesa was built
# against it, and `libEGL.so.1` needs symbols from it before it will load.
# Ours is here so a host without any libwayland-client still starts.
#
# This runs before AppRun.wrapped, which appends the inherited
# LD_LIBRARY_PATH after its own entries — so this is always a fallback.
_tc_host_has_wayland_client() {
if command -v ldconfig >/dev/null 2>&1 &&
ldconfig -p 2>/dev/null | grep -q "libwayland-client\.so\.0"; then
return 0
fi
local d
for d in /usr/lib /usr/lib64 /usr/lib/x86_64-linux-gnu \
/lib /lib64 /lib/x86_64-linux-gnu; do
[ -e "$d/libwayland-client.so.0" ] && return 0
done
return 1
}
if ! _tc_host_has_wayland_client; then
_TC_APPDIR="${APPDIR:-"$(dirname "$(readlink -f "$0")")/.."}"
export LD_LIBRARY_PATH="${_TC_APPDIR}/usr/lib/wayland-fallback${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
fi
unset -f _tc_host_has_wayland_client
HOOK_EOF
chmod +x "$root/$HOOK"
# AppRun sources each hook by name rather than globbing the directory, so a
# new hook file is inert until AppRun is told about it.
if ! grep -q "triple-c-wayland-fallback" "$root/AppRun"; then
python3 - "$root/AppRun" <<'PATCH_EOF'
import sys
path = sys.argv[1]
src = open(path).read()
exec_line = 'exec "$this_dir"/AppRun.wrapped "$@"'
if exec_line not in src:
raise SystemExit("AppRun does not have the exec line this patch expects")
src = src.replace(
exec_line,
'source "$this_dir"/apprun-hooks/"triple-c-wayland-fallback.sh"\n' + exec_line,
)
open(path, "w").write(src)
PATCH_EOF
fi
# --- metadata -------------------------------------------------------------
# Version comes from the artifact rather than a second source that could drift.
version="$(printf '%s' "$appimage" | sed -n 's/.*_\([0-9][0-9.]*\)_.*/\1/p')"
[ -n "$version" ] || { echo "Could not read a version out of $appimage" >&2; exit 1; }
if [ -f "$appdata_src" ]; then
mkdir -p "$root/usr/share/metainfo"
sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/" \
"$appdata_src" > "$root/usr/share/metainfo/$APP_ID.appdata.xml"
echo "Added AppStream metadata for $version."
else
echo "No AppStream source at $appdata_src — skipping." >&2
fi
# linuxdeploy emits `Categories=` empty, which files the app nowhere.
for desktop in "$root"/*.desktop; do
[ -e "$desktop" ] || continue
if grep -q "^Categories=$" "$desktop"; then
sed -i "s/^Categories=$/Categories=$CATEGORIES/" "$desktop"
echo "Filled in Categories for $(basename "$desktop")."
fi
done
echo "Demoted $LIB to $FALLBACK_DIR; repacking."
tool="$work/appimagetool"
curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL"
chmod +x "$tool"
# --appimage-extract-and-run: CI runners generally have no FUSE.
# -u embeds the update string and writes "$STABLE_NAME.zsync" beside the image.
ARCH=x86_64 "$tool" --appimage-extract-and-run \
-u "$UPDATE_INFO" "$root" "$STABLE_NAME" >/dev/null
chmod +x "$STABLE_NAME"
# The versioned name is what the per-version release publishes; the stable one
# and its .zsync go to the rolling tag. Same bytes, two names.
cp "$STABLE_NAME" "$appimage"
chmod +x "$appimage"
# The guards are the test. Each one is a way the repack could look like it
# worked while shipping the original bug.
( cd "$check" && "$here/$appimage" --appimage-extract >/dev/null )
out="$check/squashfs-root"
fail() { echo "FAILED: $1" >&2; exit 1; }
[ -e "$out/usr/lib/$LIB" ] && fail "$LIB is still on the loader path."
[ -e "$out/$FALLBACK_DIR/$LIB" ] || fail "the fallback copy of $LIB is missing."
[ -e "$out/$HOOK" ] || fail "the fallback hook is missing."
grep -q "triple-c-wayland-fallback" "$out/AppRun" || fail "AppRun does not source the hook."
[ -x "$out/usr/bin/triple-c" ] || fail "no executable usr/bin/triple-c."
# An empty Categories or missing metadata ships an image a manager cannot file
# or describe, and both fail silently at runtime rather than at build time.
grep -q "^Categories=.\+" "$out"/*.desktop || fail "Categories is still empty."
[ -f "$appdata_src" ] && { [ -e "$out/usr/share/metainfo/$APP_ID.appdata.xml" ] \
|| fail "AppStream metadata did not make it into the image."; }
# The update string is the difference between adoptable and updatable. It
# lives in the image's own `.upd_info` ELF section, not in the .zsync — the
# .zsync only records a *relative* filename, which a client resolves against
# the URL it fetched the .zsync from. That is exactly why the output is named
# for the fixed tag: a versioned name here resolves to the build the client
# already has.
[ -e "$STABLE_NAME" ] || fail "the stable-named image is missing."
[ -e "$STABLE_NAME.zsync" ] || fail "appimagetool wrote no $STABLE_NAME.zsync."
readelf -p .upd_info "$STABLE_NAME" 2>/dev/null | grep -q "$UPDATE_TAG" \
|| fail "the image carries no update information for the $UPDATE_TAG tag."
grep -aq "^Filename: $STABLE_NAME$" "$STABLE_NAME.zsync" \
|| fail "the .zsync names something other than $STABLE_NAME."
echo "OK: $appimage prefers the host $LIB (fallback kept), carries AppStream"
echo " metadata, and updates from the $UPDATE_TAG tag via $STABLE_NAME.zsync."
+127
View File
@@ -0,0 +1,127 @@
#!/bin/sh
# Register a Triple-C AppImage with the desktop, so it appears in the app
# launcher with its icon instead of only being runnable from a file manager.
#
# An AppImage is a single executable file and nothing more: it ships a
# `.desktop` entry and icons *inside* itself, but nothing on the host ever
# reads them, because nothing installed it. This script does what a package
# manager's install hooks would — copies the icons into the user's icon theme
# and writes a `.desktop` entry pointing at wherever the AppImage actually
# lives.
#
# ./scripts/install-appimage.sh ~/Apps/Triple-C_0.4.17_amd64.AppImage
# ./scripts/install-appimage.sh --uninstall
#
# Everything goes under ~/.local/share, so there is no sudo and no root-owned
# file to clean up later. The AppImage itself is never copied or moved — the
# launcher entry points at the path you give here, so keep the file somewhere
# stable (`~/Apps` or `~/.local/bin`, not `~/Downloads`) or re-run this after
# moving it.
#
# Extraction uses `--appimage-extract`, which unpacks the payload directly and
# needs no FUSE. So this script works even on a system where *running* the
# AppImage would need `fuse2` installed first.
set -eu
APP_ID="triple-c"
DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor"
DESKTOP_FILE="${DESKTOP_DIR}/${APP_ID}.desktop"
refresh_caches() {
# Both are best-effort: a minimal desktop may ship neither, and neither
# failing means the install did not work.
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "${DESKTOP_DIR}" 2>/dev/null || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -f -t "${ICON_DIR}" 2>/dev/null || true
fi
}
uninstall() {
rm -f "${DESKTOP_FILE}"
find "${ICON_DIR}" -name "${APP_ID}.png" -delete 2>/dev/null || true
refresh_caches
echo "Removed the Triple-C launcher entry and icons."
echo "The AppImage itself was not touched."
}
if [ "${1:-}" = "--uninstall" ]; then
uninstall
exit 0
fi
APPIMAGE="${1:-}"
if [ -z "${APPIMAGE}" ]; then
echo "usage: $0 [--uninstall] /path/to/Triple-C_<version>_amd64.AppImage" >&2
exit 2
fi
if [ ! -f "${APPIMAGE}" ]; then
echo "No such file: ${APPIMAGE}" >&2
exit 1
fi
# An absolute path, because the .desktop Exec line is read from anywhere.
APPIMAGE=$(cd "$(dirname "${APPIMAGE}")" && printf '%s/%s' "$(pwd)" "$(basename "${APPIMAGE}")")
if [ ! -x "${APPIMAGE}" ]; then
echo "Making ${APPIMAGE} executable"
chmod +x "${APPIMAGE}"
fi
WORK=$(mktemp -d)
# shellcheck disable=SC2064 # WORK is expanded now on purpose.
trap "rm -rf '${WORK}'" EXIT INT TERM
echo "Extracting bundled icons from $(basename "${APPIMAGE}")..."
( cd "${WORK}" && "${APPIMAGE}" --appimage-extract >/dev/null )
SRC="${WORK}/squashfs-root"
if [ ! -d "${SRC}/usr/share/icons/hicolor" ]; then
echo "That AppImage has no bundled icons — is it really Triple-C?" >&2
exit 1
fi
# Copy every size the bundle ships, keeping the theme's directory layout.
COUNT=0
while IFS= read -r icon; do
[ -n "${icon}" ] || continue
rel=${icon#"${SRC}/usr/share/icons/hicolor/"}
install -Dm644 "${icon}" "${ICON_DIR}/${rel}"
COUNT=$((COUNT + 1))
done <<EOF
$(find "${SRC}/usr/share/icons/hicolor" -name "${APP_ID}.png")
EOF
echo "Installed ${COUNT} icon size(s) into ${ICON_DIR}"
# Written rather than copied from the bundle. The bundled entry has
# `Exec=triple-c`, which resolves only inside the running AppImage's own mount
# — from the host it names a binary that is not on PATH, so the launcher entry
# would appear and then fail to start anything. `Categories` is empty in the
# bundle too, which leaves the entry to fall into "Other" in most menus.
# `StartupWMClass` is kept exactly as the bundle sets it: it is what lets the
# shell match the running window to this entry, so the taskbar shows the real
# icon instead of a generic placeholder.
mkdir -p "${DESKTOP_DIR}"
cat > "${DESKTOP_FILE}" <<DESKTOP
[Desktop Entry]
Type=Application
Name=Triple-C
Comment=Run Claude Code sandboxed in Docker containers
Exec=${APPIMAGE} %U
Icon=${APP_ID}
Terminal=false
Categories=Development;
StartupWMClass=${APP_ID}
DESKTOP
chmod 644 "${DESKTOP_FILE}"
echo "Wrote ${DESKTOP_FILE}"
refresh_caches
echo
echo "Done. Triple-C should now be in your app launcher."
echo "If the icon is generic or the entry is missing, log out and back in —"
echo "see \"App Icon Missing After Installing (Linux)\" in HOW-TO-USE.md."
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
#
# Publish the AppImage and its .zsync to the fixed `linux-latest` tag on the
# GitHub mirror — the URL every installed copy checks for updates.
#
# This exists because the update URL has to be one that never moves.
# `releases/latest` does move: it follows whatever release is newest, and the
# Gitea-to-GitHub backfill creates one GitHub release per Gitea tag, including
# the `-win` and `-mac` tags that carry no AppImage. Pointing a million
# installed copies at a URL that can resolve to a release with no AppImage in
# it is a failure that shows up on users' machines and nowhere else.
#
# So this tag holds exactly two files, replaced in place on every release.
# The versioned per-release artifacts are published separately and are what a
# human downloads; this is what the updater reads.
#
# It writes to GitHub rather than Gitea because that mirror is where updates
# are pulled from. Needs GH_PAT with contents write on the mirror.
#
# Usage: GH_PAT=... publish-update-channel.sh <directory holding the artifacts>
set -euo pipefail
REPO="shadowdao/triple-c"
TAG="linux-latest"
API="https://api.github.com/repos/$REPO"
ASSETS=("Triple-C_x86_64.AppImage" "Triple-C_x86_64.AppImage.zsync")
: "${GH_PAT:?GH_PAT is required to publish the update channel}"
dir="${1:?usage: publish-update-channel.sh <artifacts directory>}"
cd "$dir"
for asset in "${ASSETS[@]}"; do
[ -e "$asset" ] || { echo "Missing $asset in $dir" >&2; exit 1; }
done
gh() { curl -sf -H "Authorization: Bearer $GH_PAT" -H "Accept: application/vnd.github+json" "$@"; }
echo "==> Looking for the $TAG release"
release="$(gh "$API/releases/tags/$TAG" 2>/dev/null || true)"
release_id="$(printf '%s' "$release" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))' 2>/dev/null || true)"
if [ -z "$release_id" ]; then
echo "==> Creating it"
# Not a prerelease, but deliberately not the "latest" release either: this
# tag is a channel, and it must never displace the versioned release a
# person lands on from the releases page.
release="$(gh -X POST "$API/releases" -d "$(python3 -c '
import json
print(json.dumps({
"tag_name": "'"$TAG"'",
"name": "Linux update channel",
"body": "Rolling AppImage build that Triple-Cs in-app updater reads. "
"The two files here are replaced on every release; for a specific "
"version, use the versioned releases instead.",
"draft": False,
"prerelease": False,
"make_latest": "false",
}))')")"
release_id="$(printf '%s' "$release" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')"
fi
echo "==> Removing superseded assets from release $release_id"
printf '%s' "$release" | python3 -c '
import sys, json
keep = set(sys.argv[1:])
for a in json.load(sys.stdin).get("assets", []):
if a["name"] in keep:
print(a["id"])
' "${ASSETS[@]}" | while read -r asset_id; do
[ -n "$asset_id" ] || continue
gh -X DELETE "$API/releases/assets/$asset_id" >/dev/null || true
done
for asset in "${ASSETS[@]}"; do
echo "==> Uploading $asset ($(du -h "$asset" | cut -f1))"
curl -sf -X POST \
-H "Authorization: Bearer $GH_PAT" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$asset" \
"https://uploads.github.com/repos/$REPO/releases/$release_id/assets?name=$asset" >/dev/null
done
# The updater is only as good as this URL, and a silent failure here means
# every installed copy quietly stops updating. Confirm both are actually
# fetchable at the address the AppImage was built to check.
echo "==> Verifying the published URLs"
for asset in "${ASSETS[@]}"; do
url="https://github.com/$REPO/releases/download/$TAG/$asset"
code="$(curl -s -o /dev/null -w '%{http_code}' -L "$url")"
[ "$code" = "200" ] || { echo "FAILED: $url returned $code" >&2; exit 1; }
echo " $code $url"
done
echo "OK: $TAG updated."