Commit Graph

4213 Commits

Author SHA1 Message Date
Teknium e27b8c5b92 test(desktop): trim session-control suites to behaviour contracts
session-control.test.tsx 1277→381 lines (32→8 cases): legacy-vs-structured
precedence, action dispatch, send continuation (idle + busy-queue), failure
banner, heartbeat countdown. store/session-control.test.ts 648→472 lines
(26→16): ordering/stale-token invariants, method-not-found downgrade, and a
gateway-switch wipe test replacing the removed rebind seam tests; the
setTimeout-spy 'no timer' test dropped.
2026-09-06 09:21:45 -07:00
Teknium efc30169c3 fix(desktop): queue goal-resume continuation when busy; wipe session controls on gateway switch
- session-control-goal.tsx: a 'send' dispatch against a busy session now
  parks the kickoff on the composer queue (the backend already resumed the
  goal) instead of reporting continuationFailed. Shared helper
  queueKickoffIfSessionBusy() extracted from slash.ts so both paths agree.
- gateway-switch.ts: wipeSessionListsForGatewaySwitch clears
  $sessionControlBySession (runtime-id keyed; new backend re-mints ids).
  Dead resetSessionControlAfterGatewayRebind removed; clearSessionControl
  now called from the session delete path beside clearQueuedPrompts.
- session-control.tsx: read/hydration failures use controlUnavailable copy,
  not actionFailed.
- i18n: heartbeatDueWaitingForIdle added to ja/ru/zh-hant; new
  continuationQueued/continuationBusy/controlUnavailable in all locales.
2026-09-06 09:21:45 -07:00
Jerry Gooch 45b7dabfb9 fix(desktop): run and surface Heartbeats reliably
(cherry picked from commit 77af78f1d8b7cb736d5d86e4fc7722449528a269)
2026-09-06 09:21:45 -07:00
Jerry Gooch 4f008c36bb fix(desktop): preserve session control action state
(cherry picked from commit 0efb410ed49ed6977352b313a639ff3f9d9375c1)
2026-09-06 09:21:45 -07:00
Jerry Gooch dffd8d62c2 feat(desktop): add session automation controls
(cherry picked from commit 8a61c2bcba8e4c7b3adcc172d4e5457721f76990)
2026-09-06 09:21:45 -07:00
Jerry Gooch bfddf556bf feat(desktop): hydrate structured session controls
(cherry picked from commit fec4bab6a191c474456956a860d86957d435552b)
2026-09-06 09:21:45 -07:00
Teknium fe49acb670 refactor(desktop): import-session entry is a sidebar nav row; browser module named as a foreign_sessions sibling
- Sidebar: the entry joins SIDEBAR_NAV (same chrome, active state, data-tour
  handle as the other rows) instead of a one-off Button below the rail;
  i18n moves to sidebar.nav['session-import'].
- Reuse common.retry / common.refresh / common.back instead of duplicating them
  under sessionImport in six locales.
- hermes_cli/foreign_session_browser.py -> foreign_sessions_browser.py so it
  sorts as a sibling of the foreign_sessions module it extends.
2026-09-06 09:09:41 -07:00
Adolanium 9186e3ebc5 feat(desktop): session import view for foreign coding-agent transcripts
Browse the backend host's foreign CLI session logs, preview a bounded read-only
transcript, and continue a copy in Hermes under the selected profile. Reuses the
hermes_cli.foreign_sessions parsers and the portability validator/writer;
imports are transactional and deduplicated on the recorded origin.
2026-09-06 09:09:41 -07:00
Axl Ibiza, MBA cacf1218d7 fix(desktop-update): acknowledge Windows progress completion
A delayed browser could miss the 900ms terminal event and spin forever after the updater exited. Retain terminal delivery until the page acknowledges it, bound unavailable-client teardown and failed requests, and preserve a truthful final display.

