Skip to content

feat(studio): rebuild global search on cmdk and align the shell with the prototype - #2228

Merged
kantord merged 9 commits into
mainfrom
feat/studio-shell-search-2205
Oct 14, 2026
Merged

kantord merged 9 commits into
mainfrom
feat/studio-shell-search-2205

Conversation

@peppescg

@peppescg peppescg commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

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.tsx now renders through CommandDialog, CommandInput, CommandList, CommandGroup, CommandItem, and CommandEmpty, with the prototype's palette structure.
  • Results come from a SearchProvider, the prototype's provider shape (search-types.ts). It's backed by Studio's ranked index (createGlobalSearchProvider in search-index.ts). The prototype's search-static.ts isn'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.
  • cmdk's own filter is off (shouldFilter={false}), and so are its vim bindings. Ctrl+K still toggles the palette closed.
  • These are kept unchanged:
    • the pre-open identity check and the account-scoped inventory cache keys, including eviction on sign-out
    • the help-only mode
    • shortcut suppression and the open shortcuts
    • ranking and contents, and the navigation targets
    • focus restore, both to the invoker and to the destination heading
    • the IME composition guard and the touch-scroll guard
    • the live status footer and the 44px close control
    • the keyboard-aware full-screen layout on phones

Shared primitive fixes (components/ui/command.tsx)

  • CommandDialog now renders its title and description inside DialogContent. Before, a closed palette left a stray screen-reader-only title in the page, next to the trigger. It also accepts description={null}, which leaves the dialog without aria-describedby as before, and it forwards commandProps and contentProps.
  • CommandInput gains icon and trailing slots. It also fixes a cmdk 1.1.1 bug: cmdk reads the selected option's id before an automatic selection has rendered, so aria-activedescendant is 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

  • From 500px, the nav uses the prototype's spacing and its search breakpoint. The trigger becomes a "Search…" field from 500px, up to 214px wide, instead of from 900px. The ⌘K keycap appears once the field has room for it.
  • The field shrinks when the row is tight, so the nav never scrolls sideways. Every target keeps Studio's 44px minimum and its offset focus rings.

Banners

  • Connection band: the message and actions are now centred, with the prototype's spacing. The actions take the prototype's outlined strip-button look, drawn in the band's own warning tokens so text and focus rings keep their contrast. PopupFallback accepts a className for this. The states, the messages, popup sign-in, retry, and the popup fallback are all unchanged.
  • Storage health (new): this is the prototype's degraded-store notice, shown in the shell's transient band above the nav. It only uses what the BFF already serves: the runtime storageHealth capability and GET /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.
  • Workspace trust: not ported. See "Gaps".

PWA share target

  • manifest.webmanifest gains share_target, a GET to /workspace/chat.
  • The prototype maps text, title, and url to prompt, title, and url. Studio's chat seed (chat-seed.ts) only consumes prompt and send, so the manifest maps only text to prompt. Shared text goes through the existing seed handling unchanged:
    • control characters stripped
    • capped at 32 Ki UTF-16 code units without splitting a surrogate pair
    • prefilled and never sent
    • removed from the address bar
  • No unconsumed title or url parameter is left behind.

Docs

user-docs/operating/studio.md documents the share target and the storage notice.

