Skip to content

feat(search): tenant-scoped search across desktop and mobile#1249

Open
yahyafakhroji wants to merge 7 commits into
mainfrom
search
Open

feat(search): tenant-scoped search across desktop and mobile#1249
yahyafakhroji wants to merge 7 commits into
mainfrom
search

Conversation

@yahyafakhroji
Copy link
Copy Markdown
Contributor

@yahyafakhroji yahyafakhroji commented May 13, 2026

Summary

Wires cloud-portal to the tenant-scoped search API from milo-os/search#92. All search is project-scoped (GCP-style). Two surfaces share one behavior engine:

  • Desktop: always-visible project search bar in the project layout chrome. ⌘K / Ctrl K focuses the input.
  • Mobile + tablet: full-screen search sheet, opened via the header search icon.

Every surface shows a footer "Searching in [project name]" so the user always knows what they're searching inside. When there's no active project context, the search opens in a disabled state with "Open a project to search."

Supported project resources

The search currently covers these project-scoped kinds (defined in PROJECT_KINDS at app/resources/search/search.constants.ts):

Kind API group UI label
Domain networking.datumapis.com Domain
HTTPProxy networking.datumapis.com AI Edge
DNSZone dns.networking.miloapis.com DNS
ExportPolicy telemetry.miloapis.com Metrics

Each kind requires a matching ResourceIndexPolicy deployed in the cluster to be indexable.

Scope resolution

The active project comes from one of:

  1. `useApp().project` — set automatically on `/project/:id/*` routes
  2. localStorage fallback — last-visited project, for cross-route continuity
  3. None — search disabled, user prompted to open a project

What changed

  • `app/resources/search/` — resource module (schema, adapter, service, queries, constants, recents, telemetry, active-project storage)
  • `app/features/search/engine/` — headless behavior hook (`useSearchEngine` + `useActiveProject`)
  • `app/features/search/surfaces/` — project search bar + mobile sheet
  • `app/features/search/shared/` — shared UI (result list, result item, empty state, partial-permission banner, scope footer)
  • Mount points: global header + project layout (via additive `headerContent` prop) + mobile sheet trigger
  • Removed dead `app/components/header/search-bar.tsx`

Preview

Screen.Recording.2026-05-21.at.17.02.57.mov

References

@yahyafakhroji yahyafakhroji marked this pull request as draft May 13, 2026 10:55
@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@yahyafakhroji yahyafakhroji changed the title feat(search): tenant-scoped search across cmd-K, project bar, and mobile sheet feat(search): tenant-scoped search across desktop and mobile May 13, 2026
@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@yahyafakhroji yahyafakhroji marked this pull request as ready for review May 21, 2026 01:34
@yahyafakhroji yahyafakhroji requested a review from a team May 21, 2026 10:02
@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

