docs: implementation plan for context-aware cmd-k search
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
510
docs/superpowers/plans/2026-04-02-context-aware-cmdk.md
Normal file
510
docs/superpowers/plans/2026-04-02-context-aware-cmdk.md
Normal file
@@ -0,0 +1,510 @@
|
||||
# Context-Aware Cmd-K Search Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make cmd-k search context-aware — show users/groups/roles when on admin pages instead of operational data.
|
||||
|
||||
**Architecture:** Route-based data swap in `LayoutShell.tsx`. When `isAdminPage` is true, build search data from admin RBAC hooks instead of catalog/agents/exchanges. `RbacPage` reads navigation state to auto-switch tab and highlight the selected item.
|
||||
|
||||
**Tech Stack:** React, @cameleer/design-system v0.1.26, react-router location state
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `ui/package.json` | Bump DS to v0.1.26 |
|
||||
| `ui/src/components/LayoutShell.tsx` | Import admin hooks, build admin search data, swap based on `isAdminPage`, update select/submit handlers for admin categories |
|
||||
| `ui/src/pages/Admin/RbacPage.tsx` | Read location state `{ tab, highlight }`, switch tab and scroll to highlighted item |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Bump Design System to v0.1.26
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/package.json`
|
||||
|
||||
- [ ] **Step 1: Update DS version**
|
||||
|
||||
In `ui/package.json`, change the `@cameleer/design-system` dependency:
|
||||
|
||||
```json
|
||||
"@cameleer/design-system": "^0.1.26",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Install and verify**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd ui && npm install
|
||||
```
|
||||
Expected: Clean install, no errors. `node_modules/@cameleer/design-system/package.json` shows version 0.1.26.
|
||||
|
||||
- [ ] **Step 3: Verify SearchCategory is now open**
|
||||
|
||||
Check that `SearchCategory` accepts arbitrary strings by running:
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
Expected: Clean compile (existing code still valid since old categories are still valid strings).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add ui/package.json ui/package-lock.json
|
||||
git commit -m "chore: bump @cameleer/design-system to v0.1.26"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Build Admin Search Data in LayoutShell
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/components/LayoutShell.tsx`
|
||||
|
||||
This task adds the admin search data builder function and wires it into the search data flow, swapping based on `isAdminPage`.
|
||||
|
||||
- [ ] **Step 1: Add admin search data builder function**
|
||||
|
||||
Add this function after the existing `buildSearchData` function (after line 96 in `LayoutShell.tsx`):
|
||||
|
||||
```typescript
|
||||
function buildAdminSearchData(
|
||||
users: UserDetail[] | undefined,
|
||||
groups: GroupDetail[] | undefined,
|
||||
roles: RoleDetail[] | undefined,
|
||||
): SearchResult[] {
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
if (users) {
|
||||
for (const u of users) {
|
||||
results.push({
|
||||
id: `user:${u.userId}`,
|
||||
category: 'user',
|
||||
title: u.displayName || u.userId,
|
||||
meta: u.userId,
|
||||
path: '/admin/rbac',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (groups) {
|
||||
for (const g of groups) {
|
||||
results.push({
|
||||
id: `group:${g.id}`,
|
||||
category: 'group',
|
||||
title: g.name,
|
||||
meta: g.parentGroupId ? `parent: ${g.parentGroupId}` : 'top-level group',
|
||||
path: '/admin/rbac',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (roles) {
|
||||
for (const r of roles) {
|
||||
results.push({
|
||||
id: `role:${r.id}`,
|
||||
category: 'role',
|
||||
title: r.name,
|
||||
meta: r.scope,
|
||||
path: '/admin/rbac',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add admin hook imports**
|
||||
|
||||
Add to the existing import from `'../../api/queries/admin/rbac'` — but this file doesn't import from rbac yet. Add a new import near the top of the file (after the existing query imports around line 21):
|
||||
|
||||
```typescript
|
||||
import { useUsers, useGroups, useRoles } from '../api/queries/admin/rbac';
|
||||
import type { UserDetail, GroupDetail, RoleDetail } from '../api/queries/admin/rbac';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Call admin hooks in LayoutContent**
|
||||
|
||||
Inside `LayoutContent()`, after the existing `useAttributeKeys()` call (line 228), add:
|
||||
|
||||
```typescript
|
||||
// --- Admin search data (only fetched on admin pages) ----------------
|
||||
const { data: adminUsers } = useUsers();
|
||||
const { data: adminGroups } = useGroups();
|
||||
const { data: adminRoles } = useRoles();
|
||||
```
|
||||
|
||||
Note: These hooks use `useQuery` which caches results. When the user is on admin pages and switches to cmd-k, the data is already warm from the RBAC page itself. When not on admin pages, these queries still fire but are lightweight (small payloads, cached by react-query). If we want to avoid the extra fetches when not on admin, we can add `enabled: isAdminPage` — but this is an optimization and the hooks in `rbac.ts` don't support an `enabled` parameter currently. The simplest approach is to let them fire; react-query deduplication handles the rest.
|
||||
|
||||
- [ ] **Step 4: Build admin search data with useMemo**
|
||||
|
||||
After the `catalogRef` block (around line 366), add:
|
||||
|
||||
```typescript
|
||||
const adminSearchData: SearchResult[] = useMemo(
|
||||
() => buildAdminSearchData(adminUsers, adminGroups, adminRoles),
|
||||
[adminUsers, adminGroups, adminRoles],
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Swap search data based on isAdminPage**
|
||||
|
||||
Replace the existing `searchData` useMemo block (lines 368-402):
|
||||
|
||||
```typescript
|
||||
const operationalSearchData: SearchResult[] = useMemo(() => {
|
||||
if (isAdminPage) return [];
|
||||
|
||||
const exchangeItems: SearchResult[] = (exchangeResults?.data || []).map((e: any) => ({
|
||||
id: e.executionId,
|
||||
category: 'exchange' as const,
|
||||
title: e.executionId,
|
||||
badges: [{ label: e.status, color: statusToColor(e.status) }],
|
||||
meta: `${e.routeId} · ${e.applicationId ?? ''} · ${formatDuration(e.durationMs)}`,
|
||||
path: `/exchanges/${e.applicationId ?? ''}/${e.routeId}/${e.executionId}`,
|
||||
serverFiltered: true,
|
||||
matchContext: e.highlight ?? undefined,
|
||||
}));
|
||||
|
||||
const attributeItems: SearchResult[] = [];
|
||||
if (debouncedQuery) {
|
||||
const q = debouncedQuery.toLowerCase();
|
||||
for (const e of exchangeResults?.data || []) {
|
||||
if (!e.attributes) continue;
|
||||
for (const [key, value] of Object.entries(e.attributes as Record<string, string>)) {
|
||||
if (key.toLowerCase().includes(q) || String(value).toLowerCase().includes(q)) {
|
||||
attributeItems.push({
|
||||
id: `${e.executionId}-attr-${key}`,
|
||||
category: 'attribute' as const,
|
||||
title: `${key} = "${value}"`,
|
||||
badges: [{ label: e.status, color: statusToColor(e.status) }],
|
||||
meta: `${e.executionId} · ${e.routeId} · ${e.applicationId ?? ''}`,
|
||||
path: `/exchanges/${e.applicationId ?? ''}/${e.routeId}/${e.executionId}`,
|
||||
serverFiltered: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...catalogRef.current, ...exchangeItems, ...attributeItems];
|
||||
}, [isAdminPage, catalogRef.current, exchangeResults, debouncedQuery]);
|
||||
|
||||
const searchData = isAdminPage ? adminSearchData : operationalSearchData;
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify compilation**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd ui && npx tsc --noEmit
|
||||
```
|
||||
Expected: Clean compile.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add ui/src/components/LayoutShell.tsx
|
||||
git commit -m "feat: build admin search data for cmd-k on admin pages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update Palette Select and Submit Handlers
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/components/LayoutShell.tsx`
|
||||
|
||||
This task updates `handlePaletteSelect` to navigate with tab/highlight state for admin results, and `handlePaletteSubmit` to navigate to the top result when on admin pages.
|
||||
|
||||
- [ ] **Step 1: Update handlePaletteSelect for admin categories**
|
||||
|
||||
Replace the existing `handlePaletteSelect` callback (lines 443-459) with:
|
||||
|
||||
```typescript
|
||||
const ADMIN_CATEGORIES = new Set(['user', 'group', 'role']);
|
||||
const ADMIN_TAB_MAP: Record<string, string> = { user: 'users', group: 'groups', role: 'roles' };
|
||||
|
||||
const handlePaletteSelect = useCallback((result: any) => {
|
||||
if (result.path) {
|
||||
if (ADMIN_CATEGORIES.has(result.category)) {
|
||||
const itemId = result.id.split(':').slice(1).join(':');
|
||||
navigate(result.path, {
|
||||
state: { tab: ADMIN_TAB_MAP[result.category], highlight: itemId },
|
||||
});
|
||||
} else {
|
||||
const state: Record<string, unknown> = { sidebarReveal: result.path };
|
||||
if (result.category === 'exchange' || result.category === 'attribute') {
|
||||
const parts = result.path.split('/').filter(Boolean);
|
||||
if (parts.length === 4 && parts[0] === 'exchanges') {
|
||||
state.selectedExchange = {
|
||||
executionId: parts[3],
|
||||
applicationId: parts[1],
|
||||
routeId: parts[2],
|
||||
};
|
||||
}
|
||||
}
|
||||
navigate(result.path, { state });
|
||||
}
|
||||
}
|
||||
setPaletteOpen(false);
|
||||
}, [navigate, setPaletteOpen]);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update handlePaletteSubmit for admin context**
|
||||
|
||||
Replace the existing `handlePaletteSubmit` callback (lines 461-466) with:
|
||||
|
||||
```typescript
|
||||
const handlePaletteSubmit = useCallback((query: string) => {
|
||||
if (isAdminPage) {
|
||||
// Navigate to top result — CommandPalette calls onSelect for the top result
|
||||
// when Enter is pressed, so this is only reached if there are no results.
|
||||
// Navigate to RBAC page as fallback.
|
||||
navigate('/admin/rbac');
|
||||
} else {
|
||||
const baseParts = ['/exchanges'];
|
||||
if (scope.appId) baseParts.push(scope.appId);
|
||||
if (scope.routeId) baseParts.push(scope.routeId);
|
||||
navigate(`${baseParts.join('/')}?text=${encodeURIComponent(query)}`);
|
||||
}
|
||||
}, [isAdminPage, navigate, scope.appId, scope.routeId]);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify compilation**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd ui && npx tsc --noEmit
|
||||
```
|
||||
Expected: Clean compile.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add ui/src/components/LayoutShell.tsx
|
||||
git commit -m "feat: handle admin cmd-k selection with tab navigation state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: RbacPage Reads Location State for Tab + Highlight
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/pages/Admin/RbacPage.tsx`
|
||||
|
||||
This task makes `RbacPage` read `{ tab, highlight }` from location state, switch to the correct tab, and pass the highlight ID down.
|
||||
|
||||
- [ ] **Step 1: Update RbacPage to read location state**
|
||||
|
||||
Replace the entire `RbacPage.tsx` with:
|
||||
|
||||
```typescript
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { StatCard, Tabs } from '@cameleer/design-system';
|
||||
import { useRbacStats } from '../../api/queries/admin/rbac';
|
||||
import styles from './UserManagement.module.css';
|
||||
import UsersTab from './UsersTab';
|
||||
import GroupsTab from './GroupsTab';
|
||||
import RolesTab from './RolesTab';
|
||||
|
||||
const TABS = [
|
||||
{ label: 'Users', value: 'users' },
|
||||
{ label: 'Groups', value: 'groups' },
|
||||
{ label: 'Roles', value: 'roles' },
|
||||
];
|
||||
|
||||
const VALID_TABS = new Set(TABS.map((t) => t.value));
|
||||
|
||||
export default function RbacPage() {
|
||||
const { data: stats } = useRbacStats();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState('users');
|
||||
const [highlightId, setHighlightId] = useState<string | null>(null);
|
||||
|
||||
// Read tab + highlight from location state (set by cmd-k navigation)
|
||||
useEffect(() => {
|
||||
const state = location.state as { tab?: string; highlight?: string } | null;
|
||||
if (!state) return;
|
||||
|
||||
if (state.tab && VALID_TABS.has(state.tab)) {
|
||||
setTab(state.tab);
|
||||
}
|
||||
if (state.highlight) {
|
||||
setHighlightId(state.highlight);
|
||||
}
|
||||
|
||||
// Consume the state so back-navigation doesn't re-trigger
|
||||
navigate(location.pathname, { replace: true, state: null });
|
||||
}, [location.state]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard label="Users" value={stats?.userCount ?? 0} />
|
||||
<StatCard label="Groups" value={stats?.groupCount ?? 0} />
|
||||
<StatCard label="Roles" value={stats?.roleCount ?? 0} />
|
||||
</div>
|
||||
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
||||
<div className={styles.tabContent}>
|
||||
{tab === 'users' && <UsersTab highlightId={highlightId} onHighlightConsumed={() => setHighlightId(null)} />}
|
||||
{tab === 'groups' && <GroupsTab highlightId={highlightId} onHighlightConsumed={() => setHighlightId(null)} />}
|
||||
{tab === 'roles' && <RolesTab highlightId={highlightId} onHighlightConsumed={() => setHighlightId(null)} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update UsersTab to accept and use highlightId**
|
||||
|
||||
In `ui/src/pages/Admin/UsersTab.tsx`, update the component signature and add highlight logic. At the top of the file, update the default export:
|
||||
|
||||
Change the function signature from:
|
||||
```typescript
|
||||
export default function UsersTab() {
|
||||
```
|
||||
to:
|
||||
```typescript
|
||||
export default function UsersTab({ highlightId, onHighlightConsumed }: { highlightId?: string | null; onHighlightConsumed?: () => void }) {
|
||||
```
|
||||
|
||||
Then inside the component, after the existing `useState` declarations, add:
|
||||
|
||||
```typescript
|
||||
// Auto-select highlighted item from cmd-k navigation
|
||||
useEffect(() => {
|
||||
if (highlightId && users) {
|
||||
const match = users.find((u) => u.userId === highlightId);
|
||||
if (match) {
|
||||
setSelectedId(match.userId);
|
||||
onHighlightConsumed?.();
|
||||
}
|
||||
}
|
||||
}, [highlightId, users]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
```
|
||||
|
||||
Add `useEffect` to the import from `react` if not already there (line 1):
|
||||
```typescript
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update GroupsTab to accept and use highlightId**
|
||||
|
||||
In `ui/src/pages/Admin/GroupsTab.tsx`, apply the same pattern. Update the function signature from:
|
||||
```typescript
|
||||
export default function GroupsTab() {
|
||||
```
|
||||
to:
|
||||
```typescript
|
||||
export default function GroupsTab({ highlightId, onHighlightConsumed }: { highlightId?: string | null; onHighlightConsumed?: () => void }) {
|
||||
```
|
||||
|
||||
Add after existing `useState` declarations:
|
||||
|
||||
```typescript
|
||||
// Auto-select highlighted item from cmd-k navigation
|
||||
useEffect(() => {
|
||||
if (highlightId && groups) {
|
||||
const match = groups.find((g) => g.id === highlightId);
|
||||
if (match) {
|
||||
setSelectedId(match.id);
|
||||
onHighlightConsumed?.();
|
||||
}
|
||||
}
|
||||
}, [highlightId, groups]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
```
|
||||
|
||||
Ensure `useEffect` is in the `react` import.
|
||||
|
||||
- [ ] **Step 4: Update RolesTab to accept and use highlightId**
|
||||
|
||||
In `ui/src/pages/Admin/RolesTab.tsx`, apply the same pattern. Update the function signature from:
|
||||
```typescript
|
||||
export default function RolesTab() {
|
||||
```
|
||||
to:
|
||||
```typescript
|
||||
export default function RolesTab({ highlightId, onHighlightConsumed }: { highlightId?: string | null; onHighlightConsumed?: () => void }) {
|
||||
```
|
||||
|
||||
Add after existing `useState` declarations:
|
||||
|
||||
```typescript
|
||||
// Auto-select highlighted item from cmd-k navigation
|
||||
useEffect(() => {
|
||||
if (highlightId && roles) {
|
||||
const match = roles.find((r) => r.id === highlightId);
|
||||
if (match) {
|
||||
setSelectedId(match.id);
|
||||
onHighlightConsumed?.();
|
||||
}
|
||||
}
|
||||
}, [highlightId, roles]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
```
|
||||
|
||||
Ensure `useEffect` is in the `react` import.
|
||||
|
||||
- [ ] **Step 5: Verify compilation**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd ui && npx tsc --noEmit
|
||||
```
|
||||
Expected: Clean compile.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add ui/src/pages/Admin/RbacPage.tsx ui/src/pages/Admin/UsersTab.tsx ui/src/pages/Admin/GroupsTab.tsx ui/src/pages/Admin/RolesTab.tsx
|
||||
git commit -m "feat: RBAC page reads cmd-k navigation state for tab switch and highlight"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Manual Verification
|
||||
|
||||
- [ ] **Step 1: Start dev server**
|
||||
|
||||
```bash
|
||||
cd ui && npm run dev
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test operational search**
|
||||
|
||||
1. Navigate to `/exchanges`
|
||||
2. Press Cmd-K (or Ctrl-K)
|
||||
3. Type a query — verify applications, routes, agents, exchanges appear as before
|
||||
4. Select a result — verify navigation works
|
||||
|
||||
- [ ] **Step 3: Test admin search**
|
||||
|
||||
1. Navigate to `/admin/rbac`
|
||||
2. Press Cmd-K
|
||||
3. Verify the palette shows users, groups, and roles (not applications/exchanges)
|
||||
4. Type a username — verify filtering works
|
||||
5. Select a user — verify RBAC page switches to Users tab and selects that user
|
||||
|
||||
- [ ] **Step 4: Test tab switch via search**
|
||||
|
||||
1. While on `/admin/rbac` (Users tab active), press Cmd-K
|
||||
2. Search for a role name
|
||||
3. Select the role — verify RBAC page switches to Roles tab and selects that role
|
||||
|
||||
- [ ] **Step 5: Test context switching**
|
||||
|
||||
1. From admin, click "Applications" in sidebar to leave admin
|
||||
2. Press Cmd-K — verify operational search is back (apps, routes, exchanges)
|
||||
3. Click "Admin" in sidebar
|
||||
4. Press Cmd-K — verify admin search is back (users, groups, roles)
|
||||
|
||||
- [ ] **Step 6: Final commit (if any fixups needed)**
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "fix: cmd-k context-aware search adjustments"
|
||||
```
|
||||
Reference in New Issue
Block a user