Repository navigation
feat(studio): rebuild global search on cmdk and align the shell with the prototype - #2228
Merged
Merged
Conversation
peppescg
added a commit
that referenced
this pull request
Oct 9, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
peppescg
force-pushed
the
feat/studio-shell-search-2205
branch
from
October 9, 2026 15:33
9c6e197 to
b68f643
Compare
peppescg
changed the base branch from
feat/studio-foundation-2204
to
feat/studio-foundation-2204b
October 9, 2026 15:33
peppescg
added this pull request to stack #2227
October 9, 2026 15:33
kantord
force-pushed
the
feat/studio-shell-search-2205
branch
from
October 13, 2026 08:42
17426f9 to
1bf9bfc
Compare
kantord
force-pushed
the
feat/studio-shell-search-2205
branch
from
October 13, 2026 10:54
1bf9bfc to
de910f8
Compare
kantord
approved these changes
Oct 14, 2026
Add markup-independent pins for the palette: result ranking and section order for a fixed index, the accessible roles, names, and relationships, the keyboard model (clamped arrows, Home and End left to the text field, Enter without a result), the open shortcut toggling closed with focus restore, and each result kind's navigation target. They pass on the current hand-rolled combobox and must keep passing on the rebuild. Refs #2205 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…otype
Global search now renders through the shared cmdk CommandDialog with the
prototype's palette structure: a 48px input row, plain section headings,
icon and two-line result rows, the shadcn empty state, and the prototype's
dialog width. The trigger is styled for a light surface, as in the
prototype, and becomes a labelled field with a single keycap once it is
wide enough. Results come from a SearchProvider, the prototype's provider
shape, backed by Studio's ranked index.
Studio's behaviour is unchanged: the auth-session-aware index and its
pre-open identity check, shortcut suppression, the open shortcuts, ranking
and contents, navigation targets, focus restore, the IME composition guard,
the touch-scroll guard, the live status footer, the 44px close control,
and the keyboard-aware full-screen layout on phones. cmdk's vim bindings
are off so Ctrl+K still toggles the palette, and Home and End stay caret
keys.
The shared primitives gain what the palette needs. CommandDialog renders
its title inside the dialog, so a closed palette leaves no stray title in
the page, accepts description={null}, and forwards commandProps and
contentProps. CommandInput takes an icon and a trailing slot, and repairs
cmdk 1.1.1's aria-activedescendant, which is missing or stale after an
automatic selection.
Refs #2205
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
From 500px the top nav uses the prototype's spacing and its search breakpoint: the trigger becomes a search field there, up to 214px wide, instead of from 900px. The field shrinks when the row is tight, so the nav never scrolls sideways and no target drops below 44px. Phones keep Studio's tighter gaps, because six 44px targets at the 18px mobile root size leave no room for the prototype's 12px gaps. A browser journey covers the palette end to end in desktop and mobile Chromium (keyboard selection with the announced active option, Escape focus restore, Ctrl+K, navigation, viewport fit, the 44px close control) and checks every top-nav target from 336px to 1280px. Refs #2205 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The global connection and sign-in band now centres its message and actions with the prototype's spacing, and its actions take the prototype's outlined strip-button look. They are drawn in the band's own warning tokens, so the text and the focus rings keep the band's contrast. PopupFallback accepts a className for this. Behaviour is unchanged: the same states, messages, popup sign-in, retry, and popup fallback, still driven by useAuthRecovery in the global status slot, with the 44px minimum band height. Refs #2205 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Port the prototype's degraded-store banner. When the agent cannot read its saved chats, some can no longer be opened, or an automatic clean-up failed, the shell's transient band says that chats may be missing, with Settings → Storage's own detail line. It uses only what the BFF already serves: the runtime's storageHealth capability and /api/v1/storage/health, the queries Settings already makes. It requests nothing until the identity is verified, the agent is online, and it reports storage health, and renders nothing for a healthy or unsupported store. It uses Studio's warning tokens rather than the prototype's amber tint, which lacks contrast on the shell gradient. The prototype's workspace-trust banner renders nothing there either, and the BFF has no trust state or grant route, so it is not ported. Refs #2205 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Add the prototype's manifest share_target: a GET to /workspace/chat. The prototype maps text, title, and url to prompt, title, and url, but Studio's chat seed consumes only prompt, so the manifest requests only the shared text, as prompt. Shared text therefore goes through the existing seed handling unchanged: control characters stripped, capped at 32 KiB without splitting a surrogate pair, prefilled and never sent, and removed from the address bar. No title or url parameter is left behind for the chat route to ignore. The install test now pins the share target instead of its absence, and a new test builds the URL a browser would from the manifest and checks that consumeChatSeed turns it into a sanitized draft and consumes it. The Studio operating page documents the share target and the storage notice. Refs #2205 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…v flush right The before/after screenshots for this PR caught two layout regressions. - No results: global-search passed className to CommandEmpty, which spreads props after its own className, so "text-muted-foreground" replaced the slot's py-6 text-center text-sm. The text sat unpadded and left-aligned at 16px, touching the left edge on phones. The colour now goes on an inner span, and command.tsx stays identical to the prototype. - Signed out: GlobalSearch renders nothing, but the nav's search wrapper still reserved up to 214px, leaving a blank strip on the right of the nav. The wrapper now collapses with empty:hidden. Each fix has a test that fails without it. Refs #2205 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Enter on the focused "Close search" button reached cmdk's root keydown handler, which prevents the default (so the button's click never fired) and chose the highlighted result instead. The palette navigated away and focus never returned to the search trigger. CommandInput now wraps its trailing slot in a layout-free boundary that stops Enter from bubbling to cmdk. Every other key, including Tab for the focus trap, Escape, and the app's shortcuts, still bubbles, and Enter in the field still chooses the highlighted item. Regression tests (each fails without the fix): a command primitive test, a palette behaviour pin that checks the route and focus restore, and a desktop and mobile browser journey. Refs #2205 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The share target is a GET, so the browser puts shared text in the /workspace/chat?prompt= address. Past Node's 16 KiB limit on the request line and headers, the parser rejects the request before Hono sees it and Node answers with an empty 431, which a phone shows as a blank error. Studio's server now answers header overflow with a short HTML page that keeps the 431 status, says the shared text was too long, and links to a new chat. It echoes nothing from the request, loads nothing (CSP default-src 'none'), is not cached, and closes the connection. It does not redirect: when oversized cookies cause the overflow, a redirect would loop. Other parser errors keep Node's own 400, 408, and 413 responses. maxHeaderSize stays at Node's default. Raising it would not remove the limit (32 Ki code units of seed can encode to about 290 KB), would let every pre-auth connection buffer more, and would not help behind an ingress that refuses long addresses first. A POST share target would need a service worker or a cross-site form POST, which SameSite cookies block. The Studio operating page documents the limit and the page. Refs #2205 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
peppescg
force-pushed
the
feat/studio-shell-search-2205
branch
from
October 14, 2026 08:44
de910f8 to
3844972
Compare
kantord
approved these changes
Oct 14, 2026
Member
|
Post-merge review follow-ups for this PR. No blockers or regressions found. Items worth tracking were filed here:
Other candidate findings were checked and dropped as not worth tracking. Fully or partially written by an AI agent. |
2 of 11 tasks
Merged
2 tasks
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This is the shell and global search slice of the Studio alignment (#2205, part of #2210). It's part of a GitHub stack: #2225 → #2226 → this PR → #2229. Review and merge from the bottom. Global search now runs on the shared cmdk palette with the prototype's structure. The top nav uses the prototype's layout and search breakpoint, the connection band takes the prototype's strip look, and the shell gains the prototype's storage-health notice. The PWA manifest also gains the prototype's share target.
Studio's behaviour stays the same. That covers the auth-session-aware search index, shortcut suppression, keyboard and focus handling, the auth-recovery band, and chat-seed sanitization. The "No regression" section below has the proof.
What changed
Global search on cmdk
features/search/global-search.tsxnow renders throughCommandDialog,CommandInput,CommandList,CommandGroup,CommandItem, andCommandEmpty, with the prototype's palette structure.SearchProvider, the prototype's provider shape (search-types.ts). It's backed by Studio's ranked index (createGlobalSearchProviderinsearch-index.ts). The prototype'ssearch-static.tsisn't ported: it filters in insertion order and lists every entry on an empty query, which would change Studio's ranking and its empty-query prompt.shouldFilter={false}), and so are its vim bindings. Ctrl+K still toggles the palette closed.Shared primitive fixes (
components/ui/command.tsx)CommandDialognow renders its title and description insideDialogContent. Before, a closed palette left a stray screen-reader-only title in the page, next to the trigger. It also acceptsdescription={null}, which leaves the dialog withoutaria-describedbyas before, and it forwardscommandPropsandcontentProps.CommandInputgainsiconandtrailingslots. It also fixes a cmdk 1.1.1 bug: cmdk reads the selected option's id before an automatic selection has rendered, soaria-activedescendantis missing for the first result, or names the previous option after the results change. The input now repoints the input and the list at the option that is actually selected.Top nav
Banners
warningtokens so text and focus rings keep their contrast.PopupFallbackaccepts aclassNamefor this. The states, the messages, popup sign-in, retry, and the popup fallback are all unchanged.storageHealthcapability andGET /api/v1/storage/health, the same queries Settings → Storage makes. It requests nothing until the identity is verified (phase === "ready"), the agent is online, and the agent reports storage health. The detail line reuses Settings → Storage's wording.PWA share target
manifest.webmanifestgainsshare_target, aGETto/workspace/chat.text,title, andurltoprompt,title, andurl. Studio's chat seed (chat-seed.ts) only consumespromptandsend, so the manifest maps onlytexttoprompt. Shared text goes through the existing seed handling unchanged:titleorurlparameter is left behind.Docs
user-docs/operating/studio.mddocuments the share target and the storage notice.Differences from the prototype
top-[max(4rem,12dvh)]) instead of centred. Studio fetches inventories when the palette opens, while the prototype keeps that data warm, so a centred dialog would jump as results stream in.aria-liveregion announces result counts, loading, and partial failures.CommandEmptyslot and spacing. An empty query still shows a prompt, not every entry.aria-labelledby. Screen readers hear the same name as before.overflow-x-autonav, which clips focus rings, and it keepsmin-h-16instead ofh-16so the bar can grow with text zoom.min-h-11, Studio'swarningandwarning-foregroundtokens, and its icon.warningtokens instead of the prototype's amber tint, which lacks contrast on the dark shell gradient. It usesrole="status", like Studio's band, instead ofrole="alert", so a notice present at page load doesn't interrupt the screen reader.texttoprompt, as explained above.use-oidc-popup.ts(Studio checks the attempt id andevent.source), a public/runtimeroute, and the prototype's theme and palette modules.Visible change
rounded-lg, a border, andshadow-lgCommandListmax-h-[300px], lifted below 500px); phones show every resultsize-4follows the 18px root font sizepointermove(cmdk), so a touch drag over results also moves it. A tap that scrolled still never opens a result.Gaps
null, and the BFF has no trust-state or trust-grant route.inspection.ts'strustedflag is per instruction file inside a session inspection, and nothing servesworkspaceEnrollment. Nothing to port without a new BFF route, which is out of scope.unavailableReasonandlastFailureDetailaren't in Studio's storage-health schema, so the notice uses Settings → Storage's generic detail sentences.No regression
Pinning tests
global-search.pinning.test.tsxwas committed first, alone, and passed on the old hand-rolled combobox (14 tests). It passes unchanged on the cmdk palette. It only queries roles, ARIA relationships, and accessible names, and it covers:aria-autocomplete,aria-expanded,aria-controls, onearia-selectedoption, andaria-activedescendanton the first resultExisting tests
All existing search tests keep asserting the same behaviour. There are 29 in
global-search.test.tsx, plus the routing and HTTP-cache tests, and they cover auth scoping, cache eviction, the pre-open identity check, the 401 retry, IME, touch, focus trap, focus restore, and the live region.Browser journey
e2e/global-search.spec.tsruns in desktop and mobile Chromium. It covers keyboard selection with the announced active option, Escape focus restore, Ctrl/⌘+K, navigation, viewport fit, and the 44px close control. It also checks every top-nav target for 44px size, no overlap, and no horizontal scroll from 336px to 1280px. All existing auth-recovery journeys pass.Modified tests
global-search.test.tsx, in "restores focus after Escape and keeps pointer hover separate from activation": the hover is now dispatched aspointermoveinstead ofmousemove, because cmdk follows pointer events. A real mouse hover fires both. The assertions are unchanged.install-manifest.test.ts:expect(manifest).not.toHaveProperty("share_target")becomes an exact pin of the newshare_target, which this issue asks for. A new test builds the URL a browser would and checks thatconsumeChatSeedsanitizes, caps, and consumes it.workspace-shell.test.tsx:renderShellgains optionalphaseand query-client parameters, with unchanged defaults, and a new test places the storage notice in the transient band.command.test.tsx: the title stays inside the dialog, the props are forwarded, and the active descendant follows automatic selectionsearch-index.test.ts: the providerconnection-status-banner.test.tsx: the band keeps the warning contrast tokensstorage-health-banner.test.tsx: newtop-nav.test.tsx: thenot.toContain("min-[500px]:w-[214px]")assertion still holds, because the field uses a flexiblemax-w. The browser journey now covers the intent behind it: no overflow or overlap.search-keyboard.test.ts:clampActiveIndex,moveActiveIndex, andshouldActivateResultaren't called by the palette any more, because cmdk owns arrows and Enter. They and their tests stay, so no assertion is deleted. They can be removed in a follow-up.Layout regressions caught by the screenshots
The before/after screenshots below caught two regressions in an earlier revision.
09a5ae559fixes both, and each fix has a test that fails without it:global-search.tsxpassedclassNametoCommandEmpty, which spreads props after its own classes. The slot lostpy-6 text-center text-smand rendered unpadded and left-aligned, touching the left edge on phones. The colour now sits on an inner span, andcommand.tsxstays identical to the prototype.GlobalSearchrenders nothing without a session, but the nav's search wrapper still reserved up to 214px. The wrapper now collapses withempty:hidden.Gate counts
Both branches sit on
origin/mainaa1979c1d, which includes #2222.db98c3768)The
global-search.test.tsxrace fix from #2225 (db98c3768) holds on the cmdk palette: the search suite passed 8/8 runs on a loaded machine.Test evidence
task studio:check: passed. Biome is clean, typecheck is clean, and the generated-artifact check is clean.task studio:browser: 60 passed, on desktop and mobile Chromium.task docs: passed, withmatlatl --strictreporting 0 broken links.Screenshots
Captured with a throwaway Playwright journey (Chromium, not committed) with offline BFF routes, a fixed clock (2026-09-25 12:30 UTC),
Europe/Rome, and en-US. Each surface was shot at desktop 1280×800 and mobile 390×844, in light and dark; the top nav was also shot at 500, 640, 768, 900 and 1100px wide. The session-expired band comes from a send that returns401 session_expired, the popup-blocked band from a stubbedwindow.open, and the storage notice from a storage-health response with two unreadable items. Before is #2225's head (db98c3768), after is this PR's head (09a5ae559). They were captured before the stack was linearised onto #2226. That rebase was conflict-free, and #2226's changes (card, table, sortable head, tooltip, input) touch none of these surfaces. The gates were rerun on the rebased head. Each tree was captured twice, and the two runs matched pixel for pixel apart from 4 px of anti-aliasing in one before shot. Every pair was pixel-compared; red boxes mark the regions that differ. Desktop composites are downscaled to 1600px wide.Search palette (cmdk)
Empty query
Intended change: the 32rem cmdk palette with a 48px input row, a tighter prompt, and a 12px footer; the prompt and footer copy are unchanged, and mobile stays full screen.
Dark and mobile
Desktop, dark:
Mobile, light:
Mobile, dark:
With results ("re")
Intended change: sentence-case 12px headings, plain 20px icons with no box or hover arrow, and regular-weight titles, with the same six results in the same order. The results list scrolls past about 3.5 rows on desktop (300px), as in the prototype; mobile shows all.
Dark and mobile
Desktop, dark:
Mobile, light:
Mobile, dark:
No results
Intended change: "No results for “zzzz-nothing”" stays centred and padded in the prototype's tighter empty slot, above the 12px footer.
Dark and mobile
Desktop, dark:
Mobile, light:
Mobile, dark:
Top nav
Top nav at 1280px (and 390px on mobile)
Intended change: "Search…" in 14px with one ⌘K chip at the field's right edge (was "Search" with two keycaps), and nav items 4px apart (was 2px). On mobile only the search icon differs:
size-4follows the phone's 18px root font size, so it renders at 18px (was a fixed 17px).Dark and mobile
Desktop, dark:
Mobile, light:
Mobile, dark:
500px wide
Intended change: the trigger becomes a "Search…" field (was an icon button below 900px), with a 20px gap before it and 4px between nav items; no keycap yet.
Dark
Desktop, dark:
640px wide
Intended change: the "Search…" field with its ⌘K chip (was an icon button).
Dark
Desktop, dark:
768px wide
Intended change: the "Search…" field with its ⌘K chip (was an icon button).
Dark
Desktop, dark:
900px wide
Intended change: "Search…" in 14px with the ⌘K chip (was "Search" in 12px with no keycap), and nav items 4px apart.
Dark
Desktop, dark:
1100px wide
Intended change: one ⌘K chip instead of two keycaps, "Search…" in 14px, and nav items 4px apart.
Dark
Desktop, dark:
Auth-recovery band
Session expired
Intended change: the message and Sign in are centred, with an outlined strip button instead of a filled pill. Signed out there is no search trigger on either tree; the nav items now end at the page gutter, 20px further right on desktop and about 5px on mobile, because the empty search slot collapses.
Dark and mobile
Desktop, dark:
Mobile, light:
Mobile, dark:
Popup blocked
Intended change: Sign in, Retry sign-in and Open sign-in in a new tab are centred outlined strip buttons (wrapping on mobile); the nav is as in Session expired.
Dark and mobile
Desktop, dark:
Mobile, light:
Mobile, dark:
Daemon unavailable
Intended change: the message is centred; the nav differs as in the top nav above.
Dark and mobile
Desktop, dark:
Mobile, light:
Mobile, dark:
Storage-health notice (new)
Degraded store
New: the notice above the nav in the band's warning tokens. Before has no notice. Phones show only the headline, and the detail line ("2 saved items can no longer be opened.") appears from 640px.
Dark and mobile
Desktop, dark:
Mobile, light:
Mobile, dark:
Full workspace
Chat workspace
Intended change: only the top nav differs, as above; the chat, history and composer are identical. On mobile only the search icon differs:
size-4follows the phone's 18px root font size, so it renders at 18px (was a fixed 17px).Dark and mobile
Desktop, dark:
Mobile, light:
Mobile, dark:
The prototype reference code is on
stack-08:apps/web/src/features/search/global-search.tsx,components/shell/top-nav.tsx,features/runtime/{auth-recovery,storage-health,workspace-trust}-banner.tsx, andpublic/manifest.webmanifest.Review fixes
Fixes from the final regression and security review of the stack (stack #2227). Each one comes with a test that fails without the fix.
CommandInputnow wraps the trailing slot in a layout-free (display: contents) boundary that stops Enter only. Tab (focus trap), Escape, and app shortcuts still bubble, and Enter in the field still chooses the highlighted item. This is a deliberate difference from the prototype'scommand.tsx, which has the same defect. Regression tests, each failing without the fix: a command primitive test, a palette behaviour pin (no navigation, focus back on the trigger), and a desktop and mobile browser journey. No visible change. (f1c4be60d)/workspace/chat?prompt=address. Past Node's 16 KiB limit on the request line and headers, Node rejects the request before the app runs and sends an empty 431. Studio's server now answers header overflow with a short page that keeps the 431 status, says the shared text was too long, and links to a new chat. It echoes nothing from the request, loads nothing (default-src 'none'), isn't cached, and closes the connection. Other parser errors keep Node's own 400, 408, and 413. (38449722d)maxHeaderSize: a larger limit wouldn't remove the problem, because 32 Ki code units of seed can encode to about 290 KB. Every pre-auth connection could also buffer more, and an ingress that refuses long addresses first (ingress-nginx stops at 8 KB by default) would still reject the share.task studio:checktask studio:browsertask docschecksOversized share link, before and after (desktop light, mobile dark)
The prototype has no server, so there is no prototype equivalent of this page.
Re-verification (2026-10-11)
Re-verified at this head: no code change needed. Enter on Close search still closes the palette without opening a result, here and at the stack top. "What changed" now says the seed cap is 32 Ki UTF-16 code units, as the code counts it, not 32 KiB.
Refs #2205
🤖 Generated with Claude Code