Adds the data layer for cloud-portal's tenant-scoped search feature
(milo-os/search#92). Follows the standard cloud-portal resource
module pattern at app/resources/search/:

- schema.ts — Zod domain types (SearchHit, SearchResult, SearchTarget)
- adapter.ts — K8s wire ⇄ domain (the only file in the module that
  imports the generated SDK; central translation seam)
- service.ts — service factory with explicit Content-Type:
  application/json header (the search apiserver's OpenAPI spec
  doesn't declare a request-body content type, so the SDK falls
  back to '*/*' which K8s rejects with 415; the explicit header
  bypasses that)
- queries.ts — TanStack useSearchInProject hook
- constants.ts — PROJECT_KINDS (verified against deployed
  ResourceIndexPolicy: Domain, DNSZone, HTTPProxy, ExportPolicy)
  with documented disabled list for kinds awaiting policy
- recents.ts — localStorage helpers under the datum-cloud-search-*
  namespace (dash-separated to match cookie-naming convention)
- active-project.ts — last-visited project storage (powers the
  GCP-style cross-route footer when useApp().project clears off-
  project routes)
- telemetry.ts — typed emitter for search.{opened,queried,selected,
  dismissed,partial_permission,error}; payloads carry queryLength
  only — raw query text never leaves the client

Brings the regenerated search SDK gen files (schemas, sdk, types)
onto the branch since the adapter imports types from them. All
public exports surfaced via index.ts barrel; internals (SDK types,
service factory) deliberately omitted.
Single source of truth for ALL search behaviour. Surfaces (cmd-K
palette, project bar, mobile sheet) become thin presentational
shells that consume this hook.

useSearchEngine owns:
- query state + 250ms debounce
- project-scoped dispatch via useSearchInProject (no global path —
  v3 collapsed the scope union to projectId: string | null per team
  feedback)
- result grouping by kind, capped at 5 per group
- client-side tenant filter as defense-in-depth (drops cross-tenant
  hits while the server-side parent-context middleware is still
  being wired — when it lands this becomes a no-op)
- reactive recents (state-backed useState + useEffect — clearing
  recents updates the UI immediately, no useMemo staleness)
- auto-save recent queries on every successful search with hits
  (not only on selectHit), so users can re-run queries they tried
  but didn't click through on
- selectHit clears setQuery('') before onOpenChange(false) so React
  batches the state changes and the popover/sheet closes cleanly
- single emission site for all 6 telemetry events; no surface ever
  emits directly
- ARIA combobox/listbox wiring + live-region status text

useActiveProject resolves the active project from useApp().project
(route-driven) first, falls back to localStorage if the user
navigated to /account or /org/* where useApp clears project to
undefined. Side-effect mirrors any route project back to storage so
the last-visited fallback stays fresh.
Visual building blocks consumed by every search surface (CmdKPalette,
ProjectSearchBar, MobileSearchSheet).

- KindIcon — JSX-returning component (not a function returning a
  component) so call sites stay createElement-free. Icons match the
  project sidebar nav: GaugeIcon for HTTPProxy, ChartSplineIcon for
  ExportPolicy, LayersIcon for Domain, SignpostIcon for DNSZone.
  All lucide icons are wrapped via @datum-cloud/datum-ui/icons Icon
  for consistent stroke weight with the rest of the chrome.
- kindDisplayName — kind → human label map. Labels mirror the
  sidebar nav strings exactly: 'AI Edge' for HTTPProxy, 'Metrics'
  for ExportPolicy, 'DNS' for DNSZone (matches the nav, shorter than
  'DNS Zones').
- kindToHref — kind → detail-page URL via paths.config; returns null
  for non-navigable kinds (filtered out by SearchResultItem).
- SearchResultItem — single hit row. Renders displayName (e.g.
  'hiyahya.dev' from spec.domainName) as primary text and the K8s
  resource name as a secondary line when it differs. Optional
  showIcon prop (default true) lets typed-results lists hide the
  per-row icon when the group header already carries it; mixed-kind
  lists like Recently Opened keep the row icons since the group
  has no implicit kind context.
- SearchResultList — typed-results renderer; CommandGroup heading
  carries the KindIcon + label, rows below are text-only.
- SearchEmptyState — recents queries (re-runnable strings) plus
  recently-opened hits (mixed-kind, full SearchResultItem rows)
  with a Clear All action.
- SearchPartialPermissionNote — role='status' (not alert) banner
  for per-kind SAR denial reporting.
- SearchScopeFooter — sticky 'Searching in {project}' / 'Showing
  resource results for {project} only.' depending on hasResults.
  Reinforces the scope guarantee the moment results appear.
Three project-scoped surface components consuming useSearchEngine,
plus the responsive picker and layout integration.

- CmdKPalette — desktop palette. Opens via ⌘/ + Ctrl+/ hotkey (the
  hotkey always sets open=true rather than toggling, to avoid
  desync between React state and Radix's internal dialog state).
  Disabled state when no active project shows 'Open a project to
  search.' instead of silently doing nothing.
- ProjectSearchBar — always-visible inline input in the project
  layout header. Uses PopoverAnchor (not PopoverTrigger) to keep
  the input's onFocus as the sole open-driver, avoiding the
  click-to-toggle race that caused the original flicker. The
  trailing clear X button is anchored inside PopoverAnchor so
  Radix's onInteractOutside guard recognises clicks on it.
- MobileSearchSheet — full-screen Sheet on mobile + tablet. The
  surface intercepts selection (handleSelect) to call onOpenChange
  (false) BEFORE delegating to engine.selectHit, committing the
  close synchronously with the tap and avoiding the cross-event
  flicker between cmdk's onSelect and react-router's navigation.
- SearchEntry — responsive picker. Renders CmdKPalette on desktop
  (≥1024px); mobile + tablet (<1024px) get a search-icon button
  in the header that opens the MobileSearchSheet. The previous
  tablet-shows-inline approach stacked badly against the project
  switcher in the 768–1023px range — moving tablet to the mobile
  pattern eliminated the cramp.
- Header / layout integration: header.tsx mounts SearchEntry;
  dashboard.layout.tsx threads a headerContent prop through to the
  project detail layout, which slots in ProjectSearchBar.
- ProjectSearchBar uses 'hidden lg:flex' (desktop-only inline
  affordance); below lg, users get the MobileSearchSheet via the
  global SearchEntry icon-button.
- Input style is h-9 + white background + text-icon-quaternary
  search icon (matches the table-search convention) so it feels
  visually consistent with the rest of the chrome.
- Dead app/components/header/search-bar.tsx removed; its re-export
  from header/index.ts dropped.
@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

Desktop search is now exclusively the always-visible ProjectSearchBar
on project routes. The hotkey changes:

- ⌘/ + Ctrl+/ → ⌘K + Ctrl+K (modern convention; Linear, GitHub,
  Notion, Cursor all use ⌘K)
- Behavior: focus the existing inline input instead of opening a
  separate dialog
- Scope: desktop only — useEffect guards on breakpoint === 'desktop'.
  Mobile + tablet retain the icon-button + MobileSearchSheet flow
  via SearchEntry (unchanged)

Why this shape:
- The previous CmdKPalette dialog duplicated all of ProjectSearchBar's
  UI (popover, results, recents, partial-permission banner). Routing
  the hotkey at the inline input removes ~135 lines of UI duplication
  while preserving every feature.
- Browser conflict: Chrome + Firefox use Ctrl+K (Cmd+K on Mac) for
  the URL bar's search-bar focus. preventDefault overrides it.
- The listener skips if the event target IS the input — pressing
  Cmd+K while typing in our input shouldn't double-fire.

Removed:
- app/features/search/surfaces/CmdKPalette.tsx (entire file)
- 'cmd-k' variant from SearchSurface telemetry union (test fixtures
  rebased to 'project-bar')

Tradeoff: the hotkey only works on /project/:id/* routes on desktop
(where ProjectSearchBar is mounted). Off-project pages have no
hotkey — acceptable, matches the v3 'search is project-scoped'
model.
@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

Adds a <kbd> inside ProjectSearchBar (desktop only) showing the focus
shortcut. macOS renders the ⌘ glyph + K; Windows/Linux render 'Ctrl K'.
Detection is driven by useOs, which is enhanced to prefer
navigator.userAgentData.platform (the future-safe Chromium signal as
the UA string is progressively frozen) before falling back to the
userAgent regex.

ConnectorDownloadCard's local detectBrowserOs helper is dropped in
favor of the same useOs hook — the mobile-exclusion (no connector
binary for iOS/Android) stays at the call site since it's a domain
rule, not an OS-detection rule.

UI notes:
- kbd render is gated on os !== 'undetermined' so SSR + first paint
  don't flash the wrong glyph.
- kbd shows only when the input is empty. When there's a query the
  clear-X button takes that slot (mutually exclusive).
- Input right padding swaps pr-7 ↔ pr-14 to keep the icon and kbd
  from overlapping the placeholder.
- aria-keyshortcuts ('Meta+K' on Mac, 'Control+K' elsewhere) is set
  on the input so VoiceOver / Narrator announce the shortcut, while
  the visual kbd stays aria-hidden.
@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

@mattdjenkinson
Copy link
Copy Markdown
Contributor

mattdjenkinson commented May 21, 2026

@yahyafakhroji could we change it so it's integrated like in this design please? The dropdown is ok, just the actual bar. https://www.figma.com/design/bBEQ8YeTP4SngNl5EkkQdH/Datum---Master-Design-File?node-id=9515-10643&t=ghDDZQLE87D6E8LN-4

…tWithAddons

Search is project-scoped, so it no longer belongs in the global header
chrome. Both surfaces now mount in the project-detail layout's
headerContent, with the breakpoint decision lifted up from the
components to the layout:

- Desktop → ProjectSearchBar (inline input)
- Mobile + tablet → SearchEntry (icon button → MobileSearchSheet)

SearchEntry and the global header no longer self-guard on breakpoint;
the header drops its SearchEntry mount entirely.

ProjectSearchBar is rebuilt on InputWithAddons — the search icon moves
to the leading slot and the clear button to the trailing slot, instead
of absolutely positioning both over a bare Input. Placeholder shortened
to 'Search'.
@yahyafakhroji
Copy link
Copy Markdown
Contributor Author

@mattdjenkinson Updated!

@github-actions
Copy link
Copy Markdown

🧪 Test Summary

Job Status
E2E Regression ⏭️ skipped
E2E Smoke ✅ success
Unit Tests ✅ success

View workflow run

📎 Artifacts

No artifacts (all tests passed).

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