Differences from the prototype

  • Palette position: the palette stays anchored near the top (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.
  • Close control: the palette keeps Studio's inline 44px "Close search" control instead of the prototype's small floating close button.
  • Status footer: the palette keeps Studio's live status footer and its key hints. The prototype has no footer, and the footer's aria-live region announces result counts, loading, and partial failures.
  • Empty states: they keep Studio's copy ("No results for “query”" and the empty-query prompt) in the prototype's CommandEmpty slot and spacing. An empty query still shows a prompt, not every entry.
  • Input text size: the input stays at 16px on phones so iOS doesn't zoom on focus. From 640px it uses the prototype's 14px.
  • Home and End: these stay caret keys in the text field, as in Studio. cmdk would otherwise jump to the first or last result.
  • Accessible name: cmdk's root label repeats the input's existing name, because cmdk labels its input through aria-labelledby. Screen readers hear the same name as before.
  • Phone spacing in the top nav: below 500px the nav keeps Studio's tighter gaps. Six 44px targets at the 18px mobile root size leave no room for the prototype's 12px gaps. Studio also keeps 44px targets everywhere, where the prototype uses 36px. It doesn't use the prototype's overflow-x-auto nav, which clips focus rings, and it keeps min-h-16 instead of h-16 so the bar can grow with text zoom.
  • Connection band: keeps min-h-11, Studio's warning and warning-foreground tokens, and its icon.
  • Storage notice colours and role: the notice uses Studio's warning tokens instead of the prototype's amber tint, which lacks contrast on the dark shell gradient. It uses role="status", like Studio's band, instead of role="alert", so a notice present at page load doesn't interrupt the screen reader.
  • Share target: maps only text to prompt, as explained above.
  • Not ported: use-oidc-popup.ts (Studio checks the attempt id and event.source), a public /runtime route, and the prototype's theme and palette modules.

Visible change

  • Search palette:
    • 32rem wide (was 42rem), with rounded-lg, a border, and shadow-lg
    • 48px input row with a 16px muted icon
    • sentence-case 12px section headings (were uppercase 11px)
    • rows with a plain 20px icon and no bordered icon box, no hover arrow, and a regular-weight title
    • tighter empty-state spacing
    • a 12px footer
    • on desktop the results list is capped at 300px and scrolls after about 3.5 rows, as in the prototype (CommandList max-h-[300px], lifted below 500px); phones show every result
  • Search trigger:
    • "Search…" in 14px (was "Search" in 12px)
    • one ⌘K chip instead of two keycaps
    • the field appears from 500px (was 900px), and the keycap at about 640px (was 1100px)
    • 16px icon on desktop (was 17px); on phones size-4 follows the 18px root font size
  • Top nav: from 500px, a 20px gap before the search field and 4px between nav items (were 12px and 2px from 500px to 900px). Signed out, the empty search slot collapses, so the nav items sit flush with the page gutter (before, an empty wrapper left a 20px trailing gap).
  • Connection band: content centred, and outlined action buttons instead of filled pills.
  • New: the storage-health notice, and Studio as a share target in installed PWAs.
  • Pointer highlight: the result highlight follows the pointer through pointermove (cmdk), so a touch drag over results also moves it. A tap that scrolled still never opens a result.

Gaps

  • Workspace-trust banner: the prototype's version renders null, and the BFF has no trust-state or trust-grant route. inspection.ts's trusted flag is per instruction file inside a session inspection, and nothing serves workspaceEnrollment. Nothing to port without a new BFF route, which is out of scope.
  • Storage detail: the prototype's unavailableReason and lastFailureDetail aren't in Studio's storage-health schema, so the notice uses Settings → Storage's generic detail sentences.
  • Top nav at 320px, pre-existing: below 336px, six 44px targets at the 18px mobile root size overlap the logo, on feat(studio): add the @/ alias and port the prototype's foundation primitives and utilities #2225 as well. This change doesn't make it worse, and the page still doesn't scroll sideways at 320px. It should be tracked separately.
  • Shared title and url: a title or URL that the sharing app sends separately from the text isn't included. Supporting that would need a chat-seed change.

No regression

Pinning tests

global-search.pinning.test.tsx was 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:

  • result ranking and section order for a fixed index, with exact row contents
  • the dialog, combobox, listbox, and group names, plus aria-autocomplete, aria-expanded, aria-controls, one aria-selected option, and aria-activedescendant on the first result
  • the help-only combobox name
  • the keyboard: arrows stop at both ends, Home and End aren't default-prevented and don't move the highlight, and Enter without a result does nothing
  • the active descendant clearing when the results disappear
  • Ctrl/⌘+K toggling the palette closed, with focus restored to the previously focused element, and the same after Escape
  • the navigation target of every result kind: chat, schedule, configured skill, learned skill, memory, About, and shortcuts

Existing 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.ts runs 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 as pointermove instead of mousemove, 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 new share_target, which this issue asks for. A new test builds the URL a browser would and checks that consumeChatSeed sanitizes, caps, and consumes it.
  • workspace-shell.test.tsx: renderShell gains optional phase and query-client parameters, with unchanged defaults, and a new test places the storage notice in the transient band.
  • Additions only:
    • command.test.tsx: the title stays inside the dialog, the props are forwarded, and the active descendant follows automatic selection
    • search-index.test.ts: the provider
    • connection-status-banner.test.tsx: the band keeps the warning contrast tokens
    • storage-health-banner.test.tsx: new
  • Unchanged:
    • top-nav.test.tsx: the not.toContain("min-[500px]:w-[214px]") assertion still holds, because the field uses a flexible max-w. The browser journey now covers the intent behind it: no overflow or overlap.
    • search-keyboard.test.ts: clampActiveIndex, moveActiveIndex, and shouldActivateResult aren'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. 09a5ae559 fixes both, and each fix has a test that fails without it:

  • No results: global-search.tsx passed className to CommandEmpty, which spreads props after its own classes. The slot lost py-6 text-center text-sm and rendered unpadded and left-aligned, touching the left edge on phones. The colour now sits on an inner span, and command.tsx stays identical to the prototype.
  • Signed-out nav: GlobalSearch renders nothing without a session, but the nav's search wrapper still reserved up to 214px. The wrapper now collapses with empty:hidden.

Gate counts

Both branches sit on origin/main aa1979c1d, which includes #2222.

Gate #2225 (db98c3768) This branch
Biome 408 files 413 files
Server 24 files / 187 tests 24 files / 187 tests
Web 123 files / 769 tests 125 files / 799 tests
Browser 54 60

The global-search.test.tsx race 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, with matlatl --strict reporting 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 returns 401 session_expired, the popup-blocked band from a stubbed window.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.

Empty query, desktop light

Dark and mobile

Desktop, dark:

Empty query, desktop dark

Mobile, light:

Empty query, mobile light

Mobile, dark:

Empty query, 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.

With results ("re"), desktop light

Dark and mobile

Desktop, dark:

With results ("re"), desktop dark

Mobile, light:

With results ("re"), mobile light

Mobile, dark:

With results ("re"), 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.

No results, desktop light

Dark and mobile

Desktop, dark:

No results, desktop dark

Mobile, light:

No results, mobile light

Mobile, dark:

No results, 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-4 follows the phone's 18px root font size, so it renders at 18px (was a fixed 17px).

Top nav at 1280px (and 390px on mobile), desktop light

Dark and mobile

Desktop, dark:

Top nav at 1280px (and 390px on mobile), desktop dark

Mobile, light:

Top nav at 1280px (and 390px on mobile), mobile light

Mobile, dark:

Top nav at 1280px (and 390px on mobile), 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.

500px wide, desktop light

Dark

Desktop, dark:

500px wide, desktop dark

640px wide

Intended change: the "Search…" field with its ⌘K chip (was an icon button).

640px wide, desktop light

Dark

Desktop, dark:

640px wide, desktop dark

768px wide

Intended change: the "Search…" field with its ⌘K chip (was an icon button).

768px wide, desktop light

Dark

Desktop, dark:

768px wide, 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.

900px wide, desktop light

Dark

Desktop, dark:

900px wide, desktop dark

1100px wide

Intended change: one ⌘K chip instead of two keycaps, "Search…" in 14px, and nav items 4px apart.

1100px wide, desktop light

Dark

Desktop, dark:

1100px wide, 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.

Session expired, desktop light

Dark and mobile

Desktop, dark:

Session expired, desktop dark

Mobile, light:

Session expired, mobile light

Mobile, dark:

Session expired, 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.

Popup blocked, desktop light

Dark and mobile

Desktop, dark:

Popup blocked, desktop dark

Mobile, light:

Popup blocked, mobile light

Mobile, dark:

Popup blocked, mobile dark

Daemon unavailable

Intended change: the message is centred; the nav differs as in the top nav above.

Daemon unavailable, desktop light

Dark and mobile

Desktop, dark:

Daemon unavailable, desktop dark

Mobile, light:

Daemon unavailable, mobile light

Mobile, dark:

Daemon unavailable, 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.

Degraded store, desktop light

Dark and mobile

Desktop, dark:

Degraded store, desktop dark

Mobile, light:

Degraded store, mobile light

Mobile, dark:

Degraded store, 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-4 follows the phone's 18px root font size, so it renders at 18px (was a fixed 17px).

Chat workspace, desktop light

Dark and mobile

Desktop, dark:

Chat workspace, desktop dark

Mobile, light:

Chat workspace, mobile light

Mobile, dark:

Chat workspace, 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, and public/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.

  • Enter on "Close search" opened a result (major). The close button sits in the cmdk input's trailing slot, so cmdk's root keydown handler took Enter, prevented the button's click, and chose the highlighted result: the page navigated and focus never came back to the trigger. CommandInput now 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's command.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)
  • Long shared text got a blank 431 (minor). The share target is a GET, so shared text travels in the /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)
    • Why not a redirect: when oversized cookies cause the overflow, a redirect would loop.
    • Why not a larger 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.
    • Why not a POST share target: it would need a service worker or a cross-site form POST, and SameSite cookies block the latter.
    • Docs: the Studio operating page now documents the limit: about 14,000 characters of English text, about 1,600 of Chinese or Japanese, and possibly less behind an ingress.
