Skip to content

fix(control): a sentence you can read, and the check that noticed it - #479

Merged
github-actions[bot] merged 1 commit into
mainfrom
fix/control-clipped-prose
Sep 4, 2026
Merged

fix(control): a sentence you can read, and the check that noticed it#479
github-actions[bot] merged 1 commit into
mainfrom
fix/control-clipped-prose

Conversation

@catomean

@catomean catomean commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

The bug

/control's "Suggested next" line carried truncate — one line, ellipsis. On prod that rendered:

Suggested next (profile): Conduct a 1-month baseline m…

594 of its 644 pixels hidden at 390px, 432 at 1440. The rest existed only in the title tooltip, and a phone has no hover.

It is the card's one actionable sentence, and clicking it loads the text into the composer — so the sentence is the decision, and a tenth of a sentence is not one. OutcomeStreak.tsx, in this same directory, documents having already fixed this exact mistake for its glyph row; /activity has always clamped the same "what's next" content to two lines (ActivityEventRow). This makes all three agree.

Same class in the composer's status line, worse: it shared a row with four icons and Send, leaving it 72px — measured at 0% shown at 320px and 14% at 390px. Shorter copy cannot fix that; no sentence fits 72px. It needed the width, so below sm it wraps onto a row of its own and from sm up it is unchanged.

The gate — and why it is deliberately not a lint rule

responsive-audit already looks for clipped text, and deliberately exempts text-overflow: ellipsis as "a deliberate design choice". That exemption is correct for what it was written for — a project name in a narrow rail, a filename, an id — and 76 files under src/components truncate exactly that way. A rule banning truncate, or truncate next to a title, would fire on ~42 legitimate sites and be switched off within a week.

The distinction is prose, which a static rule cannot judge but a real viewport can measure. So the check lives where the viewports already are, and fires only when:

  • the text reads as a sentence — ≥60 chars, ≥8 spaces (an id has neither), and
  • under half of it is visible — a chip losing its tail is fine; a line showing its first eight words is not.

Proven by mutation

One environment, one run, only the classNames differing:

state result
with truncate 2 findings — 40% shown, -552px, 22% shown, -402px
with the fix silent at 320 / 390 / 768 / 1440

It flags none of the 76 legitimate truncations.

Also verified as a natural before/after: against prod (old code) it reported 2 findings at 320 and at 390 and stayed silent at 768/1440 where the line genuinely fits; against a local server with the fix, silent everywhere.

