tool.start for a merge:true todo write used args.todos as a full replace. A one-item status patch became Tasks 1/1, or vanished if content was omitted, so the panel looked stuck at 0/5 until the final complete result. Apply merge by id on start, keep replace for the full result, and show the in-progress spinner on the expanded header too.
* fix(desktop): MEDIA:-delivered non-media files route to the preview pipeline — PDFs/data files get the file card instead of a dead 'Open' anchor (extends #84951 to every extension)
* docs(prompt): desktop guidance aligned with any-file MEDIA: delivery — preview card truth, local-markdown-image block warning
FIRST_PAINT_BUDGET 20 + BACKFILL_STEP 60 prepended the rest of a 600-unit
page across ~10 visible commits. A 290-unit step keeps the interruptible
commits and removes the strobe.
Ownership of an empty transcript is per session and is not known until each
plugin has loaded its own data — which is why the slot mounts contributors
rather than resolving a claim up front. Taking only contributions[0] then let
a plugin that DECLINES a session suppress the one that owns it: silently,
permanently, and only for the users whose installed plugins happen to
register in that order.
Every registration is mounted and answers for itself. Declining is free; two
claiming one session render both, a visible conflict instead of a silent drop.
Keeps plugin.js and its new .mjs test deleted. main's closed-chat fix
(7c91079) landed in both; it is a real behaviour change, so the commit that
follows ports it onto the split modules rather than dropping it with the
files. Its core half — focusWorkspaceOwnerSessionTile and the
host.focusOpenWorkspaceSession verb — merged cleanly and is used as-is.
Tours had no switch at all, and the tips switch only covered the app's
own rotation — so "I don't want these" was answerable for half of one
feature and none of the other. Both are now a row in Settings →
Appearance, on by default, and off means off for Hermes as well: an
agent tip is dropped at the bridge and a tour request is refused in
words, so the agent hears that the walkthrough didn't run instead of
narrating a spotlight nobody can see.
Renames $tipRotationEnabled to $tipsEnabled to match what it now
governs. The storage key keeps the old name on purpose — renaming it
would read as unset for anyone who had already turned tips off, and
silently turning them back on is the one outcome worth avoiding.
The switches stop at the app's edge for now: the tools are still in the
model's schema and the calls simply don't land. Withdrawing them
entirely needs the setting to reach the backend, which is the next PR.
Made opt-in when it fired a tip 45 seconds into a launch and another
every six minutes, which is a cadence that owes you a choice. The pacing
has since become a settling delay of five to ten minutes per launch and
a six-hour cooldown persisted across them — roughly a tip a day, weeks
to walk the catalog. At that weight the switch has nothing left to
protect anyone from, and a discovery feature nobody meets is one nobody
has. The switch stays for whoever still wants it off.
main's Bots-home flash fix (6f8be61) landed in plugin.js, which this branch
deletes. Both files stay deleted: the guard it adds (botOpenInFlight gating
botsHomeMayOpen) protects a surface this branch removes, and the two sites
where it renamed the generation bump to cancelBotOpen already bump here via
bumpBotOpenGeneration in shared.ts.
A first tip 45 seconds in and one every six minutes after walks the
whole catalog in an hour, which is the cadence of a notification rather
than a nicety. Games get this right by being almost absent: a tip while
you settle in, then nothing for the rest of the day.
Two clocks now have to agree. A per-launch settling delay of five to ten
minutes means opening the app is never met with a bubble, and a six-hour
cooldown persisted across launches means quitting and reopening isn't a
way to farm them — the old schedule lived in the effect and re-armed on
every mount. Flipping the switch on skips the settling delay and offers
immediately, since that clock guards a launch you came into with a
purpose, not a deliberate opt-in.
An agent tip starts the cooldown too: whoever just pointed at something,
the user has had their one interruption for a while.
The two halves of tips were behind one switch, which meant the app
volunteering commentary at idle shipped on by default. Split them along
the line that matters: the rotation talks unprompted, so it now waits to
be asked for, while an agent tip stays ungated like the tour it mirrors
— Hermes raises one mid-conversation, in answer to something the user
said.
Drops the tool's config gate along with the config key it read. The
renderer mirrored that key with config.set, which has no branch for it
and answered "unknown config key" into a swallowed catch, so the opt-out
never reached the backend in the first place.
An ambient rotation that points at parts of the app you may not have found yet
— one accent bubble, an arrow, and an outline around the subject. No scrim and
no spotlight: a tip is a pointer beside your work, not a modal in front of it,
so it takes no focus, owns no Esc, and blocks nothing.
It only speaks when the app is genuinely quiet — nothing streaming, no dialog,
menu or tour up, window focused, a few seconds since the last keystroke — and
walks the catalog in order rather than shuffling, so tips arrive as a tour of
neighbouring parts of the app instead of unrelated ones. A tip with nothing on
screen to point at is skipped, not waited for.
Closing one with its ✕ retires it for good. That is the whole reason the ✕ is a
heavier gesture than letting the bubble time out, and Settings → Appearance is
the only way back — alongside the switch that turns the feature off entirely.
The default popover is glass over the app's own chrome, which is right for
something the user opened and wrong for something the app said. The accent
variant fills the same box — same arrow, same placement engine — with a solid
brand colour so an unprompted surface reads as the app speaking.
Filling it with `primary` directly doesn't work across themes: a pale accent is
a perfectly valid primary (imported VS Code themes love a pastel), and the
honest `primaryForeground` for one is near-black, so the loud surface comes out
a pastel card whispering. `--dt-primary-solid` deepens the hue until a light
foreground clears AA — a no-op on an accent that is already deep, and darkening
only, so the hue survives.
Six verbatim copies of the leading-glyph box down to two owners: transcript
lines get SCAFFOLD_GLYPH_CLASS from scaffold-row, sidebar rows get
SIDEBAR_ROW_LEAD. The bot rail's GatewayKindGlyph was a fork of core's
ConnectionGlyph down to the icon set, so it wraps the real one now and the
duplicate kind-to-icon tables are gone.
Core has one empty state, the intro splash, and it belongs to a fresh
draft with no session selected. A session that exists but has nothing in
it yet falls outside that, and whoever opened it is the only one who
knows what should stand in the gap — core has no business learning a
bot's face and name.
Add `chat.empty` as a contribution area, resolved by the transcript the
same way `transcript.directives` resolves an inline widget. A
contribution mounts for every empty session and renders nothing for the
ones it does not own, so it can subscribe to its own stores and appear
once they load.
Pull the splash's lettering out of `intro.tsx` into a `Wordmark`
primitive so a second caller cannot fork it, and move the typeface,
weight and tracking into `.wordmark` beside the `.fit-text` rules they
travel with — as utilities they were one class-merge or one missed scan
away from silently rendering the wordmark in body text. `Wordmark` takes
its width, because fit-text sizes to fill and a short name set at the
splash's full width comes out enormous.
Contributions could scope themselves to a workspace, and Bot Mode scoped
every sessions pane out of the center. Close the last bot chat and the main
zone had nothing left to render, so it collapsed and the sidebar stretched
across where the app used to be. Bot chats are ordinary tabs in the main
strip, beside session tabs, so the filter goes: workspace mode stays as a
SIGNAL consumers adapt to, never a decision about whether a pane renders.
A structural floor backs it up — a subtree hosting the registered main pane
never collapses, whatever happens to its tabs. The shared "+" now falls
through to an ordinary session when the selected owner has no route of its
own, instead of refusing with a toast.
Also lets a pane contribution declare defaultCollapsed, so Bot Mode's
scheduled-jobs pane arrives as the right-edge rail tab rather than an open
zone. It applies when the pane enters the tree, so a user's expand persists
and is never overruled on a later boot.
Hiding the Sessions/Bots strip, or tapping the header of a lone docked
tile (Cronjobs and any plugin pane beside the workspace), left no mouse
path back: the restore menu lived on chrome the gesture just unmounted,
and a row-collapsed rail could size to 0px.
Treat hide-only chrome as stranded so `never` cannot hide those chips.
Stop collapsing on header tap (chevron only). Size a minimized zone to
MINIMIZED_TRACK and keep the horizontal strip when two or more tabs
remain.
The live thinking body pins to the newest tokens and keeps max-h-40
after the turn settles so the transcript doesn't jump. overflow-hidden
let that pin work in JS but clipped the rest of the thought. overflow-auto
makes the cap a real scroller; overscroll-contain keeps the wheel from
chaining into the transcript.
Supersedes #73757.
Co-authored-by: Dan Latimer <latdani@gmail.com>
* fix(desktop): keep attachment close and code copy icons visible
Hover-only opacity-0 hid the composer remove control and code-block copy button, so they stayed clickable but invisible on Windows and other no-hover surfaces.
* test(desktop): pin attachment close and code copy visibility at rest
The remove chip and code-block copy control must stay in the tree without a hover class, so Windows and no-hover surfaces cannot hide them again.
Close All only dismissed layout-tree panes. Bot tiles live in the
shared __bots_workspace__ bucket, so clicking a bot or swapping
profiles rehydrated the closed tabs. Persist-close those tiles
before dismissing the rest of the strip.
uploadComposerAttachment's remote/local decision (image.attach vs
image.attach_bytes) read $connection.get()?.mode — the window's ambient
connection — at all three call sites. Since the multi-connection
routing work landed, a session can be owned by a different, registered
connection than the ambient one (Bot Mode, the unified Sessions list):
the RPC itself already routes to that owner via requestForSessionProfile,
but the byte-vs-path decision did not, so a local-ambient window chatting
in a remote-owned session shipped a client-local composer-images path to
a backend that can't read it (#94640).
isSessionRemote() resolves the session's own owner route (falling back to
ambient only when no owner route is known, matching the existing RPC
routing behavior in session-states.ts) and replaces the three ambient
reads in use-prompt-actions/index.ts, session-tile-actions.ts, and
user-edit-composer.tsx.
Review follow-up, comments and one type annotation. No behaviour change.
The unmount effect now clears pending timers, but its leading comment is
still entirely about the focus bus, so the cleanup reads as unrelated code
that happens to be in the same block. Say why it lives there: both concerns
are "this composer is going away", they unmount together by definition, and
a sibling unmount-only effect would only be a second place to forget.
In the regression test, the clearTimeout mock declared id as number. Nothing
that reaches it is a number: jsdom under node returns a Timeout object, which
is why the scheduled and cleared arrays are unknown[] and compared by
identity. The annotation documented a shape the test never sees, so it is now
unknown with the cast moved to the one call that genuinely wants a number.
Confirming an inline edit unmounts UserEditComposer while its 200ms submit
latch is still pending, so the callback resumes on an unmounted tree and calls
setSubmitting. Five window.setTimeout calls in the component, none cleared on
unmount; the latch is the one with a delay long enough to reliably survive.
In the app the stray state update is an invisible no-op. In vitest it can land
after the test file's jsdom environment is gone, and React reaches for a window
that no longer exists:
Test Files 570 passed (570)
Tests 5434 passed (5434)
Errors 1 error
ReferenceError: window is not defined
at resolveUpdatePriority react-dom-client.development.js:1308:7
at dispatchSetState react-dom-client.development.js:9126:14
at Timeout._onTimeout user-edit-composer.tsx:606:9
That is an unhandled error, so the job exits 1 with every test passing, on
whichever PR happens to be running rather than on anything related. It reads
exactly like infrastructure noise, which is the reason to fix it rather than
re-run it.
The component already knows about this hazard. Its unmount effect opens with a
comment calling it "the one composer that routinely unmounts", and two of the
other timer callbacks carry defensive try/catch for the composer core being
torn down underneath them. The timers themselves were simply never cancelled;
this cancels them instead of surviving them, which removes the race rather
than tolerating it.
All five sites now go through scheduleTimeout, which records the id and drops
it when the callback runs. The existing unmount effect clears whatever is
left. Two useCallback dep arrays gained scheduleTimeout, which is stable.
One regression test, in the file the CI error originated in: spy on
setTimeout/clearTimeout, submit an edit, unmount, assert the latch id was
cleared. It fails when the clear loop is removed. The 200ms delay is matched
explicitly so unrelated library timers cannot make it pass by accident, and
ids are compared by identity because jsdom under node returns a Timeout object
rather than a number.
Fixes#92462
The diff-lines error-boundary test mocked './syntax-diff' with a factory
that THREW. vitest hoists the factory and registers its module promise in
the mocker registry; a throwing factory leaves rejected promises there,
and under CI load one escapes as "Vitest caught 1 unhandled error during
the test run" attributed to whichever sibling file the worker is running
(user-message-edit.test.tsx in run 32803716726) — an intermittent js-tests
red on green code.
Rework: the factory now resolves to a component that throws the fetch
error during render — the same way React surfaces a rejected lazy payload
— so no rejected promise ever sits in the registry.
Guard proof (sabotage A/B): with the local syntax-diff ErrorBoundary
removed from diff-lines.tsx, the reworked test still fails (workspace
fallback renders), so the #93479 regression pin is intact. Full ui
project: 596 files / 5729 tests green, no unhandled errors in 3 full-run
repetitions.
The overlay window's playVibeHearts() only fires on a reaction forwarded by
burstVibeHearts, so the single gate covers it — these tests pin that so a
future direct caller shows up as a red test.
Floating affection hearts were always on with no off switch. Message
Reactions in Appearance looks related but only gates message-row
tapbacks. Add a separate Vibe Hearts preference (default on) next to it.
A hydrated Ask/clarify row stays complete after session or bot switch, so
the live card never mounts. Re-arm the existing transcript row and keep
the provider tool id instead of appending a duplicate at the tail.
Co-authored-by: frendo <frendo.wu@gmail.com>
Clarify remounted as a tool row once session.info flipped running=false, thinking previews collapsed their body, and the duration line grew the footer.
A URL tab used to be a singleton — every link navigated the one Browser,
so there was no way to keep a page open beside another and the strip's
"+" never appeared next to it.
A Browser tab is now a vessel with its own id: links still land in the
browser you are looking at (an agent opening five pages must not leave
five tabs behind), while the "+" mints another one on request. Tabs name
themselves after the page they are showing, since three tabs reading
"Browser" name nothing.
The "+" itself is now a pane capability rather than a session-only
button, so any pane kind that can make more of itself contributes one.
The unlayered *:focus-visible reset in styles.css intentionally zeroes
--tw-ring-shadow ('No focus rings, anywhere'), so any control that relied
solely on focus-visible:ring-* had no visible keyboard focus state at all.
Mirror each control's hover treatment as a focus-visible background/text
affordance instead, keeping the global reset intact:
- ui/sidebar.tsx: group label, group action, menu button, menu action,
menu sub-button get focus-visible:bg-sidebar-accent + accent foreground
- ui/tabs.tsx: TabsTrigger gets focus-visible:bg-background + text-foreground
- ui/text-tab.tsx: focus-visible:text-foreground (matches its hover)
- chat/composer/micro-actions.tsx: pill gets focus-visible chrome-action-hover
- right-sidebar/index.tsx HEADER_ACTION_CLASS: focus-visible sidebar-accent
- right-sidebar/terminal/rail.tsx RAIL_ACTION: focus-visible chrome-action-hover
- chat/sidebar/cron-jobs-section.tsx (row body + run rows): focus-visible
chrome-action-hover
- chat/sidebar/session-row.tsx <time>: focus-visible:text-foreground
Sweep verified: remaining focus-visible:ring-* usages under apps/desktop/src
already pair with a border/bg/text companion (button/checkbox/switch/input,
starmap share-controls) or are covered by PR #93460's row-hover work
(cron/index.tsx run rows).
Fixes#93462. Reported by @fred0m.