Gate Before After
task studio:check passed (web 832, server 187 tests) passed (web 834, server 190 tests)
task studio:browser 60 passed 62 passed
task docs checks passed passed
Oversized share link, before and after (desktop light, mobile dark)

Oversized share link, desktop light
Oversized share link, 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

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
peppescg force-pushed the feat/studio-shell-search-2205 branch from 9c6e197 to b68f643 Compare October 9, 2026 15:33
@peppescg
peppescg changed the base branch from feat/studio-foundation-2204 to feat/studio-foundation-2204b October 9, 2026 15:33
@peppescg
peppescg added this pull request to stack #2227 October 9, 2026 15:33
@kantord
kantord force-pushed the feat/studio-shell-search-2205 branch from 17426f9 to 1bf9bfc Compare October 13, 2026 08:42
Base automatically changed from feat/studio-foundation-2204b to main October 13, 2026 10:54
@kantord
kantord force-pushed the feat/studio-shell-search-2205 branch from 1bf9bfc to de910f8 Compare October 13, 2026 10:54
peppescg and others added 9 commits October 14, 2026 10:21
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>
@kantord
kantord merged commit cee52d7 into main Oct 14, 2026
31 checks passed
@kantord
kantord deleted the feat/studio-shell-search-2205 branch October 14, 2026 08:59
@kantord

kantord commented Oct 14, 2026

Copy link
Copy Markdown
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.

This branch was successfully deployed

1 active deployment
Preview — 38449722 Deployed Oct 14, 2026 by vercel[bot]
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.

2 participants