Fixes #103747. Builds on OutThisLife and Teknium detached handoff work in #83634 and the #75895 quiet-window design. Continues Axl Ibiza Windows update investigation (#60233, #94107, #100763), including source/review contributions carried by merged #93353 and #85170. Existing #102373, #103140, #95719, #97299 and #103632 retain their separate scopes.
2026-09-06 07:19:21 -07:00
Teknium 817c0ce08a fix(desktop): restore drag previews before the release commit folds a zone
When a sash drag folds a tool zone to its rail, the store commit re-renders
only the wrappers whose style prop changed; the flex sibling the gesture
had pinned to `flex: 0 1 <px>` kept that inline preview and could not grow
into the freed space. Restore every captured style attribute before the
commit, on every release path.
2026-09-06 07:18:28 -07:00
Jerry Gooch e9c527eeb6 fix(desktop): cascade a sash drag through sibling panes past their floors
A sash drag used to stop the moment its seam partner hit its min size, so a
row of panes behaved like unrelated boxes: growing the Browser tile could not
take space from Chat once the pane between them was at its floor. The drag
now plans the whole run from the pointerdown sizes — the partner donates
first, then its next visible sibling, and so on — and commits once on
release (fixed zones get px overrides, the flex run gets weights).

A reverse drag after a cascade stays local to the seam. Release only folds a
tool zone that THIS gesture took to its floor, so an unrelated rail already
resting there (a minimized Terminal) no longer cancels the Files/Chat commit.

