After the slash dispatcher, the next-largest body unit was submitPromptText —
a ~280-line submit pipeline. Lift it into a colocated useSubmitPrompt sub-hook
(use-prompt-actions/submit.ts) with a typed SubmitPromptDeps object; body moves
verbatim. SubmitTextOptions moves to utils.ts (shared by submit + submitText).
Pure restructuring, no behaviour change (full use-prompt-actions suite green).
index.ts: 1,212 -> 937.
The remaining bulk of useMessageStream was handleGatewayEvent — a ~550-line
event-type dispatcher. Lift it into a colocated useGatewayEventHandler sub-hook
(use-message-stream/gateway-event.ts): the values it closed over (sibling
streaming callbacks + the 3 stable refs the deps array omitted + options)
become a typed GatewayEventDeps object; the dispatcher body moves verbatim.
Pure restructuring, no behaviour change (utils tests still green). index.ts:
1,120 -> 540.
The usePromptActions body's largest unit was executeSlashCommand — a ~530-line
`/command` dispatcher. Lift it into a colocated useSlashCommand sub-hook
(use-prompt-actions/slash.ts): the ~13 values it closed over become a typed
SlashCommandDeps object the parent passes in; the dispatcher body (and its inner
runSlash recursion) moves verbatim. SlashActionCtx (slash-only) moves with it.
Pure restructuring, no behaviour change (verified: full use-prompt-actions test
suite still green). index.ts: 1,772 -> ~1,250.
Extract the standalone gateway-event helpers (session-info patch derivation,
completion-error detection, todo-payload routing, delegate_task -> subagent
spec mapping, + the stream-flush/subagent-event constants) out of the
1,285-line hook into a colocated, tested use-message-stream/utils.ts. index.ts
keeps the stateful streaming hook and consumes the helpers.
Pure restructuring, no behaviour change; folder index keeps the import path
intact. index.ts: 1,285 -> ~1,120. Adds unit tests for the pure helpers.
Extract the ~16 standalone helpers (message reconciliation, optimistic/resolved
session upserts, stored-session resolution, runtime-info application, error
classification) out of the 1,254-line god hook into a colocated, tested
use-session-actions/utils.ts. index.ts keeps the hook orchestrator (the
stateful action callbacks) and consumes the helpers.
Pure restructuring, no behaviour change; folder index keeps the import path
(`@/app/session/hooks/use-session-actions`) intact. index.ts: 1,254 -> ~950.
Adds unit tests for the pure helpers.
Within each ring band, split the time-ordered nodes into a few sub-bursts
(~5 nodes each) that share an ignite moment, with a touch of per-node jitter.
The build-up reads as clustered pops instead of a constant single-file trickle
(or an all-at-once flood).
The top-center floating HUDs (command palette + session switcher) pin at
top-3, overlapping the titlebar's `[-webkit-app-region:drag]` bands. Drag
regions win hit-testing over the DOM regardless of z-index, so the top of
each surface — the search input — swallowed clicks, leaving only a ~2px
strip focusable. Add `[-webkit-app-region:no-drag]` to the shared
HUD_SURFACE so the whole surface is interactive.
Extract the copy-pasted "re-resolve on theme repaint" MutationObserver into a
shared hooks/use-theme-epoch (useThemeEpoch + onThemeRepaint) and consume it
from the star map, image-gen placeholder, and useIsDark instead of each hand-
rolling its own root observer. Keeps the post-paint read the canvas probes need
(useTheme() would read stale CSS — child effects run before applyTheme).
Also: light-mode band depth (inner wash), travelling-glow core scramble, and
dark-only timeline bloom.
Finding 2 of the desktop UI-consistency pass. Several surfaces intentionally
make an entire row/cell the click target while hosting nested layout inside a
raw <button> (each re-justifying the pattern in a local comment). Introduce a
zero-style RowButton primitive (components/ui/row-button.tsx) that bakes in the
shared semantics — type="button" + a stable data-slot — without imposing any
styling, then migrate every genuine row-button onto it:
- app/overlays/panel.tsx
- app/artifacts/index.tsx
- app/chat/sidebar/chrome.tsx (SidebarRowBody, SidebarRowLink)
- app/settings/providers-settings.tsx
- components/desktop-onboarding-overlay.tsx (PROVIDER_ROW_CLASS rows)
Fully behavior-preserving: RowButton adds no classes, so each row keeps its
exact layout/look (verified by a unit test asserting className passthrough).
Left as-is (not row-buttons; converting would risk visual regressions): the
compact bespoke buttons in shell/statusbar-controls.tsx (STATUSBAR_ACTION_CLASS,
also a nested DropdownMenuTrigger asChild) and pet-generate/reference-chip.tsx.
Finding 1 of the desktop UI-consistency pass: SVG icon sizing had four
competing conventions with no source of truth. Introduce a named icon-size
scale (iconSize.xs/sm/md/lg/xl -> size-3/3.5/4/5/6) in lib/icons.ts and migrate
the genuine icon deviants onto it:
- desktop-install-overlay.tsx: Loader2/Check/AlertTriangle/Chevron* (h-4 w-4,
h-3.5 w-3.5 -> iconSize.md/sm)
- composer/controls.tsx, voice-activity.tsx, queue-panel.tsx: numeric size={N}
on Tabler icons -> iconSize classes
Sizes snap to the nearest scale step; the only rendered deltas are size={11}
-> 12px (queue/stop glyphs, +1px) and AudioLines size={15} -> 14px (-1px, now
matches its sibling toolbar icons). All other migrations are exact (12/14/16px).
Out of scope (different sizing mechanisms, left untouched): non-icon h-N w-N
layout (sliders, skeletons, swatches), sprite size props (PixelEggSprite), and
Codicon font-icon sizing. Broader size-N -> token adoption is follow-up.
Revert the helix coil to the constellation scatter, biased toward the
midline (triangular vertical), and stop a packed core ring from crushing
every quieter bucket into one invisible speck: sqrt-scale the per-bucket
star count, floor star size to 2px, and lift the dim baseline.
The usePromptActions hook is the textbook "god hook" AGENTS.md warns against.
As a first, safe slice, pull its module-level standalone helpers (no closure
over hook state) into a focused, testable use-prompt-actions-utils.ts sibling:
- error classifiers: isSessionNotFoundError, isSessionBusyError,
isProviderSetupError, inlineErrorMessage
- session-busy retry: withSessionBusyRetry (+ its constants)
- attachment IO: base64FromDataUrl, imageFilenameFromPath,
readImageForRemoteAttach, readFileDataUrlForAttach, friendlyRemoteAttachError
- misc: delay, isSessionIdCandidate, blobToDataUrl, renderCommandsCatalog,
slashStatusText, appendText, visibleUserOrdinal, visibleUserIndexAtOrdinal,
the _submitInFlight guard set, and the GatewayRequest type
Pure restructuring, no behavior change; the usePromptActions and
uploadComposerAttachment exports (and their import paths) are unchanged. Adds
unit tests for the pure helpers. use-prompt-actions.ts: 1,956 -> 1,772.
- Sprite-atlas the orbs: render each (ink, sheen, darken) appearance once,
blit it per node, instead of allocating a radial gradient every frame.
- Split paint into a cached static layer + a live core scramble; the heavy
scene only re-renders on real change, so an idle map costs a scramble +
one drawImage rather than a full redraw.
- Pause the render loop while the window is hidden/blurred; resume on focus.
- Make the scramble's glyph count data-independent (constant cells to the
rim, clamped size) so it's the same field on any graph; size tracks zoom.
- Size nodes against the rested fit (fitScale), held stable through
playback's spore-zoom — so t≈0 no longer balloons orbs into bubbles.
- Wind the timeline constellation along a helix for depth.
DesktopController is a route root that had grown a controller's worth of
session-list plumbing inline. Extract the cohesive fetch/paging cluster into
a focused hook and a tested pure helper, per AGENTS.md's "keep route roots
thin" guidance:
- use-session-list-actions.ts: refreshSessions / loadMoreSessions /
loadMoreSessionsForProfile / loadMoreMessagingForPlatform / refreshCronJobs
(plus the private cron/messaging refreshers, sessionsToKeep, and the
excluded-source constants)
- desktop-controller-utils.ts: pure sameCronSignature helper (+ unit tests)
Pure restructuring, no behavior change. desktop-controller.tsx: 1,441 -> 1,233.
Pull ChatBar's module-level pure helpers, constants, and the QueueEditState
type out of the 2.3k-line composer/index.tsx into a focused, testable
composer-utils.ts sibling:
- constants: COMPOSER_STACK_BREAKPOINT_PX, COMPOSER_SINGLE_LINE_MAX_PX,
COMPOSER_FADE_BACKGROUND, DRAFT_PERSIST_DEBOUNCE_MS
- helpers: pickPlaceholder, COMPLETION_ACTIONS, slashChipKindForItem,
slashArgStage, slashCommandToken, cloneAttachments
- type: QueueEditState
Pure restructuring, no behavior change; adds unit tests for the slash helpers.
(The ChatBar component itself is a single tightly-coupled megacomponent; a
deeper hook-based decomposition is left for a dedicated follow-up.)
Behavior-preserving extraction of the 1,942-line thread.tsx transcript
renderer into co-located sibling modules, matching the existing flat
assistant-ui/ convention:
- thread-content.ts / thread-timestamp.ts: pure helpers (+ unit tests)
- thread-types.ts: shared RestoreMessageTarget
- thread-status.tsx: loading / stall / background-resume indicators
- thread-message-parts.tsx: reasoning + tool part components
- assistant-message.tsx, system-message.tsx, user-message.tsx,
user-edit-composer.tsx: the message renderers
thread.tsx now holds only the Thread route component (1,942 -> 119 lines).
Also drops a dead readAloudAudio module variable (no references).
A top-down Memory Graph panel: memories and skills on a radial time axis
(core = oldest, outer rings = newer) with a playable / scrubbable timeline
that builds the map up over time.
- Reveal lives off the React tree (a ref drives the canvas, a nanostore atom
drives the timeline + legend), so a play-through or scrub never re-renders
the panel; paint is coalesced to one rAF and playback is abortable, so even
frantic scrubbing stays responsive.
- Adaptive dated rings: one equal-width ring per POPULATED calendar bucket,
a "nice-tick" count scaled to the span. Constant (orthographic) core/band
scale — more data grows the disk outward (more rings), never thinner.
- A bucket's nodes fill the band inside their ring and ignite staggered by
real timestamp across it (no end-dump), with an EVE-style warp-in; the
camera steps out band-by-band as rings are reached.
- ASCII "computing" core, theme-aware palette with a distinct memory hue,
shared trackpad-gesture primitives.
- Shareable WoW-style "loadout" codes on a generic, reusable codec
(@/lib/loadout: bitstream + DEFLATE + version/checksum frame + base64url).
- Opens from the statusbar and command palette; i18n across all locales.
Deps: d3-force, fflate (drops unused react-force-graph-2d).
The Cmd-K "Install theme…" palette listed Marketplace themes with no hint
that you already had them, and clicking one re-downloaded + re-installed a
theme you owned. The Appearance settings grid already detected this, but by
parsing theme descriptions inline on every render — plumbing that never made
it to the palette.
Lift it into one reactive source and reuse it everywhere:
- $marketplaceInstalls (computed over $userThemes): extensionId -> installed
theme, derived once via marketplaceIdOf and memoized, instead of rebuilding
a Set per render.
- Both install surfaces now mark owned rows installed and, on click,
re-activate the installed theme rather than re-fetching it.
- Drops the duplicated description-parsing in settings and the per-session
"installed here" state in both surfaces (the store is the source of truth,
so previously-installed themes show correctly too).
DRY: the roomier-side bias computed its probability two ways
(STROLL_TOWARD_ROOM and 1 - STROLL_TOWARD_ROOM). One draw XNOR'd against
the roomier side says the same thing more plainly.
The floating pet wandered almost constantly: every idle beat picked a new
walk and hops fired ~45% of the time, so it read as nervous rather than
alive. Make movement the exception, not the default, and split the
overgrown roam hook into focused modules.
Behavior (per ambient game-AI: GameAIPro ch.36 + idle/wander state
machines):
- Loaf, don't pace: most decision beats just keep resting (REST_CHANCE
0.62) instead of always re-walking.
- Memoryless dwell: pauses now draw from an exponential distribution
(mostly short rests, the occasional long loaf) instead of a uniform
1.8-5.2s window, so the cadence never reads as a metronome.
- Hops dialed back 0.45 -> 0.2 (the jumpiest, noisiest motion).
Structure (no god-file; a hook should own one narrow job):
- roam-behavior.ts - what to do & when (dwellMs, chooseMove,
pickStrollTarget) + tuning. Pure, rng-injectable.
- roam-geometry.ts - where it can stand (snapshotLedges, overlayLedge,
resolveLedge, overlapsX, groundTop). DOM measurement + pure ledge math.
- use-pet-roam.ts - the physics/RAF loop only.
Tests: deterministic, rng-seeded unit coverage for the decision + geometry
helpers (behavior contracts, not snapshots).
When a full-screen route overlay (settings/profiles/cron/agents/command-center) is up, the pet's walkable surface swaps to a single ledge at the overlay card's bottom edge — derived from OverlayView's shared inset, not measured — so it patrols there; closing the overlay restores the normal surfaces and it drops back down.
Walk speed is derived from the sprite's animation loop + on-screen size (one body-width per loop) instead of a fixed px/s, so it steps rather than glides; the pet also sinks a few px so its feet meet the surface instead of hovering.
usePetRoam re-measures ledges from the live DOM each beat and walks/hops/falls between them, driving DOM position imperatively (no per-frame re-render).
Opt-in $petRoam (localStorage), $petMotion (run/jump pose) and $petRoamDir (-1/0/1) feed the shared $petState only while the agent is at rest ($petAtRest), so a wander never overrides real activity.
The Gateway item is the only statusbar entry with variant === 'menu'.
Since da73223f4 wrapped every render branch in `Tip`, the menu branch
nested `<DropdownMenu>` (a Radix Root that renders no DOM node) inside
`Tip`'s `<TooltipTrigger asChild>`. With no element to attach to, Radix
could never wire hover listeners, so the tooltip silently never showed.
`Tip` also can't be moved inside `DropdownMenuTrigger asChild` (the shape
proposed in #54859): it's a plain component, not a Slot-forwarding one, so
the trigger's injected ref/handlers would land on `TooltipContent` instead
of the button and break the menu's click + popper anchoring.
Fix by composing both trigger Slots directly onto a single <button>
(`TooltipTrigger asChild` over `DropdownMenuTrigger asChild`), the pattern
already used in profile-switcher.tsx, and skip the tooltip wrapper entirely
when the item has no title.
Supersedes #54859.
Co-authored-by: wnuuee1 <wnuuee1@users.noreply.github.com>
Subagent session pop-outs (`watch=1`) spectate a run driven elsewhere, so
editing/steering the transcript from there makes no sense. Gate the composer
and the user-bubble mutations on `isWatchWindow()`:
- hide the composer (folds into `showChatBar`)
- user prompts become a read-only button that toggles the 2-line clamp so long
prompts stay fully readable, instead of opening the edit composer
- drop the stop/restore actions and the checkpoint branch-picker
Keyed off the narrow `isWatchWindow()` (not `isSecondaryWindow()`), so the
new-session and cmd-click pop-outs are unaffected.
Let users click the status bar context indicator to see how tokens are
split across system prompt, tools, rules, skills, MCP, and conversation.
Co-authored-by: Cursor <cursoragent@cursor.com>