From a9469f7de9574052afa971271022a7f39365def1 Mon Sep 17 00:00:00 2001 From: Shawn McClelland Date: Tue, 8 Sep 2026 13:58:26 -0700 Subject: [PATCH 01/21] Update Clips recording and agent workflows --- .changeset/quiet-command-menu-shortcut.md | 5 + docs/command-menu-architecture.md | 123 ++++ packages/core/src/client/CommandMenu.spec.tsx | 17 +- packages/core/src/client/CommandMenu.tsx | 6 +- .../clips/.agents/skills/dictate/SKILL.md | 1 + .../clips/actions/list-recordings.test.ts | 49 ++ templates/clips/actions/list-recordings.ts | 2 + templates/clips/actions/search-dictations.ts | 81 +++ templates/clips/app/bug-report-modal.test.ts | 29 + .../bug-report/bug-report-dialog.tsx | 60 ++ .../components/bug-report/bug-report-form.tsx | 209 ++++++ .../app/components/clips-command-menu.tsx | 647 ++++++++++++++++++ .../app/components/library/empty-state.tsx | 69 +- .../app/components/library/folder-card.tsx | 34 + .../app/components/library/folder-tree.tsx | 82 ++- .../library/library-grid-layout.test.ts | 70 +- .../app/components/library/library-grid.tsx | 324 ++++++--- .../app/components/library/library-layout.tsx | 500 +++++++++----- .../library/library-primary-actions.tsx | 57 ++ .../app/components/library/page-header.tsx | 5 +- .../components/library/recording-card.test.ts | 17 + .../app/components/library/recording-card.tsx | 113 +-- .../components/library/search-bar.test.tsx | 8 +- .../app/components/library/search-bar.tsx | 24 +- .../library/sidebar-feedback-button.tsx | 48 ++ .../components/player/comments-panel.test.tsx | 11 + .../app/components/player/comments-panel.tsx | 4 +- .../app/components/player/viewer-controls.tsx | 4 +- templates/clips/app/components/ui/tabs.tsx | 2 +- .../clips/app/hooks/use-command-search.ts | 53 ++ .../app/hooks/use-upload-video-picker.tsx | 1 + templates/clips/app/i18n/ar-SA.ts | 2 + templates/clips/app/i18n/de-DE.ts | 2 + templates/clips/app/i18n/en-US.ts | 2 + templates/clips/app/i18n/es-ES.ts | 2 + templates/clips/app/i18n/fr-FR.ts | 2 + templates/clips/app/i18n/hi-IN.ts | 2 + templates/clips/app/i18n/ja-JP.ts | 2 + templates/clips/app/i18n/ko-KR.ts | 2 + templates/clips/app/i18n/pt-BR.ts | 2 + templates/clips/app/i18n/zh-CN.ts | 2 + templates/clips/app/i18n/zh-TW.ts | 2 + templates/clips/app/lib/command-events.ts | 7 + .../clips/app/root-search-command.test.ts | 20 +- templates/clips/app/root.tsx | 88 +-- templates/clips/app/routes/_app.dictate.tsx | 26 +- .../clips/app/routes/_app.library._index.tsx | 32 +- .../routes/_app.library.folder.$folderId.tsx | 2 + .../clips/app/routes/_app.meetings._index.tsx | 19 +- .../app/routes/_app.r.$recordingId.test.ts | 33 +- .../clips/app/routes/_app.r.$recordingId.tsx | 54 +- .../_app.spaces.$spaceId.folder.$folderId.tsx | 4 + .../clips/app/routes/_app.spaces.$spaceId.tsx | 2 + .../clips/app/routes/_app.spaces._index.tsx | 56 +- templates/clips/app/routes/bug-report.tsx | 199 +----- .../app/routes/share-auth-loading.test.ts | 18 +- templates/clips/app/routes/share.$shareId.tsx | 261 +++---- ...08-comments-and-desktop-window-controls.md | 5 + .../2026-09-08-global-command-search.md | 5 + .../data/pglite.agent-native-pglite.lock | 1 + templates/clips/desktop/src-tauri/src/lib.rs | 28 +- templates/clips/desktop/src-tauri/src/util.rs | 10 +- templates/clips/desktop/src/styles.css | 7 +- 63 files changed, 2629 insertions(+), 925 deletions(-) create mode 100644 .changeset/quiet-command-menu-shortcut.md create mode 100644 docs/command-menu-architecture.md create mode 100644 templates/clips/actions/search-dictations.ts create mode 100644 templates/clips/app/bug-report-modal.test.ts create mode 100644 templates/clips/app/components/bug-report/bug-report-dialog.tsx create mode 100644 templates/clips/app/components/bug-report/bug-report-form.tsx create mode 100644 templates/clips/app/components/clips-command-menu.tsx create mode 100644 templates/clips/app/components/library/folder-card.tsx create mode 100644 templates/clips/app/components/library/library-primary-actions.tsx create mode 100644 templates/clips/app/components/library/sidebar-feedback-button.tsx create mode 100644 templates/clips/app/hooks/use-command-search.ts create mode 100644 templates/clips/app/lib/command-events.ts create mode 100644 templates/clips/changelog/2026-09-08-comments-and-desktop-window-controls.md create mode 100644 templates/clips/changelog/2026-09-08-global-command-search.md create mode 100644 templates/clips/data/pglite.agent-native-pglite.lock 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/client/CommandMenu.spec.tsx b/packages/core/src/client/CommandMenu.spec.tsx index 8cf901e3e9a..8d70f4695ba 100644 --- a/packages/core/src/client/CommandMenu.spec.tsx +++ b/packages/core/src/client/CommandMenu.spec.tsx @@ -386,7 +386,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 +408,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..0e834c72316 100644 --- a/packages/core/src/client/CommandMenu.tsx +++ b/packages/core/src/client/CommandMenu.tsx @@ -667,6 +667,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 +683,6 @@ export function useCommandMenuShortcut( ) { return; } - e.preventDefault(); onOpen(); } }; 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/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("( + null, + ); + + useEffect(() => { + const handleOpen = () => { + setInitialContext(currentBugReportContext()); + setOpen(true); + }; + window.addEventListener(OPEN_BUG_REPORT_EVENT, handleOpen); + return () => window.removeEventListener(OPEN_BUG_REPORT_EVENT, handleOpen); + }, []); + + return ( + + + + {t("bugReportRoute.title")} + + {open ? ( +
+ setOpen(false)} + /> +
+ ) : null} +
+
+ ); +} diff --git a/templates/clips/app/components/bug-report/bug-report-form.tsx b/templates/clips/app/components/bug-report/bug-report-form.tsx new file mode 100644 index 00000000000..cc655a63046 --- /dev/null +++ b/templates/clips/app/components/bug-report/bug-report-form.tsx @@ -0,0 +1,209 @@ +import { appBasePath } from "@agent-native/core/client/api-path"; +import { useT } from "@agent-native/core/client/i18n"; +import { + bugReportContextToSearchParams, + type BugReportContext, + type BugReportSeverity, +} from "@shared/bug-report"; +import { IconBug, IconShieldCheck } from "@tabler/icons-react"; +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +interface BugReportFormProps { + className?: string; + initialContext?: BugReportContext | null; + onRecorderOpened?: (recorderWindow: Window | null) => void; + onRecordingStarted?: () => void; +} + +function openRecorder(url: string): Window | null { + const opened = window.open( + url, + "agent-native-clips-bug-report", + "popup,width=1120,height=820", + ); + if (!opened) { + window.location.href = url; + return null; + } + opened.focus(); + return opened; +} + +export function BugReportForm({ + className, + initialContext, + onRecorderOpened, + onRecordingStarted, +}: BugReportFormProps) { + const t = useT(); + const [title, setTitle] = useState(initialContext?.title ?? ""); + const [description, setDescription] = useState( + initialContext?.description ?? "", + ); + const [reporterEmail, setReporterEmail] = useState( + initialContext?.reporterEmail ?? "", + ); + const [severity, setSeverity] = useState( + initialContext?.severity ?? "normal", + ); + const [referrer, setReferrer] = useState(null); + + useEffect(() => { + setReferrer(document.referrer || null); + }, []); + + const sourceUrl = initialContext?.sourceUrl ?? referrer; + const pageTitle = initialContext?.pageTitle ?? null; + const sourceLabel = + pageTitle || sourceUrl || t("bugReportRoute.sourceUnknown"); + + const startRecording = () => { + const context: BugReportContext = { + projectId: initialContext?.projectId ?? null, + title: title.trim() || initialContext?.title || null, + description: description.trim() || initialContext?.description || null, + severity, + sourceUrl, + pageTitle, + appVersion: initialContext?.appVersion ?? null, + environment: initialContext?.environment ?? null, + reporterEmail: + reporterEmail.trim() || initialContext?.reporterEmail || null, + reporterName: initialContext?.reporterName ?? null, + reporterId: initialContext?.reporterId ?? null, + metadata: initialContext?.metadata ?? null, + returnUrl: initialContext?.returnUrl ?? sourceUrl, + }; + const params = bugReportContextToSearchParams(context); + params.set("intent", "bug-report"); + params.set("mode", "screen"); + params.set("surface", "browser"); + const recorderWindow = openRecorder( + `${appBasePath()}/record?${params.toString()}`, + ); + onRecorderOpened?.(recorderWindow); + onRecordingStarted?.(); + }; + + return ( +
+
+
+ +
+
+

+ {t("bugReportRoute.eyebrow")} +

+

+ {t("bugReportRoute.title")} +

+

+ {t("bugReportRoute.description")} +

+
+
+ +
+
+ + setTitle(event.target.value)} + placeholder={t("bugReportRoute.issueTitlePlaceholder")} + /> +
+ +
+ +