diff --git a/.changeset/quiet-command-menu-shortcut.md b/.changeset/quiet-command-menu-shortcut.md new file mode 100644 index 00000000000..fd262130983 --- /dev/null +++ b/.changeset/quiet-command-menu-shortcut.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Prevent Cmd/Ctrl+K from reaching an outer host while a command menu input is focused. diff --git a/docs/command-menu-architecture.md b/docs/command-menu-architecture.md new file mode 100644 index 00000000000..31b47d91dab --- /dev/null +++ b/docs/command-menu-architecture.md @@ -0,0 +1,123 @@ +# Shared command-menu architecture + +Status: planned follow-up. Do not treat the current app-specific command menus +as the long-term architecture. + +## Decision + +Use `cmdk` as the interaction engine, keep the command-menu shell and registry +contract in shared Agent-Native code, and let each app register its own commands +and searchable resources. + +The shared layer should own the behavior that must be identical everywhere: + +- Cmd/Ctrl+K opening, toggling, focus, and dismissal +- the dialog, input, list, group, item, shortcut, loading, and empty states +- keyboard navigation, selection, and accessible labeling +- command ranking and the boundary between static commands and async results +- common framework commands such as theme, agent, settings, changelog, and + diagnostics + +Apps should own only their domain knowledge: + +- localized command labels and descriptions +- icons, keywords, shortcuts, and visibility rules +- route-aware context and permission checks +- command handlers that call the app's existing action/navigation surfaces +- async search providers for resources such as recordings, meetings, + dictations, documents, or CRM records + +The shared layer must not become a universal data index or import app routes. +It coordinates registered providers; it does not invent domain results. + +## Proposed contract + +The core package should expose a registry/provider API along these lines: + +```ts +type CommandContext = { + pathname: string; + searchParams: URLSearchParams; + appId: string; + organizationId?: string; +}; + +type CommandDefinition = { + id: string; + group: string; + label: string; + description?: string; + keywords?: string[]; + shortcut?: string; + icon?: React.ComponentType<{ size?: number; className?: string }>; + availableWhen?: (context: CommandContext) => boolean; + run: (context: CommandContext) => void | Promise; +}; + +type CommandSearchProvider = { + id: string; + search: ( + query: string, + context: CommandContext, + ) => Promise; +}; +``` + +The final names and exact shape should follow the existing core type conventions. +The important boundary is that the menu renders descriptors and provider +results instead of each app manually rebuilding the palette's React tree and +filtering its children. + +## Current state + +The repository already has the beginnings of this split: + +- `packages/toolkit/src/ui/command.tsx` wraps the `cmdk` primitive. +- `packages/core/src/client/CommandMenu.tsx` owns the shared dialog shell, + keyboard hook, framework entries, and composable group/item surface. +- `templates/clips/app/components/clips-command-menu.tsx` currently owns the + Clips registry, route context, navigation handlers, and recording/meeting/ + dictation search providers. + +The remaining problem is that app registries are still hand-authored JSX. The +same pattern exists in other templates, so improvements currently require +duplicated work and can drift in behavior. + +## Migration plan + +1. Add the shared descriptor/provider types and registry context in core. +2. Make the shared `CommandMenu` render registered descriptors while keeping + its existing composable API temporarily for compatibility. +3. Move common framework commands into the shared registry. +4. Convert Clips from `ClipsCommandMenu` JSX groups to registered static + commands plus registered search providers. Preserve its route-aware + commands and action-backed searches. +5. Convert the other app menus (including CRM, Forms, Dispatch, and Macros) to + the same registration surface. +6. Remove duplicate per-app shortcut listeners and bespoke static filtering + after all consumers migrate. +7. Add shared contract tests for registration, availability, ranking, async + loading, stale-result suppression, keyboard selection, and contextual + commands; retain app tests for domain-specific handlers and routes. + +## Acceptance criteria + +- An app can add commands and search providers without copying the command + dialog or keyboard handling. +- A command is hidden when its app-provided context or permission predicate + says it is unavailable. +- Search results can navigate through the app's existing client routing and + actions without raw API calls or a second data model. +- Async providers show loading and empty states consistently and cannot display + stale results from a previous query or route. +- Cmd/Ctrl+K opens exactly one menu in a host containing multiple Agent-Native + surfaces. +- App-local commands remain localized and can link to the current resource, + folder, meeting, or dictation context. + +## Non-goals + +- Replacing `cmdk` with a second command-palette dependency. +- Putting app route definitions or resource-specific SQL in core. +- Creating one global search endpoint that every app must use. +- Refactoring the current Clips menu as part of an unrelated UX-fixes change. diff --git a/packages/core/src/cli/create-start-shape.spec.ts b/packages/core/src/cli/create-start-shape.spec.ts index aa3728a17f2..6c2eaf9a2f0 100644 --- a/packages/core/src/cli/create-start-shape.spec.ts +++ b/packages/core/src/cli/create-start-shape.spec.ts @@ -36,7 +36,12 @@ beforeEach(() => { afterEach(() => { process.chdir(originalCwd); - fs.rmSync(parentDir, { recursive: true, force: true }); + fs.rmSync(parentDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 50, + }); vi.clearAllMocks(); }); diff --git a/packages/core/src/client/CommandMenu.spec.tsx b/packages/core/src/client/CommandMenu.spec.tsx index 8cf901e3e9a..00e163fd686 100644 --- a/packages/core/src/client/CommandMenu.spec.tsx +++ b/packages/core/src/client/CommandMenu.spec.tsx @@ -118,6 +118,34 @@ describe("CommandMenu docs group", () => { ); }); + it("filters command items nested in fragments", () => { + act(() => { + root.render( + undefined} + showAgentFallback={false} + > + + <> + undefined}> + Open comments + + undefined}> + Open transcript + + + + , + ); + }); + + search("transcript"); + + expect(document.body.textContent).not.toContain("Open comments"); + expect(document.body.textContent).toContain("Open transcript"); + }); + it("offers the shared About Agent-Native surface and matches version searches", () => { act(() => { root.render( @@ -386,7 +414,7 @@ describe("CommandMenu docs group", () => { expect(document.body.textContent).toContain("open"); }); - it("does not open from native select controls when contenteditable is allowed", () => { + it("claims Cmd+K from native controls without opening", () => { function ShortcutHarness() { const [open, setOpen] = React.useState(false); useCommandMenuShortcut(() => setOpen(true), { @@ -408,17 +436,18 @@ describe("CommandMenu docs group", () => { const select = document.querySelector("select"); expect(select).toBeTruthy(); + const event = new KeyboardEvent("keydown", { + key: "k", + metaKey: true, + bubbles: true, + cancelable: true, + }); act(() => { - select!.dispatchEvent( - new KeyboardEvent("keydown", { - key: "k", - metaKey: true, - bubbles: true, - }), - ); + select!.dispatchEvent(event); }); expect(document.body.textContent).toContain("closed"); + expect(event.defaultPrevented).toBe(true); }); it("opens from contenteditable before editor handlers stop propagation", () => { diff --git a/packages/core/src/client/CommandMenu.tsx b/packages/core/src/client/CommandMenu.tsx index 8771d6e3f66..b122aa5e3d2 100644 --- a/packages/core/src/client/CommandMenu.tsx +++ b/packages/core/src/client/CommandMenu.tsx @@ -424,6 +424,15 @@ export function CommandMenu({ if (!React.isValidElement(child)) return child; const props = child.props as Record; + if (child.type === React.Fragment) { + const fragmentChildren = filterChildren(props.children as ReactNode); + if (React.Children.count(fragmentChildren) === 0) return null; + return React.cloneElement(child, { + ...props, + children: fragmentChildren, + } as Record); + } + // If it's a CommandGroup, filter its children if (child.type === CommandGroup) { const groupChildren = filterChildren(props.children as ReactNode); @@ -667,6 +676,11 @@ export function useCommandMenuShortcut( useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + // Claim the shortcut before checking the focused element so an outer + // host cannot open its own command menu while this one is focused. + e.preventDefault(); + e.stopPropagation(); + // Don't trigger if user is typing in a native form control. const target = e.target instanceof HTMLElement ? e.target : null; const isContentEditable = target?.isContentEditable; @@ -678,7 +692,6 @@ export function useCommandMenuShortcut( ) { return; } - e.preventDefault(); onOpen(); } }; diff --git a/packages/core/src/email-catalog/redact-body.spec.ts b/packages/core/src/email-catalog/redact-body.spec.ts index 55d1a44ea24..d1fe46fa65c 100644 --- a/packages/core/src/email-catalog/redact-body.spec.ts +++ b/packages/core/src/email-catalog/redact-body.spec.ts @@ -96,10 +96,16 @@ describe("redactSensitiveEmailBodyContent", () => { }); it("redacts a JWT-shaped token", () => { - const text = - "Session token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"; + const fakeJwt = [ + Buffer.from(JSON.stringify({ alg: "HS256" })).toString("base64url"), + Buffer.from(JSON.stringify({ sub: "example-user" })).toString( + "base64url", + ), + Buffer.from("not-a-signature").toString("base64url"), + ].join("."); + const text = `Session token: ${fakeJwt}`; const redacted = redactSensitiveEmailBodyContent(text); - expect(redacted).not.toContain("eyJhbGciOiJIUzI1NiJ9"); + expect(redacted).not.toContain(fakeJwt); expect(redacted).toContain("[REDACTED]"); expect(redacted).toContain("Session token:"); }); diff --git a/templates/clips/.agents/skills/dictate/SKILL.md b/templates/clips/.agents/skills/dictate/SKILL.md index ec6a1ef48d1..aff655a1475 100644 --- a/templates/clips/.agents/skills/dictate/SKILL.md +++ b/templates/clips/.agents/skills/dictate/SKILL.md @@ -40,6 +40,7 @@ Dictate captures **mic only** — system audio is never recorded for dictations. | Action | What it does | | -------------------- | ------------------------------------------------------------------------------------------- | | `list-dictations` | Past dictations, scoped via `accessFilter` | +| `search-dictations` | Search native or cleaned dictation text, with matching snippets | | `cleanup-dictation` | Polish a single dictation's text (writes `cleanedText`) | | `cleanup-transcript` | Shared cleanup pipeline (also used by Clips + Meetings); resolves credentials per the order below | diff --git a/templates/clips/DESIGN.md b/templates/clips/DESIGN.md index 37c1e102c4a..2076dedacda 100644 --- a/templates/clips/DESIGN.md +++ b/templates/clips/DESIGN.md @@ -54,10 +54,11 @@ transcript, agent, insights, and settings. ## Progressive disclosure -The viewer presents jobs, not inventories. Sharing starts with one header-level -copy-link action, invitations, and current access. Social destinations and -embed publishing replace the body as focused secondary views; embed -configuration and agent context links stay collapsed until requested. The +The viewer presents jobs, not inventories. Human sharing and agent continuity +are separate jobs: Share owns invitations, durable access policy, password, +expiry, social destinations, and embed publishing; a quiet adjacent Send to +agent action owns ephemeral handoff to an agent destination. Secondary sharing +destinations replace the Share body as focused views. The overflow leads with recording cleanup actions, while maintenance and document-generation commands live in named submenus. A generic AI-tools launcher does not compete with Share. The editor opens in transcript mode and @@ -65,9 +66,11 @@ reveals the precision timeline as a peer mode instead of stacking both workspaces under the player. Share is the viewer toolbar's sole labeled primary action and the product-led -growth entry point. Copy link is the first action inside Share, never a competing -toolbar button. Edit, download, and overflow use equal compact icon controls -with accessible names and tooltips; their visual weight must not rival Share. +growth entry point. Send to agent is an accessible secondary icon action that +opens one compact handoff menu; it must not become a second sharing/settings dialog. Copy +link stays inside Share, never as a competing toolbar button. Edit, download, +and overflow use equal compact icon controls with accessible names and tooltips; +their visual weight must not rival Share. Viewer identity uses one avatar grammar everywhere. People use profile images or initials; agents use the same circular avatar shape with the assistant mark. diff --git a/templates/clips/actions/list-organization-state.ts b/templates/clips/actions/list-organization-state.ts index 6c0b217515f..1733d195ffe 100644 --- a/templates/clips/actions/list-organization-state.ts +++ b/templates/clips/actions/list-organization-state.ts @@ -16,10 +16,24 @@ import { } from "@agent-native/core/org"; import { isEmailDerivedName } from "@agent-native/core/user-profile"; import { getUserProfiles } from "@agent-native/core/user-profile/server"; -import { and, asc, desc, eq, isNotNull, or } from "drizzle-orm"; +import { + and, + asc, + desc, + eq, + isNotNull, + isNull, + notInArray, + or, + sql, +} from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { + agentRecordingAccessFilter, + isAgentRecordingCaller, +} from "../server/lib/agent-recording-access.js"; import { getCurrentOwnerEmail, ownerEmailMatches, @@ -38,7 +52,7 @@ export default defineAction({ ), }), http: { method: "GET" }, - run: async (args) => { + run: async (args, ctx) => { const db = getDb(); const ownerEmail = getCurrentOwnerEmail(); @@ -127,7 +141,12 @@ export default defineAction({ createdAt: Number(i.createdAt), })); - const [spaces, folders] = await Promise.all([ + const resolvedDb = await Promise.resolve(db); + const meetingRecordingIds = resolvedDb + .select({ id: schema.meetings.recordingId }) + .from(schema.meetings) + .where(isNotNull(schema.meetings.recordingId)); + const [spaces, folders, folderRecordingCountRows] = await Promise.all([ db .select() .from(schema.spaces) @@ -146,7 +165,39 @@ export default defineAction({ ), ) .orderBy(asc(schema.folders.position)), + resolvedDb + .select({ + folderId: schema.recordings.folderId, + recordingCount: sql`COUNT(1)`, + }) + .from(schema.recordings) + .where( + and( + agentRecordingAccessFilter( + schema.recordings, + schema.recordingShares, + schema.recordingViewers, + { + agentOnly: isAgentRecordingCaller(ctx?.caller), + userEmail: ctx?.userEmail, + }, + ), + eq(schema.recordings.organizationId, organizationId), + isNotNull(schema.recordings.folderId), + isNull(schema.recordings.archivedAt), + isNull(schema.recordings.trashedAt), + notInArray(schema.recordings.id, meetingRecordingIds), + ), + ) + .groupBy(schema.recordings.folderId), ]); + const recordingCountByFolder = new Map( + folderRecordingCountRows.flatMap((row) => + row.folderId + ? [[row.folderId, Number(row.recordingCount ?? 0)] as const] + : [], + ), + ); return { currentUserEmail: ownerEmail, @@ -173,6 +224,7 @@ export default defineAction({ spaceId: f.spaceId, ownerEmail: f.ownerEmail, position: f.position, + recordingCount: recordingCountByFolder.get(f.id) ?? 0, })), personalFolders: folders .filter((f) => f.spaceId === null) @@ -180,6 +232,7 @@ export default defineAction({ id: f.id, name: f.name, parentId: f.parentId, + recordingCount: recordingCountByFolder.get(f.id) ?? 0, })), invitations, }; diff --git a/templates/clips/actions/list-recordings.test.ts b/templates/clips/actions/list-recordings.test.ts index 99ab37c18e1..34c6216037e 100644 --- a/templates/clips/actions/list-recordings.test.ts +++ b/templates/clips/actions/list-recordings.test.ts @@ -104,6 +104,7 @@ vi.mock("../server/db/index.js", () => ({ id: "recordings.id", ownerEmail: "recordings.ownerEmail", organizationId: "recordings.organizationId", + folderId: "recordings.folderId", archivedAt: "recordings.archivedAt", trashedAt: "recordings.trashedAt", }, @@ -287,3 +288,51 @@ describe("list-recordings shared view", () => { ); }); }); + +describe("list-recordings folder scope", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("excludes foldered recordings from the library root", async () => { + const parsed = action.schema.parse({ + view: "library", + countOnly: true, + }); + + await action.run(parsed); + + expect(mockCountWhere).toHaveBeenCalledWith( + expect.objectContaining({ + conditions: expect.arrayContaining([ + { + kind: "is-null", + column: "recordings.folderId", + }, + ]), + }), + ); + }); + + it("keeps foldered recordings scoped to the requested folder", async () => { + const parsed = action.schema.parse({ + view: "library", + folderId: "folder_1", + countOnly: true, + }); + + await action.run(parsed); + + expect(mockCountWhere).toHaveBeenCalledWith( + expect.objectContaining({ + conditions: expect.arrayContaining([ + { + kind: "eq", + column: "recordings.folderId", + value: "folder_1", + }, + ]), + }), + ); + }); +}); diff --git a/templates/clips/actions/list-recordings.ts b/templates/clips/actions/list-recordings.ts index 90fefe15054..05377a3f0b9 100644 --- a/templates/clips/actions/list-recordings.ts +++ b/templates/clips/actions/list-recordings.ts @@ -228,6 +228,8 @@ export default defineAction({ if (args.view === "library" || args.view === "space") { if (args.folderId !== undefined && args.folderId !== null) { whereClauses.push(eq(schema.recordings.folderId, args.folderId)); + } else { + whereClauses.push(isNull(schema.recordings.folderId)); } } diff --git a/templates/clips/actions/search-dictations.ts b/templates/clips/actions/search-dictations.ts new file mode 100644 index 00000000000..ff9b23e846a --- /dev/null +++ b/templates/clips/actions/search-dictations.ts @@ -0,0 +1,81 @@ +/** Search dictation history by its native or cleaned transcript text. */ + +import { defineAction } from "@agent-native/core/action"; +import { buildDeepLink } from "@agent-native/core/server"; +import { accessFilter } from "@agent-native/core/sharing"; +import { and, desc, sql } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import { buildCaseInsensitiveSearchPattern } from "./search-recordings-utils.js"; + +const SNIPPET_RADIUS = 80; + +function buildSnippet(text: string, query: string): string | null { + const index = text.toLowerCase().indexOf(query.toLowerCase()); + if (index === -1) return null; + const start = Math.max(0, index - SNIPPET_RADIUS); + const end = Math.min(text.length, index + query.length + SNIPPET_RADIUS); + return `${start > 0 ? "…" : ""}${text.slice(start, end).replace(/\s+/g, " ").trim()}${end < text.length ? "…" : ""}`; +} + +export default defineAction({ + description: + "Search dictations by their native or cleaned transcript text. Results are scoped to dictations the current user can access and include a short matching snippet.", + schema: z.object({ + query: z.string().min(1).describe("Search text"), + limit: z.coerce.number().int().min(1).max(100).default(30), + }), + http: { method: "GET" }, + run: async (args) => { + const db = getDb(); + const pattern = buildCaseInsensitiveSearchPattern(args.query); + const rows = await db + .select({ + id: schema.dictations.id, + fullText: schema.dictations.fullText, + cleanedText: schema.dictations.cleanedText, + durationMs: schema.dictations.durationMs, + source: schema.dictations.source, + targetApp: schema.dictations.targetApp, + startedAt: schema.dictations.startedAt, + createdAt: schema.dictations.createdAt, + }) + .from(schema.dictations) + .where( + and( + accessFilter(schema.dictations, schema.dictationShares), + sql`(lower(${schema.dictations.fullText}) LIKE ${pattern} ESCAPE '\\' OR lower(coalesce(${schema.dictations.cleanedText}, '')) LIKE ${pattern} ESCAPE '\\')`, + ), + ) + .orderBy(desc(schema.dictations.startedAt)) + .limit(args.limit); + + return { + query: args.query, + dictations: rows.map((dictation) => ({ + ...dictation, + snippet: + buildSnippet(dictation.cleanedText ?? "", args.query) ?? + buildSnippet(dictation.fullText, args.query), + })), + }; + }, + link: ({ result }) => { + if (!result || typeof result !== "object") return null; + const dictations = (result as { dictations?: unknown }).dictations; + if (!Array.isArray(dictations) || dictations.length === 0) return null; + const first = dictations[0] as { id?: string }; + if (!first.id) return null; + return { + url: buildDeepLink({ + app: "clips", + view: "dictate", + params: { dictationId: first.id }, + to: "/dictate", + }), + label: "Open Dictate in Clips", + view: "dictate", + }; + }, +}); diff --git a/templates/clips/app/bug-report-modal.test.ts b/templates/clips/app/bug-report-modal.test.ts new file mode 100644 index 00000000000..6f938f095e5 --- /dev/null +++ b/templates/clips/app/bug-report-modal.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +describe("Clips bug-report entry points", () => { + it("opens the shared dialog without navigating away from the app shell", () => { + const rootSource = readFileSync( + new URL("./root.tsx", import.meta.url), + "utf8", + ); + const dialogSource = readFileSync( + new URL("./components/bug-report/bug-report-dialog.tsx", import.meta.url), + "utf8", + ); + const feedbackSource = readFileSync( + new URL( + "./components/library/sidebar-feedback-button.tsx", + import.meta.url, + ), + "utf8", + ); + + expect(rootSource).toContain(""); + expect(dialogSource).toContain("OPEN_BUG_REPORT_EVENT"); + expect(dialogSource).toContain("