Salvaged from #103166 (Jerry Gooch), trimmed to the sash-drag change; the
width/height lock feature, zone-body context menu and the restored-tool CSS
floor are held as separate decisions.
2026-09-06 07:18:28 -07:00
hermes-seaeye[bot] 089bb32886 fmt(js): npm run fix on merge (#104210)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-06 10:15:23 +00:00
kshitijk4poor 06402ecb7c test(desktop): tighten the real-sh mutex quoting test
Review follow-ups on the #96187 cherry-pick: skip on win32 like the
sibling tests that shell out; reuse the file's `exec` helper; one temp
root so a failing second mkdtemp cannot leak the shim dir; quote the
shim's redirect target; guard the payload-prefix sentinel so a renamed
loop marker can never make the test execute the real spawn payload;
drop the lockMetadata fields the assertions never read. Move the
mutexPath contract into withRemoteUpdateMutex's doc comment instead of
a third inline restatement.
2026-09-06 15:40:05 +05:30
John Paul Soliva b98edf8898 chore(desktop): drop the committed update-mutex artifact directory
`apps/desktop/'` is not a real path. It is a literal single-quote directory
holding a full absolute path as nested subdirectories:

    apps/desktop/'/var/folders/5h/.../hermes-update-mutex-LMF9y5/home/.hermes-update-in-progress.mutex'

Both files are 0 bytes, nothing in the tree references them, and the leading
and trailing `'` are part of the filenames. They are the fingerprint of the
double-quoted mutex path in `withRemoteUpdateMutex()`: the path reaches Python
with its shell quotes still attached, so it is treated as CWD-relative and
`os.makedirs()` materialises the whole absolute path under whatever directory
the process happened to be in. Running
`apps/desktop/electron/remote-lifecycle.test.ts` from `apps/desktop`
reproduces it on the spot.

They were swept in by a `git add .` in 36620578f0, an unrelated menu-label
commit.

This only removes the committed artifact. The generator is a separate concern
already covered by open PRs (#99189, #96187, #96260) and issues (#99133,
#96212, #96188); those repair the quoting but none of them deletes these two
files, so the litter would survive whichever one lands.
2026-09-06 15:40:05 +05:30
Kolton Jacobs 63b77aa593 fix(desktop): stop double-quoting expandRemotePath fragments in the SSH spawn path
expandRemotePath() returns an already-quoted shell fragment
("$HOME"'/path'), but three call sites wrapped its output in shq()
again: the withRemoteUpdateMutex python argv, the reservation/lock/
owner_file assignments in buildSpawnCommand, and the identity values in
buildOwnedStaleTerminationCommand.

The remote shell strips only one quoting layer, so python received a
mutex path with literal quote characters in it (creating a directory
literally named ' in $HOME), and the payload's mkdir "$reservation"
loop spun on a path that can never exist. Every Desktop SSH backend
spawn hung until the connect timeout, retried, and left an orphaned
flock queue behind; stale-owner cleanup always printed REFUSED for the
same reason.

The regression test parses the composed command with a real sh — the
same parse the remote login shell performs — and requires the mutex
path and the payload's reservation paths to come out fully expanded.
2026-09-06 15:40:05 +05:30
Teknium 2bb9c4e693 feat(desktop): approval-mode zap shows on the status bar by default
Whether dangerous commands run unasked is state worth seeing at a glance,
so the approval pill (yolo lightning) leaves STATUSBAR_HIDDEN_BY_DEFAULT.

Existing stores were seeded with it hidden, so the hidden-set key moves to
`hermes.desktop.statusbarHidden.v2`, seeded from v1 minus `approval-mode`:
other customizations survive, the zap appears once on update, and hiding
it again persists under the new key.
2026-09-06 02:17:05 -07:00
Teknium cca26097c5 fix(desktop): status bar shows for every install, including ones that hid it under the old key
The whole-bar preference lived at `hermes.desktop.statusbarVisible`. For a
stretch (d399c164 → 120e465c) the atom's fallback was `false`, so any
install that launched in that window persisted a hidden bar the user never
chose, and flipping the fallback back to `true` only helped fresh stores.

Move the preference to `hermes.desktop.statusbarVisible.v2` and do not seed
it from v1: every existing install comes back to "on" once on update, and a
hide made afterwards persists under the new key. Fresh installs are on by
default as before.
2026-09-06 02:17:05 -07:00
hermes-seaeye[bot] 6f4a822d9f fmt(js): npm run fix on merge (#104156)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-06 09:13:28 +00:00
kshitijk4poor 08f170aeab fix(desktop): clear an unconsumed foreground spawn mark when the dial settles
spawnPoolBackend() is not on every dial path: a primary route (startHermes),
a registry remote scope (connectRegistryBackend), a reused primary SSH
backend, or a guard rejection all settle the claim without requesting a
slot, so a foreground mark set for that dial stayed in pendingForegroundSpawns
and would have upgraded the next background hydration spawn of the same key.
applySpawnPriority() now returns the cleanup; both IPC handlers run it in a
finally around the claim. The mark is also taken right before the slot
request instead of at function entry, so the remote branch never consumes it.
2026-09-06 14:37:30 +05:30
kshitijk4poor 355058b80d fix(desktop): tag the route probe of a user open as foreground too
Every user open first probes its route (sharedPrimaryRoute /
isAttachedSharedRemote) with getConnection / getConnectionFor, and only then
dials the secondary. With #102496 only the second dial carried
priority: 'foreground', so main started (or joined) the spawn as a background
slot wait on the probe and the click still waited out the probe's 20 s
RECONNECT_ATTEMPT_TIMEOUT_MS before promotion kicked in. Thread the priority
into both probes; the activation doors (ensureGatewayForProfile /
ensureGatewayForAgent) pass 'foreground' explicitly.

Also drop the renderer-side isBackgroundSlotWaitTimeout + the try/catch whose
two branches both rethrew: Electron rebuilds IPC rejections as a plain Error,
so name/silent/priority never reached the renderer and the helper was dead.

Test: gateway-spawn-priority.test.ts asserts every dial of a foreground open
carries the tag and an untagged open never does (red on the #102496 head).
2026-09-06 14:37:30 +05:30
kshitijk4poor 49ab5e2b73 fix(desktop): consume foreground spawn marks and log only real slot waits
Follow-ups to the #102496 salvage in the Electron main process:

- pendingForegroundSpawns leaked: promoteInFlightLocalSpawn marked the key
  even when the pool entry already existed (the common click path), and
  nothing consumed it. After that backend was reaped, the next dial for the
  key - normally 10 s roster hydration - spawned as foreground and sat in the
  reserved slot. Mark only when no entry exists yet; spawnPoolBackend consumes
  the mark before any early return (remote route included) so it never
  outlives the dial.
- The "waiting for a free local slot" log fired for a foreground request that
  was granted the reserved slot immediately (condition was queuedCount > 0
  after request()). The request now reports `queued`; log only then.
- One promotePoolEntry() and one logPoolSpawnFailure() replace three copies of
  the promote snippet and two copies of the background/foreground log branch;
  spawnPoolBackend reads entry.spawnPriority instead of a second opts channel;
  isBackgroundSlotWaitTimeout is an instanceof check (same process as the
  class, no duck typing).
2026-09-06 14:37:30 +05:30
Cursor Agent 6230e9767d style(desktop): format promotion rejection handler
Co-authored-by: Josh Tsai <bounce12340@users.noreply.github.com>
2026-09-06 14:37:30 +05:30
Cursor Agent d196983b5d fix(desktop): observe foreground promotion failures
Co-authored-by: Josh Tsai <bounce12340@users.noreply.github.com>
2026-09-06 14:37:30 +05:30
Josh Tsai 4f1fbc47cd fix(desktop): let foreground bot opens preempt spawn-cap hydration
LocalBackendSpawnCoordinator is FIFO with maxBackends=3, so launch
hydration queues ~27 ensureBackend calls and a user click times out
waiting for a slot. Reserve a foreground slot, drain foreground first,
and fail background slot-wait quietly.

Fixes #102281.
2026-09-06 14:37:30 +05:30
Gille 96ed0e71ea fix(bot-mode): match scoped bot selection in reset guard 2026-09-05 16:17:23 -07:00
liuhao1024 2afd17a9d7 fix(desktop): resolve v2 registry SSH terminals from the bootstrap pool key
The terminal pane's registry-scoped lookup keys sshConnections with
backendScopeKey(connectionId, profile), but the pool's single writer
publishes every tunnel under its per-profile bootstrap key with the
registry connection stamped on the entry. The lookup can never hit, so
a v2 registry SSH terminal pane stays 'pending' forever (#97345).

Fall back to the entry stamped with the window's connection id - the
same identity match managedSshScopeRole applies to pool entries - and
return the writer's actual scope so downstream getSshConnectionState
and teardown keep working.
2026-09-05 18:20:20 +05:30
Adolanium dde7ed42b3 fix(desktop): look up v1 SSH terminals with the bootstrap pool key
resolveDesktopRemoteRoute tags v1 SSH with a registry connectionId when
the host matches. The terminal then looked up sshConnections with
backendScopeKey(connectionId, profile), but bootstrap stored the
tunnel under sshScopeKey(profile or null). The pane stayed pending.

Ignore the identity tag for v1 pool lookup.
2026-09-05 18:20:20 +05:30
kshitijk4poor 288ef25d77 refactor(desktop): spell the adopt guard as null-to-real; fix hidden-row comment
The guard `fromKey === toKey || fromKey !== NO_SESSION` only ever admitted
null -> real; write that directly. Bot Chat kickoffs are visible user rows
(prompt.submit without display_kind), so the comment names only widget
intents as the hidden-row producer. No behaviour change: 98 tests green,
real->real leak test still fails when the guard is loosened.
2026-09-05 16:45:06 +05:30
kshitijk4poor 2516ed13d8 fix(desktop): voice turn bound honours hidden user rows; adopt moves the null-session anchor
Follow-ups to the #96622 salvage:

- collectUnspokenTurnSpeech bounded the fallback at the last *visible*
  user row. A turn kicked off by a hidden user prompt (inline-preview
  widget intents, Bot Chat kickoffs) has no visible user, so the slice
  fell back to index -1 and replayed the whole transcript again. Hidden
  user rows are real turns for the agent; count them.
- adoptSpokenReplySession copied the null-session anchor onto the created
  session but left the null entry behind, where the NEXT new chat (null
  session again) would read it and skip its first reply. Move, not copy.
- Pin the "target already has an anchor -> not overwritten" guard.
2026-09-05 16:45:06 +05:30
Patrick Gibbs a2e0ca7fc9 fix(desktop): don't replay earlier turns in voice conversation
When lastSpokenId is missing or stale, collectUnspokenTurnSpeech used
index -1 and concatenated every prior assistant reply into one speech
string. Bound collection to the current turn (after the last user
message) and carry the spoken anchor from a null session id onto the
created session so live conversations stay turn-scoped.
2026-09-05 16:45:06 +05:30
Teknium b51c055a12 fix(desktop): harden window-open deny and cover the link-title window
Follow-ups on the cherry-picked handler:
- a throwing observer can no longer change the decision; the handler returns
  an explicit deny regardless of logging failures
- the denied-URL log carries origin only, so query tokens / signed URLs from
  attacker-controlled content never reach the persisted desktop log
- the hidden link-title window (loads arbitrary user-linked pages on render,
  had no window-open handler at all) now denies too
- tests trimmed to two invariants (proven red against the pre-fix shape)
2026-09-04 23:19:11 -07:00
teknium1 77ca6a6d12 fix(desktop): deny window-open side-effect opens (GHSA-9f4c-93c8-jc8g)
setWindowOpenHandler opened details.url as a side effect before denying.
Per GHSA-9f4c-93c8-jc8g (CVE-2026-70608, High 7.2), a sandboxed iframe
with no allow-popups and no user gesture can reach this handler via the
OpenURL path -- and the desktop renders untrusted artifact HTML in
<iframe sandbox="allow-scripts">. A malicious artifact could therefore
force the OS browser to an attacker URL with zero interaction. Electron
ships no fixed 40.x release (fix is 41.10.3+/42.0.1), so we close it at
the seam, version-independently.

- electron/window-open-policy.ts: pure decideWindowOpen (always deny) +
  createWindowOpenHandler(onDenied) that denies and never opens a URL;
  the hook is logging-only.
- main.ts: wireCommonWindowHandlers uses it (covers primary + all
  secondary/quick windows); the deny is logged, no side-effect open.
- Trusted external links are unaffected: they already route through the
  audited hermes:openExternal IPC channel (openExternalUrl, http/https/
  mailto allowlist). Converted the one remaining bare window.open on the
  Electron path (env-var docs menu) to openExternalLink; other
  window.open sites are bridge-absent web fallbacks.
- tests-js/window-open-policy.test.ts: 4 tests pinning always-deny, the
  logging-only hook, and that a throwing hook never degrades to allow.
2026-09-04 23:19:11 -07:00
hermes-seaeye[bot] 71f8c60f6a fmt(js): npm run fix on merge (#103240)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-04 22:20:13 +00:00
Hao Zhe 64534b4dd4 fix(desktop): use shared tooltip for listing gallery 2026-09-04 15:14:49 -07:00
Brooklyn Nicholson b0ab2e163a test(desktop): pin that a multi-tab zone can still hide its strip
The resolver's own suite only ever asserted `never` against a LONE pane, so
the whole-stack stranding check passed every existing case while Hide tabs was
dead in the one zone users keep tabs in. Adds the missing count: an explicit
`never` is honored once a closeable tile has a neighbor to cycle to, and those
same stacks still paint their strip on auto (the rung resolves before `mode`,
so a stack it stops claiming has to reach the auto rule).

Verified as a tripwire — reverting the resolver fails the first case.
2026-09-04 15:01:10 -05:00
Brooklyn Nicholson 2c52945974 fix(desktop): Hide tabs works again in a zone holding more than one tab
The reachability rung that outranks an explicit `never` ran over the whole
stack: any closeable `placement: 'main'` pane pinned the strip visible at any
tab count. Session tabs and bot chats are exactly that, so one session tab in
main was enough to make Hide tabs a no-op — the zone menu row and ⌘⌥T both
wrote `tabStrip: 'never'` and the resolver overrode it on the next line.

Stranding is about the last handle, not about tabs existing. A stack of two or
more answers tab cycling and ⌘1…⌘9, so hiding its strip costs chrome and no
handle. The tile and tool-panel rungs now apply to a lone pane; hide-only
chrome (sessions / Bots) keeps firing at every count, since it has no close
verb and its Show/Hide rows live on the strip itself.
2026-09-04 15:01:10 -05:00
Brooklyn Nicholson cb6cb74696 Keep renderer-owned fences out of artifact promotion
Artifact detection runs before the rich-fence registry, so a fence long
enough to look like a code document was promoted to a code card and never
reached its own renderer. A multi-listing set crosses that threshold
easily, which made the same content render two different ways depending on
how many properties it held.

`listing` joins the languages that are never artifacts, next to `mermaid`
for the same reason.
2026-09-04 15:00:43 -05:00
Brooklyn Nicholson 9b16719e17 Render property listings as browsable transcript cards
Adds a `listing` fence to the rich-embed registry: JSON in, a native card
out — photo mosaic, address and price, specs, and the catches worth
verifying before a tour.

The rental portals publish no embeddable surface (public APIs deprecated or
broker-gated, no oEmbed, X-Frame-Options on every listing page), so a URL
cannot be framed. The data the agent already gathered can be, and the
portals' image CDNs hotlink fine — so the card is authored rather than
fetched, alongside the existing mermaid and svg fences.

Parsing treats every field as untrusted model output: one object, a bare
array, or `{listings: []}` all work, beds accept `3` and `"3.5"`, non-http
URLs are rejected so a card cannot smuggle a javascript: href, and lists
are capped. Anything unparseable falls back to the plain code block.
2026-09-04 15:00:43 -05:00
abundantbeing 6b1e12c7f4 feat(desktop): drag to create sessions from New session, project + controls, and profile groups 2026-09-04 14:57:37 -05:00
Brooklyn Nicholson 96e1e3f921 feat(desktop): Message Bubble transparency slider in Appearance
One 0-100 lever beside Window Translucency: 0 keeps your bubble solid (the
default), 100 leaves only the outline. The store paints a single root var,
--user-bubble-keep, that --dt-user-bubble mixes in, so the read-only bubble
and the inline edit composer follow together and an untouched lever yields
byte-identical CSS. Reuses TranslucencySlider (with its overlay peek), has a
Cmd-K search entry, and copy in all six locales.
2026-09-04 14:40:54 -05:00
Brooklyn Nicholson 59302ece0d fix(desktop): transcript timestamps were painted at 30% ink in both modes
TimelineTimestamp layered text-muted-foreground/55 on a token that is already
a 54% mix of the base ink, so the stamp rendered near 30% alpha: 2.3:1 on a
dark chrome, 1.9:1 on a light one. Point it at --conversation-scaffold-meta,
the token the tool-row stamps already use, so every stamp in the transcript
reads from one place.
2026-09-04 14:40:54 -05:00
hermes-seaeye[bot] 59d330fd15 fmt(js): npm run fix on merge (#103110)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-04 18:28:32 +00:00
Brooklyn Nicholson 1d1411df9c fix(desktop): keep the saved skin name until the registry can resolve it
normalizeSkin ran at boot, before the gateway seeded backend skins, and flattened an unresolvable name to the default for the whole session. The connect-time seed is apply: false by design, so nothing repainted. Keep the raw pick in provider state and normalize against the live registry instead.
2026-09-04 13:19:19 -05:00
Brooklyn Nicholson 413d5ab863 fix(desktop): cache backend skins so the boot paint can resolve the saved one
$backendThemes only lived in memory, so a relaunch on a skin from ~/.hermes/skins painted the default until the next skin.changed. Hydrate the registry from localStorage like user themes, and write it back on every change.
2026-09-04 13:19:19 -05:00
Brooklyn Nicholson 9403ad2656 refactor(desktop): share the theme shape check between registries 2026-09-04 13:19:19 -05:00
Brooklyn Nicholson 433f719676 fix(desktop): tab ✕ fade lands on the visible field under Glass
Under Window Translucency → Glass, the glass block sets the surface
tokens transparent so <body> is the sole painter. A tab's `--tab-face`
read those tokens, so the ✕ runway resolved to transparent→transparent
and the chip's `bg-(--tab-face)` painted nothing: hovering showed a bare
✕ over un-faded text, with no gradient at all.

Add a `data-glass-field` contract beside the block's existing
`data-glass-opaque` / `data-glass-raised`: a declarer receives
`--glass-field`, the same body mix glass already paints. The tab prefers
it and falls back to its own surface token (`--tab-surface`, which
TAB_ACTIVE / TAB_IDLE now set instead of `--tab-bg` so they feed the
chain rather than bypass it). Face, hover-darken, and the selection wash
all inherit the one base. Inert with glass off: the fallback resolves to
the exact opaque surface it did before.
2026-09-04 13:09:07 -05:00
Brooklyn Nicholson ff75ff2a18 fix(desktop): closeable tab labels clip so the ✕ fade has text to dissolve
The label span used `truncate`, whose ellipsis stops the text ~8px short
of the overlay. The hover gradient then faded over empty tab surface —
surface to the same surface, invisible by construction — and the ✕ read
as floating in a gap beside three dots.

A closeable tab publishes `data-closeable`; the label reads it via
`group-data-[closeable]/tab:text-clip` so overflow runs under the fade
and the gradient dissolves real letterforms. Vertical rails and
uncloseable tabs have no ✕, so they keep the ellipsis.
2026-09-04 13:09:07 -05:00
Brooklyn Nicholson 87e78d668b fix(desktop): pane tab ✕ costs a min-width floor, not 36px of dead padding
#96880 reserved `pr-9` on every closeable tab so the hover ✕ could never
sit over a label — 36px of empty runway at rest on every tab, nearly
doubling FILES (40→76px) and taxing tabs whose label already cleared the
overlay by a mile.

Restore the overlay as designed (✕ paints over the label's right edge,
gradient-blended, zero layout shift) and protect short labels with a
`min-w-13` floor instead. Sized from the chrome it has to survive: 8px
label inset + the ~19px opaque chip, so the shortest labels clear the
chip and pass only under the translucent fade. A floor bills only tabs
that need it; TERMINAL, BROWSER, NEW CHAT pay nothing.

The test that pinned `pr-9` by class string is rewritten as a set-diff
contract: adding `onClose` must add a `min-w-*` and never a `pr-*`.
2026-09-04 13:09:07 -05:00
Brooklyn Nicholson d353931dd9 test(desktop): assert tooltip labels stay in inline flow, not inline-flex
The preview-row and terminal-rail tests pinned the label's `inline-flex`
class — the very shape that broke multi-line tips. Assert the invariant
instead: no flex box under the decoration, and the preview row splits its
two lines with a hard break.
2026-09-04 12:51:08 -05:00
Brooklyn Nicholson a22b295dbd fix(desktop): paint every line of a multi-line tooltip, not just the first
Tip's chip is an inline span with `box-decoration-break: clone`, so its
background only paints the line boxes of inline flow. The #62022 hardening
forced every label child to `inline-flex`, which is an atomic inline: it sits
on the baseline and hangs any extra lines below the chip, dark-on-dark. The
preview status row's two-line label (target path + click hint) showed the
path lit and the hint invisible, and long paths wrapped into unreadable rows.

Force label children `inline` instead — still no block collapse, and wrapped
content stays in flow. TipHintLabel and the preview row drop their flex
boxes for plain inline text (a `<br />` for the hard break).
2026-09-04 12:51:08 -05:00