Verification

  • npm run verify — exit 0
  • npm run audit:responsive -- /control — clean, authenticated (the run that "passed" while landing on /sign-in was caught by the audit's own landed on note and redone)

Observed, not addressed here

/control logs a React #418 hydration mismatch at every viewport. Out of scope for this change; flagging it so it isn't lost.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UvjGNAS9CMfEGNW26tUR4P

/control's "Suggested next" line carried `truncate` — one line, ellipsis. On
prod that rendered "Suggested next (profile): Conduct a 1-month baseline m…":
594 of its 644 pixels hidden at 390px, 432 at 1440. The remainder existed
only in the `title` tooltip, and a phone has no hover.

It is the card's one actionable sentence, and clicking it loads the text into
the composer — so the sentence IS the decision, and a tenth of a sentence is
not one. OutcomeStreak.tsx, in this same directory, documents having already
fixed this exact mistake for its glyph row. /activity has always clamped the
same "what's next" content to two lines (ActivityEventRow). This makes the
three agree: line-clamp-2.

Same class in the composer's status line, worse: it shared a row with four
icons and Send, leaving it 72px — measured at 0% shown at 320px and 14% at
390px. Shorter copy cannot fix that; no sentence fits 72px. It needed the
width, so below `sm` it wraps onto a row of its own (order-last w-full) and
from `sm` up it stays exactly as it was.

THE GATE, and why it is not a lint rule.

responsive-audit already looks for clipped text, and deliberately EXEMPTS
`text-overflow: ellipsis` as "a deliberate design choice". That exemption is
right for what it was written for — a project name in a narrow rail, a
filename, an id — and 76 files under src/components truncate exactly that way.
A rule banning `truncate`, or `truncate` beside a `title`, would fire on ~42
legitimate sites and be turned off within a week.

The distinction is prose, which a static rule cannot judge but a real viewport
can measure. So the check lives where the viewports are: flag an ellipsis only
when the text READS as a sentence (>=60 chars, >=8 spaces — an id has neither)
AND under half of it is visible (a chip losing its tail is fine; a line showing
its first eight words is not).

Proven by mutation, in one environment, with only the classNames differing:
  - with `truncate`  -> 2 findings ("40% shown, -552px", "22% shown, -402px")
  - with the fix     -> silent at 320 / 390 / 768 / 1440
and it flags none of the 76 legitimate truncations.

Verified against prod before the fix (2 findings at 320 and at 390, silent at
768/1440 where the line fits) and against a local server after it.

npm run verify passes.

Separately observed and NOT addressed here: /control logs a React #418
hydration mismatch at every viewport.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvjGNAS9CMfEGNW26tUR4P
@github-actions
github-actions Bot merged commit 60dac48 into main Sep 4, 2026
3 checks passed
@github-actions
github-actions Bot deleted the fix/control-clipped-prose branch September 4, 2026 10:30
github-actions Bot pushed a commit that referenced this pull request Sep 4, 2026
…off by 2x (#480)

Three follow-ups from sweeping the earlier findings. Two of the four I had
carried forward did NOT survive checking and are dropped: /api/control/dispatch
is a real route, so the docs referencing it are correct; and
scripts/test/push-notifications.ts exists. Verifying first is why this commit
is smaller than the list that prompted it.

1. THE TEST NOBODY RAN, FOR A REASON THAT WAS NEVER TRUE

scripts/test/push-notifications.ts read scripts/agent-hook-bridge.sh, which was
deleted on 2026-06-11 in 956ccf6 ("delete the bash daemon and bridge files").
It threw ENOENT and the suite exited 1. Its entry in test-unit.ts SKIP said
"needs push/web-push env — run manually". That was never the reason: every
check in the file is a static file read and it needs no environment at all.

A wrong skip reason is worse than no skip. It answers the question that would
have found the bug, so the red looked accounted for and nobody looked again for
three months.

Retargeted rather than deleted. The behaviour moved instead of going away — a
closing run reaches the operator through notify-close.ts → pushToUser →
push-fanout — and scripts/test/notify-close.ts covers that module without
mentioning push at all. So "an agent finished, tell the operator" was asserted
by nobody. Deleting the check would have quietly ratified that hole.

Removed from SKIP, so it now runs in test:unit (132/132) and therefore in CI.
Mutation-proven: renaming the pushToUser call fails with "notify-close must
call pushToUser" (exit 1); restored, exit 0.

2. THE BOX IS HALF THE MACHINE THE DOCS DESCRIBE

Measured 2026-09-04: nproc 4, 7746 MiB, 75 G disk, 4 GB swap. That is a CX33.
Three docs said CX43, 8 vCPU / 16 GB, 40 GB disk — every figure but the swap
wrong, in the direction that matters: planning against 16 GB on an 8 GB box is
how you meet the OOM killer.

src/app/api/system/hetzner/route.ts has said "the box is a cx33" all along, so
this was code and docs disagreeing with the code being right. Note the shape of
the drift: CPU and RAM went DOWN while disk went UP (40→75), which is what a
disk-inclusive rescale to a smaller tier looks like — plausibly to escape the
disk-full problem the old caveat describes. The June runbook entry is kept as
history and marked superseded rather than rewritten.

The docs now point at /api/system/hetzner instead of restating numbers. A
hardcoded spec is a second source of truth that rots silently, which is exactly
what happened here between June and September.

3. THE COMPOSER STATUS, FINISHED

#479 gave this line its own row below `sm` but kept `sm:truncate`, which left it
at 57% on a 1440 viewport — "Autopilot on: queue is empty, so FleetCr…". That is
the same defect as the phone's 14%, just less obvious, and there is no `title`
here so the remainder is unreachable at every width. It now wraps everywhere.
Costs one toolbar line when the sentence is long; truncating cost the half of
the sentence that says what will happen.

pnpm run verify passes (repo moved to pnpm in #478).


Claude-Session: https://claude.ai/code/session_01UvjGNAS9CMfEGNW26tUR4P

Co-authored-by: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Sep 4, 2026
…lamp that never clamped (#482)

Found by using /control in a browser as a user, on a phone-width window,
start to finish: pick a project, type a prompt, send it, follow the outcome.

1. "PASTE FROM HISTORY" WAS SHOWING THE OPPOSITE OF WHAT IT PROMISES

The card offered back conversational asides — "you do it. open in browser and
do it" — as reusable work. Not a rendering bug: those really were dispatches.
The selection was wrong.

Measured on prod, 30 days: 1859 distinct custom prompts, of which 1823 (98%)
were sent exactly ONCE. The query ordered by recency, so it surfaced the three
most recent one-offs — by definition the prompts least likely to be wanted
again — while the genuinely reused ones sat unoffered: "continue" 115×, a
git-diff review prompt 63×, "go ahead" 14×, "keep going" 8×.

Requiring two uses cuts the pool from 1859 to 36 and leaves exactly what the
feature is named for. Nothing is lost: Activity keeps every dispatch with a
re-run control. One-offs just stop competing for space on the card.

2. THE CLAMP I SHIPPED IN #479 WAS INERT, AND I COULD NOT SEE IT

`block ... line-clamp-2` computes to `display: block; -webkit-line-clamp: 2`.
`-webkit-line-clamp` does nothing without `display: -webkit-box`, and
Tailwind's `block` utility overrode it. It LOOKED fixed because an unclamped
box just wraps — which reads fine until the sentence is long.

Checked live in the browser: a 460-character next_step grew that box from 48px
to 96px and pushed the composer, the thing you act with, off the bottom of the
viewport. So my "it renders fully now" was measuring the ABSENCE of a clamp.

Dropping `block` makes it work; three lines, not two, so a typical next_step
(~256 chars) still reads in full and a pathological one is bounded and
reachable (clicking loads the whole text into the composer).

THE GATE: responsive-audit now flags a line-clamp that is declared but not
enforced. It cannot test `display === "-webkit-box"` — Chrome reported
`flow-root` for a clamp working correctly — so it tests BEHAVIOUR: an element
declaring N lines that renders more than N. Run against live prod it says:

  "Suggested next (profile)…"[ui-link-subtle-button.block.line-clamp-2]
  declared 2 lines, renders 4 (display:block)

naming the offending class. That is this exact bug, caught in shipped code.

3. A FAILURE MESSAGE THAT NAMED THE WRONG AGENT

A real dispatch returned: "launched claude (pty) + injected, but the agent
isn't generating yet … Retry, or switch the project agent away from grok if
this repeats." One sentence naming the agent that ran and, as advice, one that
did not — a literal left over from when grok was the default. `agent` was in
scope; the auth branch above it interpolates it correctly.

Gated in failure-message-pairing: a failure message that NAMES an agent must
interpolate ${agent}. Stated that way rather than "no agent ids", because the
auth message legitimately says `~/.claude/.credentials.json` and `claude
setup-token` — a real path and a real command — and already names the right
agent. The check joins `+` continuations so a message split across two lines is
judged whole; per-line it got that one wrong.

Mutation-proven: restoring the "grok" literal → 20 passed, 1 failed, naming the
line. Restored → 21/21.

pnpm run verify passes.


Claude-Session: https://claude.ai/code/session_01UvjGNAS9CMfEGNW26tUR4P

Co-authored-by: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant