Compare commits

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

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

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

Also from the review:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145mQi9NZiCDrznBUEEDE4n
2026-09-08 11:02:33 -07:00
18 changed files with 864 additions and 215 deletions
+38 -1
View File
@@ -28,6 +28,27 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
with:
# Put BuildKit in the host's network namespace so it can reach
# act_runner's cache service.
#
# The `docker-container` driver — which the multi-arch build below
# requires, since the plain `docker` driver cannot do
# linux/amd64+linux/arm64 — runs BuildKit in its *own* container on
# Docker's default bridge. act_runner advertises ACTIONS_CACHE_URL as
# an address the *job* container can reach, and nothing teaches the
# BuildKit container about it: the job could reach
# 192.168.1.126:40649 while the container actually making the request
# could not, and the build died with `no route to host`.
#
# `no route to host` is EHOSTUNREACH — a firewall rejecting, not a
# missing route (a wrong address times out instead) — which is what a
# default firewalld zone does to traffic arriving from the docker
# bridge. Sharing the host's namespace sidesteps the question
# entirely: the cache address becomes local to BuildKit.
#
# No effect on runners where this already worked.
driver-opts: network=host
- name: Login to Gitea Container Registry - name: Login to Gitea Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -55,5 +76,21 @@ jobs:
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
ghcr.io/shadowdao/triple-c-sandbox:latest ghcr.io/shadowdao/triple-c-sandbox:latest
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }} ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
# `ignore-error` is what stops a cache failure failing a build that
# already succeeded. act_runner emulates the GitHub Actions cache
# service on the runner host's LAN address, and the `docker-container`
# builder `setup-buildx-action` creates could not route to it —
# every layer of both arches built, then the job died on
# `GetCacheEntryDownloadURL: no route to host` while exporting.
#
# On a pull_request `push:` above is false, so this job pushes
# nothing and the cache is its only output: failing it discarded a
# complete, successful validation of the Dockerfile for both
# architectures. A cache is an optimisation and must degrade to
# "slow", never to "red".
#
# The import is already non-fatal — the build ran all 37 layers after
# warning that it could not read the cache — so only the exporter
# needs the flag.
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max,ignore-error=true
+45 -1
View File
@@ -413,6 +413,26 @@ container is created once by a very long function where a dropped capability is
existing toggle: the label fingerprints *the setting*, not the set of things the setting drives, existing toggle: the label fingerprints *the setting*, not the set of things the setting drives,
so a project already at `true` gets no recreation at all on upgrade. so a project already at `true` gets no recreation at all on upgrade.
### Keeping Claude Code current
`claude update` runs in **two** places, and both are needed:
- `container/entrypoint.sh` runs it once per container start, before any session exists.
- `commands/terminal_commands.rs` (and its twin in `web_terminal/ws_handler.rs`) prepend it to the
command every Claude session launches with, because containers use a stop/start model and a
long-lived one would otherwise never re-check.
Both are `timeout`-bounded and `|| echo`'d, so an offline or slow network delays a tab rather than
failing it, and **both take the same `flock` on `/tmp/.triple-c-claude-update.lock`**. That lock is
not tidiness: the entrypoint prints "container ready" only after its own update finishes, so
starting a project and immediately opening a tab — or opening two tabs at once — otherwise runs two
updaters against the same `~/.claude/bin`, and `|| echo` would hide a half-written install behind a
friendly message one line before `exec claude` ran it. `-E 0` makes losing the race a success,
because the holder just did the work. The per-session copy is what forced the non-Bedrock path from a bare `["claude", ...]`
argv into a `bash -c` wrapper — the flags and the session name are interpolated into a shell
string now, so **anything added there must go through `shell_quote_arg`**. Bash sessions are
deliberately untouched.
### Container Lifecycle ### Container Lifecycle
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation. Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
@@ -441,7 +461,7 @@ security update. Migration is the non-destructive way out; Reset is the destruct
or inside a migration. **Never on stop.** So a project in daily use for a year can legitimately or inside a migration. **Never on stop.** So a project in daily use for a year can legitimately
have no `triple-c-snapshot-{id}:latest` at all, and one that has is stale by everything installed have no `triple-c-snapshot-{id}:latest` at all, and one that has is stale by everything installed
since. `pick_probe_source` therefore reads a *stopped* container directly — commit its writable since. `pick_probe_source` therefore reads a *stopped* container directly — commit its writable
layer to `triple-c-probe-{cid}:latest`, probe that, drop it — and ranks it **above** the snapshot, layer to a unique `triple-c-probe-*` image, probe that, drop it — and ranks it **above** the snapshot,
for the same reason a running container already outranked it. Assuming a snapshot existed is what for the same reason a running container already outranked it. Assuming a snapshot existed is what
made a stopped, never-recreated project report "no container or snapshot image yet" with its made a stopped, never-recreated project report "no container or snapshot image yet" with its
container sitting right there, and left Update disabled on the projects furthest behind. container sitting right there, and left Update disabled on the projects furthest behind.
@@ -466,6 +486,30 @@ security update. Migration is the non-destructive way out; Reset is the destruct
other was still reading, reporting a bogus `probe_error` on a healthy project. `get_container_staleness` other was still reading, reporting a bogus `probe_error` on a healthy project. `get_container_staleness`
takes no `project_lock` claim (the migration banner needs it to answer *during* a migration), so takes no `project_lock` claim (the migration banner needs it to answer *during* a migration), so
uniqueness is what makes overlapping probes safe. uniqueness is what makes overlapping probes safe.
- **The stopped-container probe is cached per stop, and that is not an optimisation you may drop.**
`getContainerStaleness` is called from a `useEffect` that fires whenever the container settles, so
merely opening a stopped project's Overview probes it. Uncached that is a `docker commit` of the
whole writable layer per visit — measured at 44 s on a real project, against ~3 s for the snapshot
probe it replaced. `STOPPED_MANIFEST_CACHE` is keyed on the container's `FinishedAt`, which is
exact rather than merely plausible: nothing can write to a stopped container's writable layer, and
`FinishedAt` moves on every stop. A live test asserts the restart case, because a cache that
failed to invalidate would plan a migration against a filesystem the project no longer has.
- **Do not "skip the probe when the project is not stale" to save that cost.** It was tried. The
deltas would be empty while `probeSettled` (`!probing && staleness && !probe_error`) stayed *true*,
which leaves the migrate action in the project menu enabled — that action is not gated on the
banner — so the pre-flight would report nothing to copy while the backend was told to copy
nothing. That is the exact hazard `ProjectHome.tsx`'s `canMigrate` comment already warns about.
- **A failed stopped-container probe falls back to the snapshot whenever one exists.** Before this
feature a stopped project read its snapshot directly, so surfacing a commit failure where the
snapshot could have answered would make the banner *worse* than it was — and the failure modes are
exactly the ones where the fallback earns its keep: a full disk (the commit allocates the whole
writable layer; the snapshot probe allocates nothing) and a 409 from a concurrent claim.
- **`get_container_staleness` never commits while the project is claimed.** It takes no
`project_lock` claim itself, deliberately — the banner has to answer *during* a migration — so it
reads `project_lock::held` instead and probes the snapshot rather than the container. The
collision is not symmetric: the probe losing is a retryable `probe_error`, but
`start_project_container` removes the old container with a hard `?`, so a remove that raced a
commit would fail the user's Start with an opaque error.
- **An image's `Created` is the image's own, not its tag's.** Tagging an existing image gives you - **An image's `Created` is the image's own, not its tag's.** Tagging an existing image gives you
that image's age; BuildKit stamps `docker build` output with a fixed epoch. Only `docker commit` that image's age; BuildKit stamps `docker build` output with a fixed epoch. Only `docker commit`
stamps *now* — which is what real probe images do, and what any fixture for them must do. stamps *now* — which is what real probe images do, and what any fixture for them must do.
+27 -5
View File
@@ -243,7 +243,7 @@ Anthropic-backend project uses that token without its own login. See
│ │ │ │ │ │ │ │ │ │
│ │ └──────────────────────────────────────────────────┘ │ │ │ └──────────────────────────────────────────────────┘ │
├─────────────┴────────────────────────────────────────────────────────┤ ├─────────────┴────────────────────────────────────────────────────────┤
│ 2 project(s) · 1 running · 2 terminal(s) Jump to Current ↓ │ 2 project(s) · 1 running · 2 terminal(s) Notes
└──────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────┘
``` ```
@@ -268,8 +268,8 @@ Anthropic-backend project uses that token without its own login. See
- **Main area** — Shows the active tab: a Project Home view or an xterm.js terminal. With no tabs - **Main area** — Shows the active tab: a Project Home view or an xterm.js terminal. With no tabs
open you get a welcome screen with Docker/image/project readiness checks. open you get a welcome screen with Docker/image/project readiness checks.
- **StatusBar** — Counts of total projects, running containers and open terminal sessions; the - **StatusBar** — Counts of total projects, running containers and open terminal sessions; the
**Jump to Current ↓** button when a terminal is scrolled up; and the microphone button when **🖱 Mouse captured — release** button while a program in the terminal is holding the mouse; the
speech-to-text is enabled. **Notes** toggle; and the microphone button when speech-to-text is enabled.
--- ---
@@ -1224,9 +1224,31 @@ Programs inside the container can copy text to your host clipboard. When a conta
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container as `/tmp/clipboard_<timestamp>.png` and the file path is injected into the terminal input so Claude Code can reference it. A toast notification confirms the upload. You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container as `/tmp/clipboard_<timestamp>.png` and the file path is injected into the terminal input so Claude Code can reference it. A toast notification confirms the upload.
### Jump to Current ### Scrolling
When you scroll up in the terminal to review previous output, a **Jump to Current** button appears in the bottom-right corner. Click it to scroll back to the latest output. Scrolling is the terminal's own: scroll up to read back and it holds position, scroll to the
bottom and it follows new output again. There is no follow toggle — an earlier **Following /
Paused** control and a **Jump to Current** button were retired once they stopped doing anything
useful, because Claude Code draws its interface on the alternate screen, which has no scrollback
for them to act on.
### When the mouse stops working
Some programs ask the terminal for the mouse, so that clicks and drags go to the program instead
of selecting text. If one of them exits without handing the mouse back, the terminal looks stuck:
you cannot select text, and stray characters can appear as you move the pointer.
A **🖱 Mouse captured — release** button appears in the status bar whenever a program holds the
mouse. Click it, or press **Ctrl+Shift+X**, to take the mouse back. Nothing is sent into the
container — only the terminal's own state is reset.
Note that holding the mouse is normal for programs like `htop`, `vim` and Claude Code itself, so
the button is showing most of the time you are in one. It is there for when a program exits
without handing the mouse back and the terminal is left stuck; releasing while a program is still
running just takes the mouse away from that program.
To select text *without* taking the mouse back, hold **Shift** while dragging — or **Option** on
macOS.
### Files ### Files
+1 -1
View File
@@ -528,7 +528,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help | | `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering | | `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering |
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail | | `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic | | `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Notes toggle, STT mic |
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls | | `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar | | `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control | | `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
+1 -1
View File
@@ -58,7 +58,7 @@ choice it never asked about.
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh, Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
container start, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste, container start *and* before every Claude session launches, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
file drag-drop, STT), the web terminal, and workspace backup. file drag-drop, STT), the web terminal, and workspace backup.
--- ---
+6 -3
View File
@@ -62,10 +62,13 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
Implementation gotchas for the terminal view and its global controls (merged in PR #7, `terminal-layout-statusbar`): Implementation gotchas for the terminal view and its global controls (merged in PR #7, `terminal-layout-statusbar`):
- **xterm padding lives on a wrapper, never the host.** FitAddon measures the same element that `term.open()` mounts into, so any padding on that host element makes the grid overhang and clip its rightmost column / bottom row. Padding must live on a **wrapper `div`**; the xterm host fills it with no padding of its own. Do not reintroduce padding on the host element in `TerminalView.tsx`. - **xterm padding lives on a wrapper, never the host.** FitAddon measures the same element that `term.open()` mounts into, so any padding on that host element makes the grid overhang and clip its rightmost column / bottom row. Padding must live on a **wrapper `div`**; the xterm host fills it with no padding of its own. Do not reintroduce padding on the host element in `TerminalView.tsx`.
- **STT mic and "Jump to Current" live in the global `StatusBar`, not per-terminal overlays.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`). - **The STT mic lives in the global `StatusBar`, not a per-terminal overlay.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
- **Recording is pinned to where it started.** The STT transcript targets `recordingSessionIdRef` (the session recording began in), **not** the live active session — switching tabs mid-recording must not misroute the transcript. - **Recording is pinned to where it started.** The STT transcript targets `recordingSessionIdRef` (the session recording began in), **not** the live active session — switching tabs mid-recording must not misroute the transcript.
- **"Jump to Current" state is written only by the active terminal.** The active `TerminalView` surfaces `terminalAtBottom` and `scrollActiveToBottom` through the store; only the active terminal writes them, and they are cleared on its unmount. - **Scrolling is left to xterm, and the "Following" / "Jump to Current" controls that used to drive it are gone.** They were built for the normal buffer. Claude Code draws on the *alternate* screen, which has no scrollback, so in a Claude tab `viewportY` always equalled `baseY`, `isAtBottom` was permanently true and neither control could ever do anything — which is what made them look broken. **They did still work in `bash` tabs**, which run `bash -l` on the normal buffer; removing them is a real behaviour change there, and the justification is that xterm's native follow already covers it, not that nothing was lost. The manual `scrollToBottom()` on every write went with them — it fought that native behaviour, which follows the tail while the viewport is at the bottom and holds position while you read further up. `scrollToBottom()` remains only on activate and after a refit, and **both sample `viewportY >= baseY` before the `fit()`** so they re-anchor only a viewport that was already on the tail: the ResizeObserver fires for the Notes dock, the sidebar drag and any window resize, none of which are a reason to yank a reader to the bottom.
- **Set store function values via object-merge, not the updater form**`set({ fn: value })`, not `set(state => ...)`when publishing action callbacks (like `scrollActiveToBottom`) into the Zustand store. - **A program that grabs the mouse and dies must be escapable without closing the tab.** A TUI sets DECSET `?1000`/`?1002`/`?1003` and, if it exits without resetting them, xterm keeps routing clicks, drags and (under `?1003`) every pointer *move* to the PTY — text selection dies and escape bytes flood the prompt. `TerminalView` reconciles a badge against `term.modes.mouseTrackingMode` **in the `term.write()` callback**: the mode only changes because the container printed a sequence, so one check per write catches every transition with no polling. Releasing writes the resets through `term.write`, **never `sendInput`**the reset belongs to xterm's parser and must not reach the container, or a still-live TUI would simply re-grab the mouse on its next repaint. Bound to the control and to `Ctrl+Shift+X`, because the failure being recovered from is the pointer not working.
- **The release control lives in the `StatusBar`, not over the terminal.** Mouse tracking is the *normal* steady state of every mouse-driven TUI — htop, vim, lazygit and Claude Code all set `?1000`/`?1002` — so a badge painted at `absolute top-2 right-4 z-50` would be on screen for the entire life of those programs and would swallow clicks aimed at that program's own top-right corner, silently killing its mouse with no undo. The active `TerminalView` publishes `terminalMouseCaptured` and `releaseActiveMouse` through the store instead, the same way `terminalHasSelection` and `sttToggle` already do.
- **`macOptionClickForcesSelection: true` is set, and without it macOS has no force-select at all.** `SelectionService.shouldForceSelection` is `isMac ? altKey && macOptionClickForcesSelection : shiftKey`, and the option defaults to `false` — so the "hold Shift to select while a program holds the mouse" escape hatch is Shift everywhere else and **Option** on macOS, and existed on macOS only once this was turned on.
- **Set store function values via object-merge, not the updater form**`set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `sttToggle`) into the Zustand store.
### bollard (Docker API) ### bollard (Docker API)
@@ -142,6 +142,45 @@ fn pick_probe_source(container_running: Option<bool>, snapshot_exists: bool) ->
} }
} }
/// Reported as `probe_error` when another operation owns the project and there
/// is no snapshot image to read instead. Deliberately not a claim about the
/// container: nothing is wrong with it, the answer is simply not safe to take
/// right now. See [`stopped_probe_policy`].
const PROJECT_BUSY: &str = "Another operation is running on this project, so its contents could not be inspected. Try again once it finishes.";
/// What to do about a stopped container, whose probe is the expensive one: it
/// commits the writable layer before it can read anything.
#[derive(Debug, PartialEq, Eq)]
enum StoppedProbe {
/// Commit and probe. The current answer, and the default.
Commit,
/// Probe the snapshot image instead. Less current — it lags the container by
/// everything installed since the last commit — but it allocates nothing and
/// touches nothing, which is what makes it the right answer while another
/// operation owns the container.
SnapshotInstead,
/// Report rather than guess.
Defer,
}
/// Pick what to do about a stopped container.
///
/// **Never commits while the project is claimed.** `get_container_staleness`
/// takes no [`crate::project_lock`] claim of its own, by design, so a commit
/// here can overlap a Recreate or Reset — and the collision is not symmetric.
/// The probe losing is harmless: a surfaced `probe_error` the user retries. The
/// *recreate* losing is not, because `start_project_container` removes the old
/// container with a hard `?`, so a non-404 from a remove that raced this commit
/// fails the whole Start with an opaque "Failed to remove container". Reading
/// the claim costs nothing and takes that failure off the table.
fn stopped_probe_policy(project_is_busy: bool, snapshot_exists: bool) -> StoppedProbe {
match (project_is_busy, snapshot_exists) {
(false, _) => StoppedProbe::Commit,
(true, true) => StoppedProbe::SnapshotInstead,
(true, false) => StoppedProbe::Defer,
}
}
/// Runs two filesystem probes (~3 s each) and is therefore meant to be called /// Runs two filesystem probes (~3 s each) and is therefore meant to be called
/// on demand, not polled. /// on demand, not polled.
/// ///
@@ -220,7 +259,44 @@ pub async fn get_container_staleness(
) { ) {
(ProbeSource::RunningContainer, Some(id)) => mig::manifest_from_container(id).await, (ProbeSource::RunningContainer, Some(id)) => mig::manifest_from_container(id).await,
(ProbeSource::StoppedContainer, Some(id)) => { (ProbeSource::StoppedContainer, Some(id)) => {
mig::manifest_from_stopped_container(id).await let busy = crate::project_lock::held(&project_id).is_some();
match stopped_probe_policy(busy, snapshot_exists) {
StoppedProbe::Commit => {
match mig::manifest_from_stopped_container_cached(id).await {
Ok(m) => Ok(m),
// **Never let a failed commit cost an answer the
// snapshot could have given.** Before stopped
// containers were readable at all, a stopped project
// fell straight through to its snapshot, so surfacing
// this error where the snapshot exists would make the
// banner *worse* than it was — and the ways this fails
// are the ones where the fallback matters most: a full
// disk (the commit has to allocate the whole writable
// layer; the snapshot probe allocates nothing) and a
// 409 from an operation that claimed the project after
// the check above.
Err(e) if snapshot_exists => {
log::warn!(
"Probing the stopped container for project {} failed ({}) — \
falling back to its snapshot image, which may lag it",
project_id,
e
);
mig::manifest_from_image(&snapshot_image).await
}
Err(e) => Err(e),
}
}
StoppedProbe::SnapshotInstead => {
log::info!(
"Project {} is claimed by another operation — probing its snapshot image \
rather than committing the container",
project_id
);
mig::manifest_from_image(&snapshot_image).await
}
StoppedProbe::Defer => Err(PROJECT_BUSY.to_string()),
}
} }
(ProbeSource::Snapshot, _) => mig::manifest_from_image(&snapshot_image).await, (ProbeSource::Snapshot, _) => mig::manifest_from_image(&snapshot_image).await,
// `container_running` is `Some` exactly when `container_id` is, so the // `container_running` is `Some` exactly when `container_id` is, so the
@@ -2073,6 +2149,23 @@ mod tests {
assert_eq!(pick_probe_source(None, false), ProbeSource::Nothing); assert_eq!(pick_probe_source(None, false), ProbeSource::Nothing);
} }
#[test]
fn a_stopped_container_is_committed_only_when_nothing_else_owns_the_project() {
assert_eq!(stopped_probe_policy(false, false), StoppedProbe::Commit);
assert_eq!(stopped_probe_policy(false, true), StoppedProbe::Commit);
}
#[test]
fn a_busy_project_falls_back_rather_than_racing_a_recreate() {
// The snapshot lags, but a stale answer beats failing someone's Start.
assert_eq!(
stopped_probe_policy(true, true),
StoppedProbe::SnapshotInstead
);
// Nothing to fall back to: say so instead of committing anyway.
assert_eq!(stopped_probe_policy(true, false), StoppedProbe::Defer);
}
#[test] #[test]
fn byte_sizes_read_the_way_a_disk_warning_should() { fn byte_sizes_read_the_way_a_disk_warning_should() {
assert_eq!(human_bytes(512), "512 B"); assert_eq!(human_bytes(512), "512 B");
+187 -27
View File
@@ -6,10 +6,58 @@ use crate::AppState;
/// Build the command to run in the container terminal. /// Build the command to run in the container terminal.
/// ///
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates /// Always a `bash -c` script, because every session runs [`UPDATE_PRELUDE`]
/// the AWS session first. If the SSO session is expired, runs `aws sso login` /// before `exec claude`. For Bedrock Profile projects the script additionally
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon). /// validates the AWS session first, and runs `aws sso login` if it has expired
/// so the user can re-authenticate (the URL is clickable via xterm.js
/// WebLinksAddon).
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> { fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
let settings = state.settings_store.get();
build_claude_terminal_cmd(
project,
settings.global_aws.aws_profile.as_deref(),
session_name,
)
}
/// Shell line run immediately before `exec claude` in every Claude terminal
/// session.
///
/// `container/entrypoint.sh` already runs `claude update` when the container
/// starts, but containers here use a stop/start (and often just keep running)
/// model, so a long-lived container's CLI goes stale between restarts. Running
/// it per session is what keeps a week-old container current.
///
/// Deliberately non-fatal and time-bounded: `|| echo` swallows a failure (no
/// network, npm registry down) so a session always opens, and `timeout 60`
/// bounds how long a user waits for a terminal.
///
/// **`flock` is load-bearing, not tidiness.** Nothing serialises this against
/// the entrypoint's own `claude update`, and the entrypoint prints "container
/// ready" only *after* its copy finishes — so "start the project, open a tab"
/// races two updaters against the same `~/.claude/bin` install, as does
/// opening two tabs at once. `|| echo` would then hide a half-written install
/// behind a friendly message and the very next line (`exec claude`) would run
/// it. `-w 90` gives the entrypoint's `timeout 120` copy room to finish rather
/// than failing the wait, and `-E 0` makes losing the race a success: the
/// other holder just updated, so there is nothing left to do.
pub(crate) const UPDATE_PRELUDE: &str = concat!(
"flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock ",
r#"timeout 60 claude update 2>&1 || echo "(update skipped — continuing)""#,
);
/// Single-quote one argument for interpolation into a shell script string.
fn shell_quote_arg(arg: &str) -> String {
format!(" '{}'", arg.replace('\'', "'\\''"))
}
/// The testable core of [`build_terminal_cmd`], taking the resolved global AWS
/// profile rather than the whole [`AppState`].
fn build_claude_terminal_cmd(
project: &Project,
global_aws_profile: Option<&str>,
session_name: Option<&str>,
) -> Vec<String> {
let is_bedrock_profile = project.backend == Backend::Bedrock let is_bedrock_profile = project.backend == Backend::Bedrock
&& project && project
.bedrock_config .bedrock_config
@@ -19,36 +67,27 @@ fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<
let permission_args = project.effective_permission_mode().cli_args(); let permission_args = project.effective_permission_mode().cli_args();
// The args are interpolated into a shell script string, so single-quote
// each one.
let name_flag = session_name
.filter(|n| !n.is_empty())
.map(|n| format!(" -n{}", shell_quote_arg(n)))
.unwrap_or_default();
let permission_flags: String = permission_args.iter().map(|a| shell_quote_arg(a)).collect();
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
if !is_bedrock_profile { if !is_bedrock_profile {
let mut cmd = vec!["claude".to_string()]; return vec![
cmd.extend(permission_args); "bash".to_string(),
if let Some(name) = session_name { "-c".to_string(),
if !name.is_empty() { format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
cmd.push("-n".to_string()); ];
cmd.push(name.to_string());
}
}
return cmd;
} }
let profile = aws_commands::resolve_profile_for_project( let profile = aws_commands::resolve_profile_for_project(project, global_aws_profile);
project,
state.settings_store.get().global_aws.aws_profile.as_deref(),
);
// Build a bash wrapper that validates credentials, re-auths if needed, // Build a bash wrapper that validates credentials, re-auths if needed,
// then exec's into claude. // then exec's into claude.
let name_flag = session_name
.filter(|n| !n.is_empty())
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
.unwrap_or_default();
// The args are interpolated into a shell script string, so single-quote
// each one (same escaping style as name_flag above).
let permission_flags: String = permission_args
.iter()
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
.collect();
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
let script = format!( let script = format!(
r#" r#"
@@ -75,9 +114,11 @@ else
echo "" echo ""
fi fi
fi fi
{update_prelude}
{claude_cmd} {claude_cmd}
"#, "#,
profile = profile, profile = profile,
update_prelude = UPDATE_PRELUDE,
claude_cmd = claude_cmd claude_cmd = claude_cmd
); );
@@ -325,6 +366,9 @@ pub async fn stop_audio_bridge(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{build_claude_terminal_cmd, UPDATE_PRELUDE};
use crate::models::Project;
/// A dropped file must be named the way the *user* named it. /// A dropped file must be named the way the *user* named it.
/// ///
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry /// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
@@ -338,6 +382,122 @@ mod tests {
/// answer comes from the spelling, and a path that does not name a file is /// answer comes from the spelling, and a path that does not name a file is
/// refused rather than silently substituted (it used to fall back to /// refused rather than silently substituted (it used to fall back to
/// `"dropped-file"`). /// `"dropped-file"`).
/// A `Project` with only the fields these tests care about set; the rest
/// come through serde so the test does not have to track every field.
fn project(backend: &str, bedrock_config: serde_json::Value) -> Project {
serde_json::from_value(serde_json::json!({
"id": "p1",
"name": "Test",
"paths": [],
"container_id": null,
"status": "running",
"backend": backend,
"bedrock_config": bedrock_config,
"ollama_config": null,
"openai_compatible_config": null,
"allow_docker_access": false,
"full_permissions": false,
"ssh_key_path": null,
"git_user_name": null,
"git_user_email": null,
"created_at": "now",
"updated_at": "now"
}))
.expect("test project deserializes")
}
/// Every Claude session updates the CLI before launching it.
///
/// `container/entrypoint.sh` only updates at container *start*, and these
/// containers are long-lived, so a stale CLI is the normal case without
/// this. The plain (non-Bedrock) path therefore has to be a `bash -c`
/// wrapper rather than a bare `claude` argv.
#[test]
fn build_terminal_cmd_updates_before_launching_claude() {
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None);
assert_eq!(cmd[0], "bash");
assert_eq!(cmd[1], "-c");
assert!(
cmd[2].contains(UPDATE_PRELUDE),
"plain path must run the update prelude: {}",
cmd[2]
);
assert!(cmd[2].contains("exec claude"), "got: {}", cmd[2]);
// The update has to happen *before* the exec, which never returns.
assert!(
cmd[2].find(UPDATE_PRELUDE).unwrap() < cmd[2].find("exec claude").unwrap(),
"prelude must precede the exec: {}",
cmd[2]
);
assert!(
UPDATE_PRELUDE.contains("timeout 60") && UPDATE_PRELUDE.contains("||"),
"the update must stay time-bounded and non-fatal"
);
}
/// The session name is interpolated into a shell script, so a quote in it
/// must not break out of its single-quoted argument.
#[test]
fn build_terminal_cmd_escapes_a_quoted_session_name() {
let cmd = build_claude_terminal_cmd(
&project("anthropic", serde_json::Value::Null),
None,
Some("Bob's tab; rm -rf /"),
);
assert!(
cmd[2].contains(r#"exec claude -n 'Bob'\''s tab; rm -rf /'"#),
"session name must be single-quote escaped: {}",
cmd[2]
);
}
/// Permission flags travel the same escaped path, and an empty name adds
/// no `-n` at all.
#[test]
fn build_terminal_cmd_quotes_permission_flags_and_omits_an_empty_name() {
let mut p = project("anthropic", serde_json::Value::Null);
p.full_permissions = true;
let cmd = build_claude_terminal_cmd(&p, None, Some(""));
assert!(
cmd[2].contains("exec claude '--dangerously-skip-permissions'\n"),
"got: {}",
cmd[2]
);
assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]);
}
/// The Bedrock-profile path keeps its AWS validation *and* gains the
/// prelude, immediately before the exec.
#[test]
fn build_terminal_cmd_bedrock_validates_aws_and_updates() {
let cmd = build_claude_terminal_cmd(
&project("bedrock", serde_json::json!({
"auth_method": "profile",
"aws_region": "us-east-1",
"aws_profile": "acme",
"model_id": null,
"disable_prompt_caching": false
})),
None,
Some("it's fine"),
);
assert_eq!(cmd[0], "bash");
let script = &cmd[2];
assert!(script.contains("aws sts get-caller-identity --profile 'acme'"), "got: {}", script);
assert!(script.contains("triple-c-sso-refresh"), "got: {}", script);
assert!(script.contains(UPDATE_PRELUDE), "got: {}", script);
assert!(script.contains(r#"exec claude -n 'it'\''s fine'"#), "got: {}", script);
assert!(
script.find(UPDATE_PRELUDE).unwrap() < script.find("exec claude").unwrap(),
"prelude must precede the exec: {}",
script
);
}
#[test] #[test]
fn a_dropped_file_keeps_the_name_the_user_dropped() { fn a_dropped_file_keeps_the_name_the_user_dropped() {
use crate::commands::file_commands::host_upload_name; use crate::commands::file_commands::host_upload_name;
+174 -2
View File
@@ -1087,6 +1087,89 @@ pub async fn manifest_from_container(container_id: &str) -> Result<Manifest, Str
Ok(parse_manifest(&out)) Ok(parse_manifest(&out))
} }
/// Cached stopped-container manifests, keyed by container id, each paired with
/// the container's `FinishedAt` at the time it was captured.
///
/// **Sound because a stopped container's writable layer cannot change.** Nothing
/// can write to it while it is not running, so a manifest captured after it
/// stopped stays true until it is started again — and `FinishedAt` moves on
/// every stop, which is what makes the key exact rather than merely plausible.
///
/// This exists because `get_container_staleness` is called from a `useEffect`
/// that fires whenever the container settles, so simply opening a stopped
/// project's Overview probes it. Uncached that meant a `docker commit` of the
/// whole writable layer per visit — measured at 44 s on a real project — where
/// before this feature the same visit cost one throwaway container or nothing at
/// all. A regression like that is not worth the answer it buys.
///
/// Capped, because a `Manifest` of a real container is a few MB: this only has
/// to serve "the project whose page is open", so a handful of entries is the
/// whole working set and the oldest is dropped past that.
static STOPPED_MANIFEST_CACHE: std::sync::Mutex<
Option<Vec<(String, String, Manifest)>>,
> = std::sync::Mutex::new(None);
/// How many stopped-container manifests [`STOPPED_MANIFEST_CACHE`] keeps.
const STOPPED_MANIFEST_CACHE_MAX: usize = 4;
/// `FinishedAt` for a container, the cache's validity token. `None` when it
/// cannot be read, which is never treated as a hit.
async fn container_finished_at(container_id: &str) -> Option<String> {
let docker = get_docker().ok()?;
docker
.inspect_container(container_id, None)
.await
.ok()?
.state?
.finished_at
.filter(|s| !s.is_empty())
}
/// Capture a [`Manifest`] from a **stopped** container, reusing a cached one
/// when the container has not been started since it was taken.
///
/// See [`STOPPED_MANIFEST_CACHE`] for why this is exact and why it is needed.
pub async fn manifest_from_stopped_container_cached(
container_id: &str,
) -> Result<Manifest, String> {
let finished_at = container_finished_at(container_id).await;
if let Some(token) = &finished_at {
let guard = STOPPED_MANIFEST_CACHE.lock();
if let Ok(cache) = guard {
if let Some(entries) = cache.as_ref() {
if let Some((_, _, manifest)) = entries
.iter()
.find(|(id, tok, _)| id == container_id && tok == token)
{
log::debug!(
"Reusing the cached manifest for stopped container {}",
container_id
);
return Ok(manifest.clone());
}
}
}
}
let manifest = manifest_from_stopped_container(container_id).await?;
// Only cacheable if the container's state could be read at all; an unknown
// `FinishedAt` means there is no token that could later be compared.
if let Some(token) = finished_at {
if let Ok(mut cache) = STOPPED_MANIFEST_CACHE.lock() {
let entries = cache.get_or_insert_with(Vec::new);
entries.retain(|(id, _, _)| id != container_id);
entries.push((container_id.to_string(), token, manifest.clone()));
while entries.len() > STOPPED_MANIFEST_CACHE_MAX {
entries.remove(0);
}
}
}
Ok(manifest)
}
/// Capture a [`Manifest`] from a **stopped** container. /// Capture a [`Manifest`] from a **stopped** container.
/// ///
/// Commits the container's writable layer to a throwaway image, probes that, /// Commits the container's writable layer to a throwaway image, probes that,
@@ -1107,8 +1190,8 @@ pub async fn manifest_from_stopped_container(container_id: &str) -> Result<Manif
if let Err(e) = super::container::remove_image_by_name(&image).await { if let Err(e) = super::container::remove_image_by_name(&image).await {
log::warn!( log::warn!(
"Could not remove the staleness probe's throwaway image {}: {} — the next probe of \ "Could not remove the staleness probe's throwaway image {}: {} — `reap_probe_images` \
this container reuses the name, which leaves this one dangling for the orphan sweep", collects it at the next app start; the orphan sweep never will, because it is tagged",
image, image,
e e
); );
@@ -2273,6 +2356,95 @@ mod tests {
// ── Live Docker ───────────────────────────────────────────────────────── // ── Live Docker ─────────────────────────────────────────────────────────
/// The cache serves a second read of an unchanged stopped container, and —
/// the half that matters — stops serving it the moment the container is
/// started and stopped again. If invalidation were wrong this would report a
/// filesystem the project no longer has, and a migration would be planned
/// against it.
///
/// ```text
/// cargo test -- --ignored --nocapture stopped_manifest_cache
/// ```
#[cfg(unix)]
#[tokio::test]
#[ignore = "needs a Docker daemon; creates, commits and removes a throwaway container"]
async fn the_stopped_manifest_cache_survives_a_reread_but_not_a_restart() {
fn docker_cli(args: &[&str]) -> String {
let out = std::process::Command::new("docker")
.args(args)
.output()
.expect("docker CLI");
assert!(
out.status.success(),
"docker {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
let image = std::env::var("TRIPLE_C_TEST_IMAGE")
.unwrap_or_else(|_| "ghcr.io/shadowdao/triple-c-sandbox:latest".to_string());
let first = format!("/opt/cache-marker-a-{}", std::process::id());
let second = format!("/opt/cache-marker-b-{}", std::process::id());
let id = docker_cli(&[
"run", "-d", "--label", "triple-c.managed=true",
"--entrypoint", "/bin/sh",
&image, "-c", "sleep 600",
]);
let cleanup = || {
let _ = std::process::Command::new("docker")
.args(["rm", "-f", &id])
.output();
};
docker_cli(&["exec", &id, "mkdir", "-p", &first]);
docker_cli(&["stop", "-t", "1", &id]);
let t0 = std::time::Instant::now();
let cold = manifest_from_stopped_container_cached(&id).await;
let cold_ms = t0.elapsed().as_millis();
let t1 = std::time::Instant::now();
let warm = manifest_from_stopped_container_cached(&id).await;
let warm_ms = t1.elapsed().as_millis();
// Restart, change the filesystem, stop again — `FinishedAt` moves.
docker_cli(&["start", &id]);
docker_cli(&["exec", &id, "mkdir", "-p", &second]);
docker_cli(&["stop", "-t", "1", &id]);
let after_restart = manifest_from_stopped_container_cached(&id).await;
cleanup();
let has = |m: &Manifest, p: &str| m.paths.iter().any(|e| e.path == p && e.is_dir());
let cold = cold.expect("cold read");
let warm = warm.expect("warm read");
let after_restart = after_restart.expect("read after restart");
assert!(has(&cold, &first), "cold read missed {}", first);
assert!(has(&warm, &first), "warm read missed {}", first);
println!("cold {} ms, warm {} ms", cold_ms, warm_ms);
assert!(
warm_ms * 5 < cold_ms.max(5),
"the second read cost {} ms against a cold {} ms — it re-committed \
instead of using the cache",
warm_ms,
cold_ms
);
// The restart must have invalidated it: the new directory has to show up.
assert!(
has(&after_restart, &second),
"a restart did not invalidate the cache — {} is missing, so this is \
a stale manifest of a filesystem the container no longer has",
second
);
assert!(has(&after_restart, &first), "the restart lost {}", first);
}
/// The reaper finds a leftover probe image by prefix and — crucially — /// The reaper finds a leftover probe image by prefix and — crucially —
/// refuses to remove a young one, because that image may be another /// refuses to remove a young one, because that image may be another
/// Triple-C instance's live probe. Only a real daemon can say whether the /// Triple-C instance's live probe. Only a real daemon can say whether the
+20 -11
View File
@@ -206,6 +206,11 @@ pub async fn handle_connection(socket: WebSocket, state: Arc<WebTerminalState>)
writer_handle.abort(); writer_handle.abort();
} }
/// The desktop terminal's update prelude, reused verbatim. Shared rather than
/// copied so the web terminal cannot drift from it — a duplicated `const` with
/// a "keep these identical" comment is only as good as the next reader.
use crate::commands::terminal_commands::UPDATE_PRELUDE;
/// Build the command for a terminal session, mirroring terminal_commands.rs logic. /// Build the command for a terminal session, mirroring terminal_commands.rs logic.
fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec<String> { fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec<String> {
let is_bedrock_profile = project.backend == Backend::Bedrock let is_bedrock_profile = project.backend == Backend::Bedrock
@@ -217,17 +222,6 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
let permission_args = project.effective_permission_mode().cli_args(); let permission_args = project.effective_permission_mode().cli_args();
if !is_bedrock_profile {
let mut cmd = vec!["claude".to_string()];
cmd.extend(permission_args);
return cmd;
}
let profile = aws_commands::resolve_profile_for_project(
project,
settings_store.get().global_aws.aws_profile.as_deref(),
);
// The args are interpolated into a shell script string below, so // The args are interpolated into a shell script string below, so
// single-quote each one. // single-quote each one.
let permission_flags: String = permission_args let permission_flags: String = permission_args
@@ -236,6 +230,19 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
.collect(); .collect();
let claude_cmd = format!("exec claude{}", permission_flags); let claude_cmd = format!("exec claude{}", permission_flags);
if !is_bedrock_profile {
return vec![
"bash".to_string(),
"-c".to_string(),
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
];
}
let profile = aws_commands::resolve_profile_for_project(
project,
settings_store.get().global_aws.aws_profile.as_deref(),
);
let script = format!( let script = format!(
r#" r#"
echo "Validating AWS session for profile '{profile}'..." echo "Validating AWS session for profile '{profile}'..."
@@ -260,9 +267,11 @@ else
echo "" echo ""
fi fi
fi fi
{update_prelude}
{claude_cmd} {claude_cmd}
"#, "#,
profile = profile, profile = profile,
update_prelude = UPDATE_PRELUDE,
claude_cmd = claude_cmd claude_cmd = claude_cmd
); );
+9 -8
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, notesDockOpen, toggleNotesDock, notesDockOpen, toggleNotesDock, terminalMouseCaptured, releaseActiveMouse,
} = useAppState( } = useAppState(
useShallow(s => ({ useShallow(s => ({
projects: s.projects, projects: s.projects,
@@ -18,10 +18,10 @@ export default function StatusBar({ stt }: Props) {
terminalHasSelection: s.terminalHasSelection, terminalHasSelection: s.terminalHasSelection,
activeSessionId: s.activeSessionId, activeSessionId: s.activeSessionId,
sttEnabled: s.appSettings?.stt?.enabled, sttEnabled: s.appSettings?.stt?.enabled,
terminalAtBottom: s.terminalAtBottom,
scrollActiveToBottom: s.scrollActiveToBottom,
notesDockOpen: s.notesDockOpen, notesDockOpen: s.notesDockOpen,
toggleNotesDock: s.toggleNotesDock, toggleNotesDock: s.toggleNotesDock,
terminalMouseCaptured: s.terminalMouseCaptured,
releaseActiveMouse: s.releaseActiveMouse,
})) }))
); );
const running = projects.filter((p) => p.status === "running").length; const running = projects.filter((p) => p.status === "running").length;
@@ -60,15 +60,16 @@ export default function StatusBar({ stt }: Props) {
</span> </span>
</> </>
)} )}
{/* Right-aligned controls: Jump to Current + STT mic */} {/* Right-aligned controls: mouse release + Notes + STT mic */}
<div className="ml-auto flex items-center gap-3 pl-2"> <div className="ml-auto flex items-center gap-3 pl-2">
{activeSessionId && !terminalAtBottom && ( {activeSessionId && terminalMouseCaptured && (
<button <button
onClick={() => scrollActiveToBottom()} data-mouse-release="true"
onClick={() => releaseActiveMouse()}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer" className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
title="Scroll the terminal to the latest output" title="A program in the container is reading the mouse, so clicks and drags go to it instead of selecting text. Click, or press Ctrl+Shift+X, to take it back. To select text without taking it back, hold Shift while dragging (Option on macOS)."
> >
Jump to Current 🖱 Mouse captured release
</button> </button>
)} )}
<button <button
@@ -3,6 +3,7 @@ import { render, fireEvent, cleanup, act } from "@testing-library/react";
import TerminalView, { supersedes } from "./TerminalView"; import TerminalView, { supersedes } from "./TerminalView";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import { uploadHostFileToTerminal } from "../../lib/tauri-commands"; import { uploadHostFileToTerminal } from "../../lib/tauri-commands";
import { URL_TOAST_SELECTOR } from "./UrlToast";
/** /**
* The window-wide native drag-drop listener, captured at registration. * The window-wide native drag-drop listener, captured at registration.
@@ -370,15 +371,34 @@ describe("TerminalView — where a dropped file lands", () => {
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1); expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1);
}); });
it("uploads a file dropped onto the always-present Following toggle", async () => { it("uploads a file dropped onto the chrome painted over the terminal", async () => {
// The regression this file could not see. The toggle is `absolute top-2 // The regression this file could not see. Chrome like the URL toast is a
// right-4 z-50` and is rendered unconditionally, so `elementFromPoint` // *sibling* of the xterm host painted over the pane, so
// returns *it* for the terminal's top-right corner — and a gate asking // `elementFromPoint` returns it rather than the host — and a gate asking
// "is what is painted here inside the xterm host?" answered no, forever, // "is what is painted here inside the xterm host?" answered no, forever,
// with no message and no log line. jsdom never ran that branch. // with no message and no log line. jsdom never ran that branch.
const view = await mountWithLayout(); //
const toggle = view.getByTitle(/Auto-scroll/i); // The original fixture was the always-rendered "▼ Following" toggle. That
stubElementFromPoint(toggle); // control is retired and the mouse-release button that could have replaced
// it lives in the status bar now, so the toast is what stands in — it is
// real chrome over the pane, which is the only property under test.
await mountWithLayout();
const emit = ptyOutput.listeners.get("terminal-output-s1");
if (!emit) throw new Error("no terminal-output listener registered");
await act(async () => {
emit({
payload: Array.from(
new TextEncoder().encode(
`\x1b]7777;open;${btoa("https://example.com/x")}\x07`,
),
),
});
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
const toast = document.querySelector(URL_TOAST_SELECTOR);
if (!toast) throw new Error("URL toast not shown");
stubElementFromPoint(toast);
await drop(780, 10); await drop(780, 10);
@@ -594,3 +614,88 @@ describe("TerminalView — focus on request", () => {
expect(document.activeElement).toBe(helperTextarea(view.container)); expect(document.activeElement).toBe(helperTextarea(view.container));
}); });
}); });
describe("TerminalView — releasing a captured mouse", () => {
/** Feed raw bytes to the terminal as if the container had printed them, and
* let xterm drain its write queue (it parses asynchronously). */
async function emitBytes(text: string) {
const emit = ptyOutput.listeners.get("terminal-output-s1");
if (!emit) throw new Error("no terminal-output listener registered");
await act(async () => {
emit({ payload: Array.from(new TextEncoder().encode(text)) });
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
/** What the status bar would render from: the active terminal publishes the
* capture state, and the release action, into the store. The control itself
* lives in `StatusBar` deliberately, so it never sits on top of the TUI
* that is asking for the mouse. */
function captured(): boolean {
return useAppState.getState().terminalMouseCaptured;
}
it("shows nothing while the container has not grabbed the mouse", async () => {
mountSession("claude");
await act(async () => {});
expect(captured()).toBe(false);
});
it("surfaces a release control once the container turns mouse tracking on", async () => {
// `?1003h` is any-event tracking: every mouse *move* over the terminal is
// reported to the app. When the TUI that asked for it dies without
// resetting the mode, xterm keeps routing moves to the PTY and drops text
// selection — the freeze this control exists to break out of.
mountSession("claude");
await act(async () => {});
await emitBytes("\x1b[?1003h\x1b[?1006h");
expect(captured()).toBe(true);
});
it("clears the mode locally, without sending a byte to the container", async () => {
// The reset is written into xterm's own parser, not onto the wire. The
// program inside is usually gone; if it is not, it must not be told the
// user pulled the mouse back, or a live TUI would just re-grab it.
mountSession("claude");
await act(async () => {});
await emitBytes("\x1b[?1003h");
terminalInput.mockClear();
// Exactly what the status-bar button's onClick does.
const release = useAppState.getState().releaseActiveMouse;
await act(async () => {
release();
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
// The published flag is bound to the live mode, so it going false *is* the
// assertion that xterm's mouse tracking is back to "none".
expect(captured()).toBe(false);
expect(terminalInput).not.toHaveBeenCalled();
});
it("releases on Ctrl+Shift+X, for when the pointer itself is unusable", async () => {
const { container } = mountSession("claude");
await act(async () => {});
await emitBytes("\x1b[?1002h");
terminalInput.mockClear();
await act(async () => {
fireEvent.keyDown(helperTextarea(container), {
key: "X",
ctrlKey: true,
shiftKey: true,
});
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
expect(captured()).toBe(false);
// The chord must not also reach the container as input.
expect(terminalInput).not.toHaveBeenCalled();
});
});
+105 -125
View File
@@ -99,8 +99,8 @@ export default function TerminalView({ sessionId, active }: Props) {
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 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 setTerminalMouseCaptured = useAppState(s => s.setTerminalMouseCaptured);
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom); const setReleaseActiveMouse = useAppState(s => s.setReleaseActiveMouse);
const ssoBufferRef = useRef(""); const ssoBufferRef = useRef("");
const ssoTriggeredRef = useRef(false); const ssoTriggeredRef = useRef(false);
@@ -219,14 +219,11 @@ export default function TerminalView({ sessionId, active }: Props) {
return () => document.removeEventListener("keydown", onKeyDown, true); return () => document.removeEventListener("keydown", onKeyDown, true);
}, []); }, []);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null); const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [isAutoFollow, setIsAutoFollow] = useState(true);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const isAtBottomRef = useRef(true); // True while the program in the container holds mouse reporting open (any of
// Tracks user intent to follow output — only set to false by explicit user // the DECSET ?1000/?1002/?1003 tracking modes). See `syncMouseCapture`.
// actions (mouse wheel up), not by xterm scroll events during writes. const [mouseCaptured, setMouseCaptured] = useState(false);
const autoFollowRef = useRef(true); const mouseCapturedRef = useRef(false);
const lastUserScrollTimeRef = useRef(0);
// Keep latest `active` readable inside long-lived listeners (drag-drop below, // Keep latest `active` readable inside long-lived listeners (drag-drop below,
// and the unmount-cleanup effect further down). // and the unmount-cleanup effect further down).
@@ -251,10 +248,10 @@ export default function TerminalView({ sessionId, active }: Props) {
// //
// The rect asked about is the **pane wrapper**, not the xterm host inside it: // The rect asked about is the **pane wrapper**, not the xterm host inside it:
// the pane is what the user sees as "the terminal", gutter included, and the // the pane is what the user sees as "the terminal", gutter included, and the
// chrome painted over it (the Following toggle, the URL toast) is a sibling // chrome painted over it (the mouse-release badge, the URL toast) is a
// of the host rather than a child. Nothing painted over the pane refuses a // sibling of the host rather than a child. Nothing painted over the pane
// drop on its own account — asking "is this element mine?" once turned every // refuses a drop on its own account — asking "is this element mine?" once
// pixel under that chrome into a permanent dead zone. // turned every pixel under that chrome into a permanent dead zone.
useEffect(() => { useEffect(() => {
let unlisten: (() => void) | undefined; let unlisten: (() => void) | undefined;
let cancelled = false; let cancelled = false;
@@ -315,12 +312,60 @@ export default function TerminalView({ sessionId, active }: Props) {
}; };
}, [sessionId, sendInput]); }, [sessionId, sendInput]);
/**
* Reconcile the badge with xterm's live mouse-tracking mode.
*
* There is no event for this, but there does not need to be a poll either:
* the mode only ever changes because the container printed a DECSET/DECRST
* sequence, so checking once per write covers every transition, exactly when
* it happens. The ref gate keeps the common case (mode unchanged, thousands
* of writes a second) down to one string comparison and no re-render.
*/
const syncMouseCapture = useCallback(() => {
const term = termRef.current;
if (!term) return;
const captured = term.modes.mouseTrackingMode !== "none";
if (captured === mouseCapturedRef.current) return;
mouseCapturedRef.current = captured;
setMouseCaptured(captured);
}, []);
/**
* Take the mouse back from a program that grabbed it and never let go.
*
* A TUI that dies mid-menu (or is killed, or detaches) leaves its mouse
* tracking modes set. xterm goes on routing clicks, drags and under
* `?1003` every pointer *move* to the PTY, which kills text selection and
* floods the prompt with escape bytes. The result reads as a frozen
* terminal, and until now the only exit was closing the tab.
*
* The reset is `term.write`, deliberately, not `sendInput`: it goes into
* xterm's own parser and never onto the wire. The program that asked for
* tracking is usually already gone; if it is not, telling it the user pulled
* the mouse back would only invite it to grab again on its next repaint.
*/
const releaseMouse = useCallback(() => {
const term = termRef.current;
if (!term) return;
// The three tracking modes, then the two encodings they report in. All
// five, because a program is free to have set any combination and a
// leftover encoding mode outlives the tracking mode that motivated it.
term.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l", syncMouseCapture);
}, [syncMouseCapture]);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
const term = new Terminal({ const term = new Terminal({
cursorBlink: true, cursorBlink: true,
fontSize: 14, fontSize: 14,
// Let the user select text even while a program holds the mouse.
// xterm's force-selection modifier is Shift everywhere *except* macOS,
// where it is Option and is gated behind this option, which defaults to
// false — so without this line Mac users have no force-select at all and
// the only way to copy from a mouse-driven TUI is to take the mouse back
// first. `SelectionService.shouldForceSelection`.
macOptionClickForcesSelection: true,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace", fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
theme: { theme: {
background: "#0d1117", background: "#0d1117",
@@ -391,6 +436,14 @@ export default function TerminalView({ sessionId, active }: Props) {
useAppState.getState().sttToggle(); useAppState.getState().sttToggle();
return false; return false;
} }
// Ctrl+Shift+X hands the mouse back. Same action as the badge, bound to
// a key because the failure this recovers from is *the pointer not
// working* — a control you have to click can be unreachable in exactly
// the situation that calls for it.
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "X") {
releaseMouse();
return false;
}
// Shift+Enter inserts a newline in Claude Code's prompt instead of // Shift+Enter inserts a newline in Claude Code's prompt instead of
// submitting it. xterm.js does not consult `shiftKey` for Enter // submitting it. xterm.js does not consult `shiftKey` for Enter
// (`Keyboard.ts`, `case 13`), so without this branch Shift+Enter is // (`Keyboard.ts`, `case 13`), so without this branch Shift+Enter is
@@ -501,42 +554,6 @@ export default function TerminalView({ sessionId, active }: Props) {
); );
}); });
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
// Captured during capture phase so it fires before xterm's own handler.
const handleWheel = (e: WheelEvent) => {
lastUserScrollTimeRef.current = Date.now();
if (e.deltaY < 0) {
autoFollowRef.current = false;
setIsAutoFollow(false);
isAtBottomRef.current = false;
setIsAtBottom(false);
}
};
containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true });
// Track scroll position to show "Jump to Current" button.
// Debounce state updates via rAF to avoid excessive re-renders during rapid output.
let scrollStateRafId: number | null = null;
const scrollDisposable = term.onScroll(() => {
const buf = term.buffer.active;
const atBottom = buf.viewportY >= buf.baseY;
isAtBottomRef.current = atBottom;
// Re-enable auto-follow only when USER scrolls to bottom (not write-triggered)
const isUserScroll = (Date.now() - lastUserScrollTimeRef.current) < 300;
if (atBottom && isUserScroll && !autoFollowRef.current) {
autoFollowRef.current = true;
setIsAutoFollow(true);
}
if (scrollStateRafId === null) {
scrollStateRafId = requestAnimationFrame(() => {
scrollStateRafId = null;
setIsAtBottom(isAtBottomRef.current);
});
}
});
// Track text selection to show copy hint in status bar // Track text selection to show copy hint in status bar
const selectionDisposable = term.onSelectionChange(() => { const selectionDisposable = term.onSelectionChange(() => {
setTerminalHasSelection(term.hasSelection()); setTerminalHasSelection(term.hasSelection());
@@ -599,15 +616,11 @@ export default function TerminalView({ sessionId, active }: Props) {
const outputPromise = onOutput(sessionId, (data) => { const outputPromise = onOutput(sessionId, (data) => {
if (aborted) return; if (aborted) return;
term.write(data, () => { // Scrolling on new output is xterm's own job, and it already gets it
if (autoFollowRef.current) { // right: it follows the tail while the viewport is at the bottom and
term.scrollToBottom(); // holds position while you are reading further up. The manual
if (!isAtBottomRef.current) { // `scrollToBottom()` that used to live here fought that second half.
isAtBottomRef.current = true; term.write(data, syncMouseCapture);
setIsAtBottom(true);
}
}
});
detector.feed(data); detector.feed(data);
// Scan for SSO refresh marker in terminal output // Scan for SSO refresh marker in terminal output
@@ -649,11 +662,18 @@ export default function TerminalView({ sessionId, active }: Props) {
resizeRafId = requestAnimationFrame(() => { resizeRafId = requestAnimationFrame(() => {
resizeRafId = null; resizeRafId = null;
if (!containerRef.current || containerRef.current.offsetWidth === 0) return; if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
// Whether the viewport was following the tail has to be sampled
// *before* the fit: reflowing wrapped lines moves `baseY`, so asking
// afterwards cannot tell "was at the bottom" from "was pushed off it".
const wasAtBottom =
term.buffer.active.viewportY >= term.buffer.active.baseY;
fitAddon.fit(); fitAddon.fit();
resize(sessionId, term.cols, term.rows); resize(sessionId, term.cols, term.rows);
if (autoFollowRef.current) { // Only re-anchor a viewport that was already on the tail. This
term.scrollToBottom(); // observer fires for any pane size change — opening the Notes dock,
} // dragging the sidebar, resizing the window — and none of those are a
// reason to yank someone away from the scrollback they are reading.
if (wasAtBottom) term.scrollToBottom();
}); });
}); });
resizeObserver.observe(containerRef.current); resizeObserver.observe(containerRef.current);
@@ -667,14 +687,11 @@ export default function TerminalView({ sessionId, active }: Props) {
osc52Disposable.dispose(); osc52Disposable.dispose();
relayDisposable.dispose(); relayDisposable.dispose();
inputDisposable.dispose(); inputDisposable.dispose();
scrollDisposable.dispose();
selectionDisposable.dispose(); selectionDisposable.dispose();
setTerminalHasSelection(false); setTerminalHasSelection(false);
containerRef.current?.removeEventListener("wheel", handleWheel, { capture: true });
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true }); containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
outputPromise.then((fn) => fn?.()); outputPromise.then((fn) => fn?.());
exitPromise.then((fn) => fn?.()); exitPromise.then((fn) => fn?.());
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId); if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
resizeObserver.disconnect(); resizeObserver.disconnect();
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ } try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
@@ -723,10 +740,12 @@ export default function TerminalView({ sessionId, active }: Props) {
} }
if (active) { if (active) {
// Same rule as the resize observer: re-anchor only what was already
// anchored, so a tab left scrolled up comes back where it was left.
const wasAtBottom =
term.buffer.active.viewportY >= term.buffer.active.baseY;
fitRef.current?.fit(); fitRef.current?.fit();
if (autoFollowRef.current) { if (wasAtBottom) term.scrollToBottom();
term.scrollToBottom();
}
term.focus(); term.focus();
} }
}, [active, gpuRenderingSetting]); }, [active, gpuRenderingSetting]);
@@ -826,39 +845,6 @@ export default function TerminalView({ sessionId, active }: Props) {
); );
}, [urlPrompt, projectId, dismissUrlPrompt]); }, [urlPrompt, projectId, dismissUrlPrompt]);
const handleScrollToBottom = useCallback(() => {
const term = termRef.current;
if (term) {
autoFollowRef.current = true;
setIsAutoFollow(true);
fitRef.current?.fit();
term.scrollToBottom();
isAtBottomRef.current = true;
setIsAtBottom(true);
}
}, []);
// Surface this terminal's scroll state to the status bar's "Jump to Current"
// control, but only while it's the active (visible) terminal.
useEffect(() => {
if (!active) return;
setTerminalAtBottom(isAtBottom);
setScrollActiveToBottom(handleScrollToBottom);
}, [active, isAtBottom, handleScrollToBottom, setTerminalAtBottom, setScrollActiveToBottom]);
// On unmount, if this was the active terminal, clear the status-bar scroll
// state so it doesn't point at a disposed terminal. (Tab switches don't
// unmount — the deactivating terminal stays mounted but hidden — so this
// only fires when the active session is actually closed.)
useEffect(() => {
return () => {
if (activeRef.current) {
setTerminalAtBottom(true);
setScrollActiveToBottom(() => {});
}
};
}, [setTerminalAtBottom, setScrollActiveToBottom]);
const writeSelection = useCallback((mode: "trimmed" | "raw") => { const writeSelection = useCallback((mode: "trimmed" | "raw") => {
const term = termRef.current; const term = termRef.current;
if (!term) return; if (!term) return;
@@ -876,20 +862,26 @@ export default function TerminalView({ sessionId, active }: Props) {
setContextMenu({ x: e.clientX, y: e.clientY }); setContextMenu({ x: e.clientX, y: e.clientY });
}, []); }, []);
const handleToggleAutoFollow = useCallback(() => { // Surface the capture state and its escape hatch to the status bar, but only
const next = !autoFollowRef.current; // while this is the visible terminal.
autoFollowRef.current = next; useEffect(() => {
setIsAutoFollow(next); if (!active) return;
if (next) { setTerminalMouseCaptured(mouseCaptured);
const term = termRef.current; setReleaseActiveMouse(releaseMouse);
if (term) { }, [active, mouseCaptured, releaseMouse, setTerminalMouseCaptured, setReleaseActiveMouse]);
fitRef.current?.fit();
term.scrollToBottom(); // On unmount, if this was the active terminal, clear the status-bar state so
isAtBottomRef.current = true; // it does not point at a disposed terminal. (Tab switches do not unmount —
setIsAtBottom(true); // the deactivating terminal stays mounted but hidden — so this only fires
// when the active session is actually closed.)
useEffect(() => {
return () => {
if (activeRef.current) {
setTerminalMouseCaptured(false);
setReleaseActiveMouse(() => {});
} }
} };
}, []); }, [setTerminalMouseCaptured, setReleaseActiveMouse]);
return ( return (
<div <div
@@ -915,18 +907,6 @@ export default function TerminalView({ sessionId, active }: Props) {
{imagePasteMsg} {imagePasteMsg}
</div> </div>
)} )}
{/* Auto-follow toggle - top right */}
<button
onClick={handleToggleAutoFollow}
className={`absolute top-2 right-4 z-50 px-2 py-1 rounded text-[10px] font-medium border shadow-sm transition-colors cursor-pointer ${
isAutoFollow
? "bg-[#1a2332] text-[#3fb950] border-[#238636] hover:bg-[#1f2d3d]"
: "bg-[#1f2937] text-[#8b949e] border-[#30363d] hover:bg-[#2d3748]"
}`}
title={isAutoFollow ? "Auto-scrolling to latest output (click to pause)" : "Auto-scroll paused (click to resume)"}
>
{isAutoFollow ? "▼ Following" : "▽ Paused"}
</button>
{/* Padding lives on this wrapper, NOT on the xterm host element. xterm's {/* Padding lives on this wrapper, NOT on the xterm host element. xterm's
FitAddon measures the host element it's mounted into; padding there FitAddon measures the host element it's mounted into; padding there
causes the grid to overhang and clip the rightmost column / bottom causes the grid to overhang and clip the rightmost column / bottom
+6 -5
View File
@@ -243,11 +243,12 @@ describe("dropTarget", () => {
describe("chrome over a pane, with no dialog open", () => { describe("chrome over a pane, with no dialog open", () => {
/** Everything that is painted over a pane and is not a blocker. */ /** Everything that is painted over a pane and is not a blocker. */
const CHROME: Array<[string, () => HTMLElement]> = [ const CHROME: Array<[string, () => HTMLElement]> = [
// `TerminalView`'s "▼ Following / ▽ Paused" toggle: `absolute top-2 // `TerminalView`'s mouse-release badge: `absolute top-2 right-4 z-50`,
// right-4 z-50`, rendered unconditionally, and a *sibling* of the xterm // and a *sibling* of the xterm host — so "does the pane contain what is
// host — so "does the pane contain what is painted here?" made the // painted here?" made the terminal's top-right corner a dead zone no
// terminal's top-right corner a dead zone no user action could clear. // user action could clear. (The retired Following toggle held the same
["the Following/Paused toggle", () => document.createElement("button")], // corner and produced the original bug.)
["the mouse-release badge", () => document.createElement("button")],
// `ToastHost`: `fixed bottom-4 right-4 z-[60]`, 24rem wide, over every // `ToastHost`: `fixed bottom-4 right-4 z-[60]`, 24rem wide, over every
// pane, and its error cards stay until dismissed. // pane, and its error cards stay until dismissed.
["a toast card", () => document.createElement("div")], ["a toast card", () => document.createElement("div")],
+2 -2
View File
@@ -26,8 +26,8 @@
* *
* - Asking `el.contains(document.elementFromPoint(x, y))` "is the thing * - Asking `el.contains(document.elementFromPoint(x, y))` "is the thing
* painted here mine?" refused drops onto anything painted *over* a pane * painted here mine?" refused drops onto anything painted *over* a pane
* that is not part of it: `TerminalView`'s always-rendered "▼ Following" * that is not part of it: `TerminalView`'s mouse-release badge (a sibling
* toggle (a sibling of the xterm host), the URL toast, `ToastHost`'s stack. * of the xterm host), the URL toast, `ToastHost`'s stack.
* Permanent dead zones no user action could clear. * Permanent dead zones no user action could clear.
* - Replacing that with "is a *blocking overlay* painted here?" removed the * - Replacing that with "is a *blocking overlay* painted here?" removed the
* dead zones and opened a hole instead. `elementFromPoint` returns the * dead zones and opened a hole instead. `elementFromPoint` returns the
+15 -3
View File
@@ -350,8 +350,8 @@ export const sweepClaudeTokenSnapshots = () =>
// without deleting its volumes. Reset is the destructive alternative: it wipes // without deleting its volumes. Reset is the destructive alternative: it wipes
// ~/.claude, the OAuth credential, installed skills and every transcript. // ~/.claude, the OAuth credential, installed skills and every transcript.
// //
// Flow: getContainerStaleness (read-only, ~6s — two filesystem probes, so call // Flow: getContainerStaleness (~6s — two filesystem probes, so call it on demand
// it on demand rather than polling) → migrateProjectToBase → the project sits // rather than polling) → migrateProjectToBase → the project sits
// in "awaiting-confirmation" while the user tries it → confirmMigration or // in "awaiting-confirmation" while the user tries it → confirmMigration or
// rollbackMigration. // rollbackMigration.
// //
@@ -361,7 +361,19 @@ export const sweepClaudeTokenSnapshots = () =>
// //
// Progress arrives on the existing `container-progress` event. // Progress arrives on the existing `container-progress` event.
/** Read-only. Runs two container/image filesystem probes; not for polling. */ /**
* Runs two container/image filesystem probes; not for polling.
*
* **Not read-only, despite only reporting.** When the container is *stopped*
* the backend has to commit its writable layer to a throwaway image before it
* can read anything `docker exec` needs a running container so this writes
* (and then removes) an image. The result is cached per stop, so repeat calls
* while the container stays stopped are cheap, but the first one after each stop
* pays for a commit of the whole layer: seconds on a small project, tens of
* seconds on a large one. Do not add a caller that fires more often than "the
* container settled into a new state" without re-reading
* `get_container_staleness`'s doc comment first.
*/
export const getContainerStaleness = (projectId: string) => export const getContainerStaleness = (projectId: string) =>
invoke<ContainerStaleness>("get_container_staleness", { projectId }); invoke<ContainerStaleness>("get_container_staleness", { projectId });
+14 -10
View File
@@ -205,16 +205,20 @@ interface AppState {
// UI state // UI state
terminalHasSelection: boolean; terminalHasSelection: boolean;
setTerminalHasSelection: (has: boolean) => void; setTerminalHasSelection: (has: boolean) => void;
// Whether a program in the active terminal is holding mouse reporting open,
// and how to take it back. Surfaced so the release control can live in the
// status bar: painted over the terminal it would sit on top of whatever TUI
// is asking for the mouse, and swallow clicks aimed at that program's own
// top-right corner for as long as it ran. Only the active TerminalView
// writes these.
terminalMouseCaptured: boolean;
setTerminalMouseCaptured: (captured: boolean) => void;
releaseActiveMouse: () => void;
setReleaseActiveMouse: (fn: () => void) => void;
// STT toggle for the active session, registered by App so the terminal's // STT toggle for the active session, registered by App so the terminal's
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance. // Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
sttToggle: () => void; sttToggle: () => void;
setSttToggle: (fn: () => void) => void; setSttToggle: (fn: () => void) => void;
// Active terminal scroll state, surfaced so the status bar can host the
// "Jump to Current" control. Only the active TerminalView writes these.
terminalAtBottom: boolean;
setTerminalAtBottom: (v: boolean) => void;
scrollActiveToBottom: () => void;
setScrollActiveToBottom: (fn: () => void) => void;
sidebarView: "projects" | "settings"; sidebarView: "projects" | "settings";
setSidebarView: (view: "projects" | "settings") => void; setSidebarView: (view: "projects" | "settings") => void;
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
@@ -496,12 +500,12 @@ export const useAppState = create<AppState>((set) => ({
// UI state // UI state
terminalHasSelection: false, terminalHasSelection: false,
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }), setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
terminalMouseCaptured: false,
setTerminalMouseCaptured: (captured) => set({ terminalMouseCaptured: captured }),
releaseActiveMouse: () => {},
setReleaseActiveMouse: (fn) => set({ releaseActiveMouse: fn }),
sttToggle: () => {}, sttToggle: () => {},
setSttToggle: (fn) => set({ sttToggle: fn }), setSttToggle: (fn) => set({ sttToggle: fn }),
terminalAtBottom: true,
setTerminalAtBottom: (v) => set({ terminalAtBottom: v }),
scrollActiveToBottom: () => {},
setScrollActiveToBottom: (fn) => set({ scrollActiveToBottom: fn }),
sidebarView: "projects", sidebarView: "projects",
setSidebarView: (view) => set({ sidebarView: view }), setSidebarView: (view) => set({ sidebarView: view }),
sidebarCollapsed: loadSidebarCollapsed(), sidebarCollapsed: loadSidebarCollapsed(),
+7 -1
View File
@@ -639,8 +639,14 @@ fi
# any terminal session launches `claude`. Runs as the claude user (the CLI is # any terminal session launches `claude`. Runs as the claude user (the CLI is
# installed under /home/claude/.claude/bin). Non-fatal and time-bounded so a # installed under /home/claude/.claude/bin). Non-fatal and time-bounded so a
# slow or offline network never blocks container readiness. # slow or offline network never blocks container readiness.
# The lock is shared with the per-session update that every Claude terminal
# runs before `exec claude` (commands/terminal_commands.rs, UPDATE_PRELUDE).
# "Container ready" is printed *after* this finishes, so a user who starts a
# project and immediately opens a tab would otherwise have two updaters
# rewriting ~/.claude/bin at once, and the session's `|| echo` would hide the
# damage right before it ran the result.
echo "entrypoint: checking for Claude Code updates..." echo "entrypoint: checking for Claude Code updates..."
timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/home/claude/.local/bin:$PATH"; claude update' \ timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/home/claude/.local/bin:$PATH"; flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock claude update' \
&& echo "entrypoint: Claude Code is up to date" \ && echo "entrypoint: Claude Code is up to date" \
|| echo "entrypoint: warning — Claude Code update skipped or failed (continuing)" || echo "entrypoint: warning — Claude Code update skipped or failed (continuing)"