diff --git a/.changeset/agent-toggle-message-dots.md b/.changeset/agent-toggle-message-dots.md new file mode 100644 index 0000000000..01f5a8397b --- /dev/null +++ b/.changeset/agent-toggle-message-dots.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Use the Tabler message-dots icon for the agent sidebar toggle. diff --git a/.changeset/owner-null-org-db-tools.md b/.changeset/owner-null-org-db-tools.md new file mode 100644 index 0000000000..120fb06598 --- /dev/null +++ b/.changeset/owner-null-org-db-tools.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Keep agent DB tools scoped to owner rows when org context is active and rows have no org id. diff --git a/.changeset/quiet-desktop-webviews.md b/.changeset/quiet-desktop-webviews.md new file mode 100644 index 0000000000..ccfb3703e2 --- /dev/null +++ b/.changeset/quiet-desktop-webviews.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Suppress automatic stale route-chunk reloads inside the Agent Native desktop app. diff --git a/.changeset/secure-public-viewer-resolver.md b/.changeset/secure-public-viewer-resolver.md new file mode 100644 index 0000000000..74fb5e7bae --- /dev/null +++ b/.changeset/secure-public-viewer-resolver.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Harden the public-viewer anonymous-owner resolver: validate Referer origin, require the exact Builder callback path, and discard expired status connect URLs in the embedded settings panel. diff --git a/packages/core/src/client/AgentPanel.tsx b/packages/core/src/client/AgentPanel.tsx index 62c9a07187..963b046919 100644 --- a/packages/core/src/client/AgentPanel.tsx +++ b/packages/core/src/client/AgentPanel.tsx @@ -48,7 +48,7 @@ import { } from "./components/ui/dropdown-menu.js"; import { IconMessageCircle, - IconMessageChatbot, + IconMessageDots, IconTerminal2, IconSettings, IconLayoutSidebarRightCollapse, @@ -1906,7 +1906,7 @@ export function AgentToggleButton({ className }: { className?: string }) { className, )} > - + Toggle agent diff --git a/packages/core/src/client/route-chunk-recovery.spec.ts b/packages/core/src/client/route-chunk-recovery.spec.ts index 20f304d076..0fa85a9c4b 100644 --- a/packages/core/src/client/route-chunk-recovery.spec.ts +++ b/packages/core/src/client/route-chunk-recovery.spec.ts @@ -12,7 +12,7 @@ import { function createFakeWindow( startHref = "https://example.com/dispatch/apps", - opts: { lockReload?: boolean } = {}, + opts: { lockReload?: boolean; userAgent?: string } = {}, ) { const documentListeners = new Map(); const windowListeners = new Map(); @@ -63,6 +63,9 @@ function createFakeWindow( }, location: fakeLocation, history: fakeHistory, + navigator: { + userAgent: opts.userAgent ?? "Mozilla/5.0", + }, console: { error: vi.fn(), }, @@ -253,6 +256,41 @@ describe("route chunk recovery", () => { expect(originalReload).not.toHaveBeenCalled(); }); + it("suppresses stale route chunk auto-reloads inside Agent Native desktop", () => { + const { fakeWindow, fakeLocation, originalReload, dispatchDocument } = + createFakeWindow("https://example.com/dispatch/apps", { + userAgent: "Mozilla/5.0 Electron/41.2.2 AgentNativeDesktop/0.1.7", + }); + + installRouteChunkRecovery(fakeWindow); + + const anchor = { + tagName: "A", + href: "https://example.com/dispatch/new-app", + hasAttribute: () => false, + getAttribute: () => null, + parentElement: null, + }; + dispatchDocument("click", { + defaultPrevented: false, + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + target: anchor, + } as unknown as MouseEvent); + + fakeWindow.console.error( + "Error loading route module `/dispatch/assets/new-app-stale.js`, reloading page...", + ); + fakeLocation.reload(); + + expect(fakeLocation.assign).not.toHaveBeenCalled(); + expect(originalReload).not.toHaveBeenCalled(); + expect(fakeLocation.href).toBe("https://example.com/dispatch/apps"); + }); + it("recovers unhandled dynamic import rejections using the intended target", () => { const { fakeWindow, fakeLocation, dispatchDocument, dispatchWindow } = createFakeWindow(); @@ -290,6 +328,44 @@ describe("route chunk recovery", () => { expect(preventDefault).toHaveBeenCalled(); }); + it("suppresses unhandled dynamic import navigation inside Agent Native desktop", () => { + const { fakeWindow, fakeLocation, dispatchDocument, dispatchWindow } = + createFakeWindow("https://example.com/dispatch/apps", { + userAgent: "Mozilla/5.0 Electron/41.2.2 AgentNativeDesktop/0.1.7", + }); + + installRouteChunkRecovery(fakeWindow); + + const anchor = { + tagName: "A", + href: "https://example.com/dispatch/new-app", + hasAttribute: () => false, + getAttribute: () => null, + parentElement: null, + }; + dispatchDocument("click", { + defaultPrevented: false, + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + target: anchor, + } as unknown as MouseEvent); + + const preventDefault = vi.fn(); + dispatchWindow("unhandledrejection", { + reason: new Error( + "Failed to fetch dynamically imported module: https://example.com/dispatch/assets/new-app-stale.js", + ), + preventDefault, + } as unknown as PromiseRejectionEvent); + + expect(fakeLocation.assign).not.toHaveBeenCalled(); + expect(fakeLocation.href).toBe("https://example.com/dispatch/apps"); + expect(preventDefault).toHaveBeenCalled(); + }); + it("moves the current URL before reload when the browser will not let reload be patched", () => { const { fakeWindow, fakeLocation, originalReload, dispatchDocument } = createFakeWindow("https://example.com/dispatch/apps", { diff --git a/packages/core/src/client/route-chunk-recovery.ts b/packages/core/src/client/route-chunk-recovery.ts index 1f0be45f77..146b8a5260 100644 --- a/packages/core/src/client/route-chunk-recovery.ts +++ b/packages/core/src/client/route-chunk-recovery.ts @@ -106,6 +106,10 @@ function hardNavigate(win: Window, href: string): void { } } +function isAgentNativeDesktop(win: Window): boolean { + return /AgentNativeDesktop/i.test(win.navigator?.userAgent || ""); +} + function recoverToIntendedNavigation( win: Window, state: RouteChunkRecoveryState, @@ -114,6 +118,9 @@ function recoverToIntendedNavigation( if (!target) return false; state.recovering = true; state.recoveryHref = target; + // Desktop webviews stay open across many deploys; a forced navigation here + // reads as a random tab reload. Leave the current view alive instead. + if (isAgentNativeDesktop(win)) return true; try { win.history.replaceState(win.history.state, "", target); } catch {} @@ -139,6 +146,12 @@ function patchHistoryMethod( function patchReload(win: Window, state: RouteChunkRecoveryState): void { const originalReload = win.location.reload.bind(win.location); const patchedReload = function patchedReload() { + if ( + isAgentNativeDesktop(win) && + Date.now() - state.routeModuleFailureAt <= 1_000 + ) { + return; + } if ( state.recoveryHref && Date.now() - state.routeModuleFailureAt <= 1_000 diff --git a/packages/core/src/client/settings/useBuilderStatus.ts b/packages/core/src/client/settings/useBuilderStatus.ts index a4ee53786f..b1974327c5 100644 --- a/packages/core/src/client/settings/useBuilderStatus.ts +++ b/packages/core/src/client/settings/useBuilderStatus.ts @@ -155,6 +155,11 @@ export function useBuilderConnectFlow( const [error, setError] = useState(null); const [hasFetchedStatus, setHasFetchedStatus] = useState(false); const [statusConnectUrl, setStatusConnectUrl] = useState(null); + // When statusConnectUrl was last fetched. The server signs the embedded + // _an_connect token with a 10-minute TTL; using an older URL silently + // fails the same-origin check on the popup side. Track freshness so + // start() can fall back to the bare /builder/connect path when stale. + const statusConnectUrlAtRef = useRef(null); const pollRef = useRef | null>(null); const mountedRef = useRef(true); const notifiedConnectedRef = useRef(false); @@ -210,6 +215,7 @@ export function useBuilderConnectFlow( setEnvManaged(!!s.envManaged); setBuilderEnabled(!!s.builderEnabled); setStatusConnectUrl(s.connectUrl ?? null); + statusConnectUrlAtRef.current = s.connectUrl ? Date.now() : null; const org = s.orgName ?? null; setOrgName(org); if (s.configured && !notifiedConnectedRef.current) { @@ -254,8 +260,17 @@ export function useBuilderConnectFlow( // before window.open lets the user-gesture token expire, which causes // popup blockers to block entirely or fall back to same-tab navigation. const origin = getCallbackOrigin() || window.location.origin; + // The signed _an_connect token in statusConnectUrl has a 10-minute TTL. + // If the panel has been open longer than that the token is dead and the + // popup will silently 403; drop the cached URL and let the bare /connect + // route do the same-origin Sec-Fetch-Site check instead. + const STATUS_CONNECT_URL_TTL_MS = 9 * 60 * 1000; + const cachedAt = statusConnectUrlAtRef.current; + const cachedFresh = + typeof cachedAt === "number" && + Date.now() - cachedAt < STATUS_CONNECT_URL_TTL_MS; const url = - statusConnectUrl ?? + (cachedFresh ? statusConnectUrl : null) ?? popupUrl ?? new URL(agentNativePath("/_agent-native/builder/connect"), origin).href; try { @@ -278,6 +293,7 @@ export function useBuilderConnectFlow( setEnvManaged(!!s.envManaged); setBuilderEnabled(!!s.builderEnabled); setStatusConnectUrl(s.connectUrl ?? null); + statusConnectUrlAtRef.current = s.connectUrl ? Date.now() : null; const org = s.orgName ?? null; setOrgName(org); setConnecting(false); diff --git a/packages/core/src/scripts/db/exec.ts b/packages/core/src/scripts/db/exec.ts index a30afa1a15..95b12c184a 100644 --- a/packages/core/src/scripts/db/exec.ts +++ b/packages/core/src/scripts/db/exec.ts @@ -368,10 +368,18 @@ function sqliteScopePredicate( } const clauses: string[] = []; - if (scoping.userEmail && scoping.ownerEmailTables.has(tableName)) { - clauses.push(`owner_email = '${escapeSqlString(scoping.userEmail)}'`); - } - if (scoping.orgId && scoping.orgIdTables.has(tableName)) { + const hasOwner = scoping.ownerEmailTables.has(tableName); + const hasOrg = scoping.orgIdTables.has(tableName); + if (scoping.userEmail && hasOwner) { + const ownerClause = `owner_email = '${escapeSqlString(scoping.userEmail)}'`; + if (scoping.orgId && hasOrg) { + clauses.push( + `${ownerClause} AND (org_id = '${escapeSqlString(scoping.orgId)}' OR org_id IS NULL)`, + ); + } else { + clauses.push(ownerClause); + } + } else if (scoping.orgId && hasOrg) { clauses.push(`org_id = '${escapeSqlString(scoping.orgId)}'`); } return clauses.length > 0 ? clauses.join(" AND ") : null; diff --git a/packages/core/src/scripts/db/parameterized.spec.ts b/packages/core/src/scripts/db/parameterized.spec.ts index 58fcb72c1e..1c83084b31 100644 --- a/packages/core/src/scripts/db/parameterized.spec.ts +++ b/packages/core/src/scripts/db/parameterized.spec.ts @@ -248,7 +248,7 @@ describe("db scripts parameterized SQL", () => { ]); expect(execute).toHaveBeenCalledWith({ - sql: `UPDATE main."notes" SET title = ? WHERE owner_email = 'script+qa-alice@example.com' AND org_id = 'org-qa-1' AND (id = ?)`, + sql: `UPDATE main."notes" SET title = ? WHERE owner_email = 'script+qa-alice@example.com' AND (org_id = 'org-qa-1' OR org_id IS NULL) AND (id = ?)`, args: ["Scoped title", "note-qa-1"], }); }); diff --git a/packages/core/src/scripts/db/scoping.spec.ts b/packages/core/src/scripts/db/scoping.spec.ts index 00a98daf00..6772c742ca 100644 --- a/packages/core/src/scripts/db/scoping.spec.ts +++ b/packages/core/src/scripts/db/scoping.spec.ts @@ -201,11 +201,12 @@ describe("scoping", () => { expect(ctx.active).toBe(true); expect(ctx.orgId).toBe("org-123"); - // notes has both owner_email AND org_id — both should appear + // notes has both owner_email AND org_id — the user owns rows in the + // current org plus legacy/personal rows with no org. const notesView = ctx.setup.find((s) => s.includes('"notes"')); expect(notesView).toContain('"owner_email" = '); expect(notesView).toContain('"org_id" = '); - expect(notesView).toContain("AND"); + expect(notesView).toContain('OR "org_id" IS NULL'); // org_only_table has only org_id const orgOnlyView = ctx.setup.find((s) => s.includes('"org_only_table"')); @@ -253,6 +254,37 @@ describe("scoping", () => { expect(notesView).not.toContain("org_id"); }); + it("keeps owner legacy rows visible when org scoping is active", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("AGENT_USER_EMAIL", "legacy-owner@test.com"); + vi.stubEnv("AGENT_ORG_ID", "org-current"); + const { buildScopingSqlite } = await import("./scoping.js"); + + const mockClient = { + execute: vi.fn().mockImplementation((sql: string) => { + if (sql.includes("sqlite_master")) { + return { rows: [{ name: "decks" }] }; + } + return { + rows: [ + { name: "id" }, + { name: "owner_email" }, + { name: "org_id" }, + { name: "title" }, + ], + }; + }), + }; + + const ctx = await buildScopingSqlite(mockClient); + const decksView = ctx.setup.find((s) => s.includes('"decks"')); + + expect(decksView).toContain(`"owner_email" = 'legacy-owner@test.com'`); + expect(decksView).toContain( + `("org_id" = 'org-current' OR "org_id" IS NULL)`, + ); + }); + it("scopes tool_data to private user rows plus matching org rows", async () => { vi.stubEnv("NODE_ENV", "production"); vi.stubEnv("AGENT_USER_EMAIL", "tools+qa@test.com"); @@ -427,5 +459,28 @@ describe("scoping", () => { expect(ctx.ownerEmailTables.has("tasks")).toBe(true); }); + + it("keeps owner legacy rows visible in postgres when org scoping is active", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("AGENT_USER_EMAIL", "legacy-pg@test.com"); + vi.stubEnv("AGENT_ORG_ID", "org-pg"); + const { buildScopingPostgres } = await import("./scoping.js"); + + const mockPgSql: any = async function (): Promise { + return [ + { table_name: "decks", column_name: "id" }, + { table_name: "decks", column_name: "owner_email" }, + { table_name: "decks", column_name: "org_id" }, + { table_name: "decks", column_name: "title" }, + ]; + }; + + const ctx = await buildScopingPostgres(mockPgSql); + const decksView = ctx.setup.find((s) => s.includes('"decks"')); + + expect(decksView).toContain(`"owner_email" = 'legacy-pg@test.com'`); + expect(decksView).toContain(`("org_id" = 'org-pg' OR "org_id" IS NULL)`); + expect(decksView).toContain("WITH LOCAL CHECK OPTION"); + }); }); }); diff --git a/packages/core/src/scripts/db/scoping.ts b/packages/core/src/scripts/db/scoping.ts index da1b838a98..3dd90df271 100644 --- a/packages/core/src/scripts/db/scoping.ts +++ b/packages/core/src/scripts/db/scoping.ts @@ -8,7 +8,8 @@ * - Template tables use an `owner_email` column for user scoping. * - Template tables use an `org_id` column for org scoping. * - Core tables have their own scoping patterns (key prefix, session_id, etc.). - * - When both columns are present, both WHERE clauses are applied (AND). + * - When both columns are present, owner_email is always required; org_id + * narrows to the current org while preserving legacy/personal NULL rows. * * Temp views take precedence over real tables in both SQLite and Postgres, * so the user's SQL runs unmodified against the filtered views. @@ -175,23 +176,27 @@ function buildScopedTables( continue; } - // Build WHERE clauses for owner_email and org_id - const clauses: string[] = []; const hasOwner = columns.includes(OWNER_COLUMN); const hasOrg = columns.includes(ORG_COLUMN); if (hasOwner) { - clauses.push(`"${OWNER_COLUMN}" = '${safeEmail}'`); - } - if (hasOrg && safeOrgId) { - clauses.push(`"${ORG_COLUMN}" = '${safeOrgId}'`); + const orgClause = + hasOrg && safeOrgId + ? ` AND ("${ORG_COLUMN}" = '${safeOrgId}' OR "${ORG_COLUMN}" IS NULL)` + : ""; + const realTable = `${qualifiedPrefix}"${table}"`; + scoped.push({ + name: table, + viewSql: `${isPostgres ? "CREATE OR REPLACE TEMPORARY" : "CREATE TEMPORARY"} VIEW "${table}" AS SELECT * FROM ${realTable} WHERE "${OWNER_COLUMN}" = '${safeEmail}'${orgClause}${checkOption}`, + }); + continue; } - if (clauses.length > 0) { + if (hasOrg && safeOrgId) { const realTable = `${qualifiedPrefix}"${table}"`; scoped.push({ name: table, - viewSql: `${isPostgres ? "CREATE OR REPLACE TEMPORARY" : "CREATE TEMPORARY"} VIEW "${table}" AS SELECT * FROM ${realTable} WHERE ${clauses.join(" AND ")}${checkOption}`, + viewSql: `${isPostgres ? "CREATE OR REPLACE TEMPORARY" : "CREATE TEMPORARY"} VIEW "${table}" AS SELECT * FROM ${realTable} WHERE "${ORG_COLUMN}" = '${safeOrgId}'${checkOption}`, }); } } diff --git a/packages/desktop-app/src/main/index.ts b/packages/desktop-app/src/main/index.ts index 017e9276bd..834b287935 100644 --- a/packages/desktop-app/src/main/index.ts +++ b/packages/desktop-app/src/main/index.ts @@ -213,7 +213,15 @@ async function handleDeepLink(url: string) { ...pendingTarget, }); } else { - reloadAllWebviews(); + const state = parsed.searchParams.get("state"); + const pendingTarget = consumeOAuthState(state); + if (pendingTarget) { + reloadWebviewsForTarget(pendingTarget); + } else { + console.warn( + "[main] ignored oauth-complete deep link without token or matching OAuth state", + ); + } } } } catch { @@ -1069,6 +1077,52 @@ function openOAuthWindow( } const webviewOAuthNavigationHandlers = new WeakSet(); +const webviewReloadGuardHandlers = new WeakSet(); +const routeChunkReloadBlockedUntil = new WeakMap< + Electron.WebContents, + number +>(); + +function isRouteChunkReloadMessage(message: string): boolean { + return ( + /Error loading route module `[^`]+`, reloading page\.\.\./.test(message) || + message.includes("Failed to fetch dynamically imported module") || + message.includes("error loading dynamically imported module") || + message.includes("Importing a module script failed") + ); +} + +function installWebviewReloadGuard(contents: Electron.WebContents) { + if (webviewReloadGuardHandlers.has(contents)) return; + webviewReloadGuardHandlers.add(contents); + + // Stale React Router chunks can ask the page to reload after a deploy. + // In the desktop shell, block that renderer-initiated refresh and let the + // user choose when to manually refresh the app. + contents.on( + "console-message", + (_event, _level, message: string | undefined) => { + if (!message || !isRouteChunkReloadMessage(message)) return; + routeChunkReloadBlockedUntil.set(contents, Date.now() + 2_000); + }, + ); + + contents.on("will-navigate", (event, url) => { + const blockUntil = routeChunkReloadBlockedUntil.get(contents) ?? 0; + if (Date.now() > blockUntil) return; + try { + const current = new URL(contents.getURL()); + const next = new URL(url); + if (current.origin !== next.origin) return; + } catch { + return; + } + event.preventDefault(); + console.warn( + "[main] blocked renderer-initiated reload after stale route chunk failure", + ); + }); +} function openOAuthFromWebviewNavigation( url: string, @@ -1119,6 +1173,7 @@ app.on("web-contents-created", (_event, contents) => { if (contents.getType() !== "webview") { contents.on("did-attach-webview" as any, (_e: any, wc: any) => { installContextMenu(wc); + installWebviewReloadGuard(wc); installWebviewOAuthNavigationHandler(wc); wc.setWindowOpenHandler(({ url }: any) => { @@ -1142,6 +1197,7 @@ app.on("web-contents-created", (_event, contents) => { return; } + installWebviewReloadGuard(contents); installWebviewOAuthNavigationHandler(contents); contents.setWindowOpenHandler(({ url }) => { diff --git a/packages/desktop-app/src/renderer/components/AppWebview.tsx b/packages/desktop-app/src/renderer/components/AppWebview.tsx index 773c711a7a..39829eeea2 100644 --- a/packages/desktop-app/src/renderer/components/AppWebview.tsx +++ b/packages/desktop-app/src/renderer/components/AppWebview.tsx @@ -351,7 +351,7 @@ const AppWebview = forwardRef( wv.setAttribute("allowpopups", ""); wv.setAttribute( "webpreferences", - "contextIsolation=true,nodeIntegration=false,sandbox=true", + "contextIsolation=true,nodeIntegration=false,sandbox=true,backgroundThrottling=false", ); wv.setAttribute("partition", `persist:app-${app.id}`); wv.setAttribute("src", url); diff --git a/templates/calendar/app/root.tsx b/templates/calendar/app/root.tsx index d7f7efb514..22bd83f992 100644 --- a/templates/calendar/app/root.tsx +++ b/templates/calendar/app/root.tsx @@ -126,7 +126,7 @@ export default function Root() { > - + {}}>Search diff --git a/templates/calls/app/components/library/library-sidebar.tsx b/templates/calls/app/components/library/library-sidebar.tsx index 8f4a5e4be2..ac298d56d0 100644 --- a/templates/calls/app/components/library/library-sidebar.tsx +++ b/templates/calls/app/components/library/library-sidebar.tsx @@ -16,8 +16,7 @@ import { IconBookmark, IconSun, IconMoon, - IconMessageChatbot, - IconBolt, + IconMessageDots, IconTarget, } from "@tabler/icons-react"; import { useTheme } from "next-themes"; @@ -535,7 +534,7 @@ function SidebarFooter() { {isDark ? "Light theme" : "Dark theme"} openAgentSidebar()}> - + Open agent @@ -561,7 +560,7 @@ function SidebarFooter() { className="flex items-center justify-center gap-1 rounded px-2 py-1 text-[10px] text-muted-foreground hover:bg-accent hover:text-foreground" aria-label="Open agent" > - + Agent diff --git a/templates/clips/actions/finalize-recording.ts b/templates/clips/actions/finalize-recording.ts index 71b25dd09f..058550bcd2 100644 --- a/templates/clips/actions/finalize-recording.ts +++ b/templates/clips/actions/finalize-recording.ts @@ -1,7 +1,8 @@ /** * Finalize a recording — assemble chunks, upload the final blob, * update the recording row, flip status to 'processing' → 'ready', - * and trigger a background agent chat to produce title/summary/chapters. + * and request transcription. Title generation is queued by the transcript + * path once usable transcript text exists. * * Usage: * pnpm action finalize-recording --id= @@ -377,30 +378,6 @@ export default defineAction({ }); }); - // Queue a background agent run by writing a structured message to - // application_state. The frontend's agent-chat-bridge picks up - // `agent-task-*` keys and dispatches sendToAgentChat({ background: true }). - await writeAppState(`agent-task-recording-${id}`, { - kind: "post-recording-processing", - recordingId: id, - background: true, - openSidebar: false, - message: [ - `A new recording (${id}) just finished uploading.`, - "Please do the following in the background:", - "1. If a transcript is already ready, generate a 1-2 sentence summary and store it in the description.", - "2. If a transcript is already ready, produce a short chapters list (chaptersJson: [{startMs, title}]) and save it on the recording row.", - "3. Do not transcribe or invent transcript text. Transcripts come from the web/macOS native transcription capture and may be cleaned up asynchronously.", - "Do NOT prompt the user — this is a silent background task.", - ].join("\n"), - context: { - recordingId: id, - videoUrl, - durationMs: args.durationMs ?? 0, - }, - createdAt: new Date().toISOString(), - }); - // Emit clip.created event — best-effort, never block the main flow. try { emit( diff --git a/templates/clips/actions/regenerate-title.ts b/templates/clips/actions/regenerate-title.ts index 36b04540ea..cef4264598 100644 --- a/templates/clips/actions/regenerate-title.ts +++ b/templates/clips/actions/regenerate-title.ts @@ -16,6 +16,22 @@ import { getDb, schema } from "../server/db/index.js"; import { writeAppState } from "@agent-native/core/application-state"; import { assertAccess } from "@agent-native/core/sharing"; +function transcriptTextFromSegments(raw: string | null | undefined): string { + if (!raw) return ""; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return ""; + return parsed + .map((segment) => + typeof segment?.text === "string" ? segment.text.trim() : "", + ) + .filter(Boolean) + .join("\n"); + } catch { + return ""; + } +} + export default defineAction({ description: "Ask the agent to regenerate this recording's title based on its transcript. The agent reads the transcript from the delegation context and calls update-recording with the new title.", @@ -42,7 +58,15 @@ export default defineAction({ .where(eq(schema.recordingTranscripts.recordingId, args.recordingId)) .limit(1); - const transcriptText = transcript?.fullText?.trim() ?? ""; + const transcriptText = + transcript?.fullText?.trim() || + transcriptTextFromSegments(transcript?.segmentsJson); + if (transcript?.status !== "ready" || !transcriptText) { + throw new Error( + "Transcript is not ready yet. Try again after transcription finishes.", + ); + } + const request = { kind: "regenerate-title" as const, recordingId: args.recordingId, @@ -50,11 +74,13 @@ export default defineAction({ currentTitle: rec.title, transcriptStatus: transcript?.status ?? "pending", transcriptText, + segmentsJson: transcript?.segmentsJson ?? "[]", message: `Regenerate the title for recording ${args.recordingId}. ` + `Read the transcript in this request's context and call ` + `\`update-recording --id=${args.recordingId} --title="..."\` with a concise ` + - `4-9 word descriptive title. Current title: "${rec.title}".`, + `4-9 word descriptive title. Current title: "${rec.title}". ` + + "Do not prompt the user.", }; await writeAppState(`clips-ai-request-${args.recordingId}`, request as any); diff --git a/templates/clips/app/components/library/library-layout.tsx b/templates/clips/app/components/library/library-layout.tsx index 59fdd05009..df11a8f251 100644 --- a/templates/clips/app/components/library/library-layout.tsx +++ b/templates/clips/app/components/library/library-layout.tsx @@ -12,7 +12,7 @@ import { IconAppWindow, IconX, IconMenu2, - IconLayoutSidebarRightExpand, + IconMessageDots, } from "@tabler/icons-react"; import { AgentSidebar, @@ -66,7 +66,7 @@ function ClipsAgentToggleButton() { className="ml-1.5 flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground" aria-label="Toggle agent panel" > - + Toggle agent diff --git a/templates/clips/app/components/player/share-dialog.tsx b/templates/clips/app/components/player/share-dialog.tsx index cb2affa73f..3ee70e1368 100644 --- a/templates/clips/app/components/player/share-dialog.tsx +++ b/templates/clips/app/components/player/share-dialog.tsx @@ -1,4 +1,5 @@ import { useMemo, useState, type ReactNode } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { IconTrash, IconLock, @@ -10,7 +11,11 @@ import { IconMail, IconCode, } from "@tabler/icons-react"; -import { useActionQuery, useActionMutation } from "@agent-native/core/client"; +import { + appPath, + useActionQuery, + useActionMutation, +} from "@agent-native/core/client"; import { Popover, PopoverTrigger, @@ -75,6 +80,15 @@ const ROLE_OPTIONS: Array<{ value: Role; label: string }> = [ { value: "admin", label: "Admin" }, ]; +function absoluteAppUrl(path: string): string { + if (typeof window === "undefined") return ""; + return new URL(appPath(path), window.location.origin).toString(); +} + +function copyToClipboard(value: string): void { + navigator.clipboard.writeText(value).catch(() => {}); +} + export interface ShareRecordingPopoverProps { recordingId: string; recordingTitle?: string; @@ -251,24 +265,21 @@ function LinkTab({ videoUrl?: string | null; animatedThumbnailUrl?: string | null; }) { - const setVisibility = useActionMutation("set-resource-visibility"); + const { setRecordingVisibility, isPending } = useRecordingVisibilityMutation( + recordingId, + sharesQuery, + ); const data = sharesQuery.data; const visibility: Visibility = (data?.visibility as Visibility | null) ?? "private"; + const isPublic = visibility === "public"; const canManage = data?.role === "owner" || data?.role === "admin" || !data?.role; const meta = VIS_META[visibility]; const handleVisibility = (next: string) => { if (next === visibility) return; - setVisibility.mutate( - { - resourceType: "recording", - resourceId: recordingId, - visibility: next, - }, - { onSuccess: () => sharesQuery.refetch() }, - ); + setRecordingVisibility(next as Visibility); }; return ( @@ -286,7 +297,7 @@ function LinkTab({