- 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.
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
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
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
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
The gate that decides whether a native file drop is accepted has been wrong
twice in opposite directions, both times because it tried to be precise about
*which points* a dialog covers:
- Round 1 asked `el.contains(elementFromPoint(x, y))` and was handed the inner
xterm host while the overlays are siblings, so the always-rendered
Following/Paused button made the terminal's top-right corner permanently
refuse drops.
- Round 2 replaced that with "is a blocking overlay painted here?" and deleted
the document-wide gate. `elementFromPoint` returns the *topmost* element, and
ToastHost is z-[60] against the Modal backdrop's z-50 in the same stacking
context — so a refused drop pushed a toast, the toast covered the dialog, and
the next drop released on it was reported clear and landed in the directory
the dialog was covering. The gate armed its own hole.
Split the two questions instead of merging them:
- Geometry answers *whose* drop it is (rect hit test, unchanged), so exactly
one listener speaks for a drop and a hidden pane's zero-size rect still keeps
TerminalView and FilesTab from both firing.
- `dropIsBlocked` answers whether the app should take a drop at all —
document-wide, no z-index in it. While a modal or blocking overlay is on
screen anywhere, every drop is refused.
There is no `elementFromPoint` call left, so no future overlay can become a
drop hole by being painted high enough and no chrome can become a dead zone by
being painted at all. The cost is over-refusal while a dialog is open, in a
state the user entered deliberately, announced, writing nothing.
Also:
- `[aria-hidden="true"]` no longer disqualifies a blocker. It is not a
visibility statement (it sits on visible decorative content), so a blocker
nested in such a wrapper would have silently stopped blocking.
- Modal drops `data-blocks-drop` when its pane hides, and moves focus out of
itself rather than leaving it inside a `display:none` panel.
- The refusal notice stays `kind: "info"` (an expected refusal is not an
error, and an error card never auto-dismisses) and carries a `dedupeKey`, so
repeated refusals replace rather than stack.
Tests: mutation-checked against the previous implementation — four in
dropTarget.test.ts, two in each of TerminalView/FilesTab, two in Modal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
The API endpoint /releases/assets/{id} returns JSON metadata, not the
binary file. Use the browser_download_url from the asset object instead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add two new workflows:
- sync-release.yml: automatically mirrors releases (with assets) to GitHub when published on Gitea
- backfill-releases.yml: manual workflow to bulk-sync all existing Gitea releases to GitHub
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
close_all_sessions() was called when stopping/removing/rebuilding a
project, which shut down exec sessions for every project. Track
container_id per session and use close_sessions_for_container() to
only close sessions belonging to the target project.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Recreate the container when SSH key path, git name, git email, or git
HTTPS token change — not just when the docker socket toggle changes.
The claude config named volume persists across recreation so no data
is lost.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>