Skip to content

feat(search): ⌘K workspace-wide global search + inbox deep-link (#101)#81

Merged
Omarear merged 1 commit into
mainfrom
feat/cmdk-global-search
Jun 24, 2026
Merged

feat(search): ⌘K workspace-wide global search + inbox deep-link (#101)#81
Omarear merged 1 commit into
mainfrom
feat/cmdk-global-search

Conversation

@Omarear

@Omarear Omarear commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Builds the top-bar ⌘K command palette from the target design — workspace-wide search across conversations and projects — plus the inbox deep-linkability it requires. Scope is the ⌘K half of #101 (project-status is a separate slice); sources deferred; #105 not built (but its scope helper is). Branched from main, independent of PR #80.

Backend — GET /api/search?q=

  • New endpoint behind requireSession + requireWorkspace, returns { conversations, projects }.
  • Tenant boundary expressed once via new lib/workspace-scope.ts workspaceProjectIds(env, workspaceId) — derived from the membership-validated c.get("workspaceId"), reusable by the future #105 unified inbox.
  • Every arm intersected with inArray(conversation.projectId, ids) inside the base WHERE (composes with caps, never bolted on after LIMIT); empty-set guard short-circuits before any IN (); and() never bare eq().
  • Searches conversation name/email/message-body (the body arm is the two-hop message→conversation→project path, bounded by MAX_MATCH_CONVERSATIONS) + project name. Reuses lib/search.ts (likeContains injection-safe, buildSnippet). Per-entity caps (8 conv / 5 proj). Never returns publicKey/inboundEmailLocal/systemPrompt/knowledgeText.

Frontend — palette + deep-link

  • ⌘K/Ctrl+K listener in the shell + a TopBar search affordance (the bar was previously empty by design). Vendored cmdk/CommandDialog (no new deps).
  • 250ms debounce, min-2-chars, abort-in-flight (react-query signal → api()). Honesty rail: live-gated rows, "Searching…" while pending, "No matches" only after a settled query — real rows only.
  • Routing: conversation → /inbox?project=&c=, project → /settings/projects/<id>.
  • Inbox deep-link: reads ?project=&c= once on mount and mirrors the open thread to the URL via history.replaceState (shareable threads). The thread endpoint re-validates tenancy, so a forged c yields an empty thread — never a leak.
  • CommandDialog now forwards cmdk Command props (fixes shouldFilter silently dropping) + adds an sr-only DialogTitle; api() gains an optional AbortSignal.

Tests — the deliverable

Real-sqlite e2e proving the cross-workspace boundary across all three arms (name/email, message-body two-hop, project), the empty-set guard (empty, not all-rows, not 500), secret-omission, and the 403 membership gate (a forged x-workspace-id can't pivot). Plus palette honesty-rail + routing, and inbox deep-link open + URL-sync.

Adversarial review

3 parallel reviewers (security/IDOR · correctness · frontend) → per-finding verification. Tenant boundary: no constructible leak across all 12 traced query paths. 3 confirmed low/nit findings, all applied: project-name lookup now self-asserts workspaceId (and()-never-bare-eq()); palette no longer flashes stale rows on quick reopen / workspace switch; ⌘K respects defaultPrevented.

Gates

API 376 + dashboard 401 tests pass; both typecheck clean; lint 7/7; prettier clean.

Deploy note: touches the dashboard → preview runs the kind: nextjs builder; if it fails with No executable pnpm found, that's the known transient Ploy flake — re-trigger once.

🤖 Generated with Claude Code

- new GET /api/search?q= — cross-project search over conversations
  (visitor name/email + message body) and projects (name), behind
  requireSession+requireWorkspace; returns { conversations, projects }
- tenant boundary expressed once via new lib/workspace-scope.ts
  workspaceProjectIds() (reusable by the future #105 unified inbox);
  every arm intersected with inArray(projectId, ids) in the base WHERE,
  empty-set guard short-circuits before any IN(), and()-never-bare-eq()
- reuse lib/search.ts (likeContains/buildSnippet/MAX_MATCH_CONVERSATIONS);
  per-entity caps (8 conversations / 5 projects); never returns project
  secrets (publicKey/inboundEmailLocal/systemPrompt/knowledgeText)
- ⌘K/Ctrl+K command palette (vendored cmdk/CommandDialog) + TopBar search
  affordance; 250ms debounce, min-2-chars, abort-in-flight; honesty rail
  (live-gated rows, Searching…, "No matches" only after a settled query);
  results route to /inbox?project=&c= or /settings/projects/<id>
- inbox deep-linkability: read ?project=&c= once on mount + mirror the open
  thread to the URL via history.replaceState (shareable threads)
- CommandDialog now forwards cmdk Command props (shouldFilter) to the inner
  Command + adds an sr-only DialogTitle; api() gains an AbortSignal passthrough

Tests (the deliverable): real-sqlite e2e proving the cross-workspace
boundary across all three arms (name/email, message-body two-hop, project),
the empty-set guard, secret-omission, and the 403 membership gate; palette
honesty-rail + routing; inbox deep-link open + URL sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@meet-ploy

meet-ploy Bot commented Jun 24, 2026

Copy link
Copy Markdown

❌ Some deployments failed

Project Deployment Branch Preview Commit Preview
llmchat-dashboard Failed Preview Preview
llmchat-showcase Failed Preview Preview
llmchat-marketing Failed Preview Preview
llmchat-api Ready Preview Preview

Deployed with Ploy

@Omarear Omarear merged commit e1aad2f into main Jun 24, 2026
1 check failed
@Omarear Omarear deleted the feat/cmdk-global-search branch June 24, 2026 17:30
Omarear added a commit that referenced this pull request Jun 24, 2026
…(#112)

Two placement fixes on the inbox restyle; honesty rail + ROADMAP dimming
unchanged.

1. Search moved to the CENTER of the top bar as a wide box ("Search
   conversations & projects… ⌘K"), matching the target — was top-right. Pure
   reposition/restyle of the trigger; the ⌘K palette logic (#81) is untouched
   (still opens + routes). Mobile keeps the icon trigger.
2. Restored the stats panel dropped by the restyle (Conversations / Escalated /
   Resolved / Avg rating) — real server aggregates, honest 0s when empty. Placed
   as a compact 2-col grid at the top of the list pane (fits the narrow column)
   and replaces the redundant "N total" line. This was a regression, not a cut.

Tests: top-bar.test (centered trigger fires onOpenSearch + ⌘K hint/placeholder);
InboxStats restored with its test; ListFilters.test updated for the stats prop.
Gates: dashboard 415 tests, tsc + lint + prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Omarear added a commit that referenced this pull request Jun 24, 2026
…stly dimmed (#112) (#82)

* feat(inbox): restyle to match target design — LIVE real, ROADMAP honestly dimmed (#112)

Layout/styling pass over the inbox to match the target mockup while holding the
LIVE/ROADMAP honesty contract (real data or honest empty states — never
fabricated; unbuilt features render dimmed/"soon", non-functional, never wired).

Left pane (ListFilters, new): "Inbox" + real total → conversation search →
FILTER-BY-TAG chip row (All + each real workspace tag, replaces the popover —
drives the same tagIds engine) → STATUS pills (LIVE Open/Resolved/Escalated/All
+ dimmed ROADMAP Unassigned/AI-handled).

Thread header (ThreadActions, new): LIVE Resolve/Reopen + Delete (confirm,
non-optimistic) moved here from the contact panel; Assign rendered dimmed/inert.

Contact panel (DetailPanel): Copy email (LIVE) + merged CONTACT fields
(Email/IP/Device/Started/Messages) + CSAT (real) + Tags add/remove (moved here)
+ Visitor-context kept as the honest "Not captured yet" em-dash block.

Composer (ReplyComposer): Reply (LIVE) vs Internal-note (dimmed); Suggest-with-AI
and attach dimmed/inert; Send live.

- removed the stat-card strip + horizontal toolbar to match the mockup; the real
  total is shown in the list header (per-conversation CSAT stays). The
  escalated/resolved/avg aggregates are intentionally dropped (mockup has none).
- top-bar ⌘K search left untouched: it's LIVE (#81) and shell, not inbox.
- inbox project switcher kept (our per-project filter); archive==resolve so a
  single Resolve, not a fake Archive. No LIVE/ROADMAP annotation pills ship.
- deleted InboxToolbar / InboxStats / TagFilter (+ tests), superseded.

Adversarial review: honesty + layout reviewers found 0 issues; correctness flagged
the (intentional) stats drop + a missing ReplyComposer test — test added. Gates:
dashboard 409 tests, tsc + lint + prettier clean. Three-pane bounded layout intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(inbox): center the top-bar search + restore the LIVE stats panel (#112)

Two placement fixes on the inbox restyle; honesty rail + ROADMAP dimming
unchanged.

1. Search moved to the CENTER of the top bar as a wide box ("Search
   conversations & projects… ⌘K"), matching the target — was top-right. Pure
   reposition/restyle of the trigger; the ⌘K palette logic (#81) is untouched
   (still opens + routes). Mobile keeps the icon trigger.
2. Restored the stats panel dropped by the restyle (Conversations / Escalated /
   Resolved / Avg rating) — real server aggregates, honest 0s when empty. Placed
   as a compact 2-col grid at the top of the list pane (fits the narrow column)
   and replaces the redundant "N total" line. This was a regression, not a cut.

Tests: top-bar.test (centered trigger fires onOpenSearch + ⌘K hint/placeholder);
InboxStats restored with its test; ListFilters.test updated for the stats prop.
Gates: dashboard 415 tests, tsc + lint + prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant