diff --git a/docs/plans/2026-07-29-clips-display-name-prompt.md b/docs/plans/2026-07-29-clips-display-name-prompt.md
new file mode 100644
index 0000000000..323d4bb814
--- /dev/null
+++ b/docs/plans/2026-07-29-clips-display-name-prompt.md
@@ -0,0 +1,902 @@
+# Clips Display-Name Prompt Implementation Plan
+
+> **For the Fusion agent:** Execute this plan task-by-task. Each step is one
+> action. Do not skip steps. Verify after each task. Commit after each task.
+
+**Goal:** Capture a real display name from signed-in Clips users who don't have
+one, via a friendly modal at two natural moments, instead of requiring them to
+find the field in Settings.
+
+**Architecture:** One shared shadcn `Dialog` component with two copy variants,
+driven by one gate hook. The net-new variant mounts on the watch page while the
+user's first recording is still processing; the returning variant mounts on the
+library index. Both write through the existing core `update-user-profile`
+action. A soft "skip" is remembered in `sessionStorage` only, so we ask again
+next browser session.
+
+**Tech Stack:** React Router 7, TanStack Query, shadcn/ui, Tabler icons,
+`@agent-native/core` actions + i18n, Vitest.
+
+---
+
+## Background the implementer needs
+
+Read this before Task 1. Three non-obvious facts drive the whole design.
+
+### 1. No user ever has an empty name
+
+At signup, Better Auth is handed the email local part as the name:
+
+- `packages/core/src/server/auth.ts:3332` — `name: email.split("@")[0]`
+- `packages/core/src/server/auth.ts:3568` — same
+
+And the read path substitutes the full email when nothing is stored:
+
+- `packages/core/src/user-profile/store.ts:19-29` — `getUserProfile()` returns
+ `normalizeUserProfileName(storedName ?? authName, email)`
+- `packages/core/src/user-profile/shared.ts:8-14` — that helper returns `email`
+ when the name is blank
+
+So `get-user-profile` **always** returns a non-empty `name`. A check like
+`if (!profile.name)` matches zero users and the modal would never appear.
+
+The only workable predicate is "the name still looks auto-derived":
+
+```
+name === email || name === email.split("@")[0]
+```
+
+This has a known false positive: a user genuinely named `tim` at `tim@…` looks
+identical to one who never set a name.
+
+**That ambiguity only applies to returning users.** A brand-new signup's name is
+the local part *by construction* — core auth wires no social/OAuth provider, so
+`auth.ts:3332` and `:3568` are the only ways a user row is created. So the two
+surfaces use different predicates:
+
+| Surface | Predicate | Why |
+| --- | --- | --- |
+| Net-new (watch page) | `isAutoDerivedName()` — local part, full email, or blank | They cannot have chosen a name yet. Ask openly. |
+| Returning (library) | `matchesEmailLocalPart()` — exactly the local part | Narrower on purpose. Frame it as a confirmation, and never bother anyone whose name differs. |
+
+### 2. There are two display-name stores, and one is dead
+
+| Store | Written by | Read by |
+| --- | --- | --- |
+| Core `user-profile` (Better Auth `user.name`) | `AccountSettingsCard` on the **Account** tab, `_app.settings._index.tsx:840` | `server/jobs/transactional-emails.ts:410` — the sender name on notification emails share recipients receive |
+| `clips-user-prefs.displayName` | Hand-rolled Profile card on the **General** tab, `_app.settings._index.tsx:1480-1503` | **Nothing.** Grep confirms zero consumers. |
+
+The core store is the real one. Task 6 deletes the dead card and its key.
+
+### 3. `application_state` is per-user forever, not per-session
+
+Despite the column name, `getSessionId()` returns the user's email:
+
+- `packages/core/src/application-state/handlers.ts:24-33`
+
+So `application_state` cannot express "ask again next session". Use
+`sessionStorage` for the soft skip. This matches how Clips already stores
+dismissals (`app/lib/capture-install-options.ts:12-64` uses `localStorage`);
+we want the shorter lifetime.
+
+### Product decisions already made
+
+- **One store.** Modal writes to core `update-user-profile`; the dead Clips
+ Profile card is deleted.
+- **Returning users are prompted on the library index**, not the watch page,
+ and only when their name is exactly the email local part. The dialog is a
+ confirmation pre-filled with that value, not an empty ask.
+- **Skip is soft** — we ask again next browser session.
+
+---
+
+## Task 1: Shared prompt dialog component
+
+**Files:**
+
+- Create: `templates/clips/app/components/profile/name-prompt-dialog.tsx`
+
+**Step 1: Create the directory**
+
+```bash
+mkdir -p code/templates/clips/app/components/profile
+```
+
+**Step 2: Create the file with this exact content**
+
+```tsx
+import { useActionMutation } from "@agent-native/core/client/hooks";
+import { useT } from "@agent-native/core/client/i18n";
+import { useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+const MAX_NAME_LENGTH = 120;
+
+export type NamePromptVariant = "welcome" | "welcome-back";
+
+export interface NamePromptDialogProps {
+ open: boolean;
+ variant: NamePromptVariant;
+ /**
+ * Pre-filled field value. The `welcome-back` variant passes the email local
+ * part so the user can confirm it with one click; `welcome` passes nothing.
+ */
+ initialName?: string;
+ /** Called with `true` after a successful save, `false` on skip/dismiss. */
+ onResolved: (saved: boolean) => void;
+}
+
+export function NamePromptDialog({
+ open,
+ variant,
+ initialName = "",
+ onResolved,
+}: NamePromptDialogProps) {
+ const t = useT();
+ const queryClient = useQueryClient();
+ const [name, setName] = useState(initialName);
+ const [seededFor, setSeededFor] = useState(initialName);
+
+ // `initialName` arrives after the profile query resolves, which is usually
+ // after first render. Seed the field once it changes rather than stranding
+ // the user with an empty confirm box.
+ if (initialName !== seededFor) {
+ setSeededFor(initialName);
+ setName(initialName);
+ }
+
+ const updateProfile = useActionMutation<
+ { email: string; name: string },
+ { name: string }
+ >("update-user-profile", {
+ onSuccess: async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["action", "get-user-profile"] }),
+ queryClient.invalidateQueries({ queryKey: ["session"] }),
+ ]);
+ toast.success(t("namePrompt.saved"));
+ onResolved(true);
+ },
+ onError: (err: any) =>
+ toast.error(err?.message ?? t("namePrompt.saveFailed")),
+ });
+
+ const trimmed = name.trim();
+ const canSubmit = trimmed.length > 0 && !updateProfile.isPending;
+
+ function handleSubmit() {
+ if (!canSubmit) return;
+ updateProfile.mutate({ name: trimmed.slice(0, MAX_NAME_LENGTH) });
+ }
+
+ return (
+
+ );
+}
+```
+
+**Step 3: Verify it compiles**
+
+```bash
+cd code/templates/clips && npx tsc --noEmit -p . 2>&1 | grep -v "actions-registry\|virtual:react-router"
+```
+
+Expected: no output (the two filtered lines are pre-existing generated-file
+errors present on a clean tree).
+
+**Step 4: Commit**
+
+```bash
+git add code/templates/clips/app/components/profile/name-prompt-dialog.tsx
+git commit -m "Add shared display-name prompt dialog"
+```
+
+---
+
+## Task 2: i18n strings
+
+**Files:**
+
+- Modify: `templates/clips/app/i18n/en-US.ts` and the 10 other locale catalogs
+
+**Step 1: Add the `namePrompt` namespace to `en-US.ts`**
+
+Insert as a new top-level key, alphabetically near `navigation`:
+
+```ts
+ namePrompt: {
+ welcomeTitle: "Your first clip is on its way",
+ welcomeBody:
+ "While it finishes processing: you can share it with a link, and anyone you send it to can watch it and leave comments. First though — what should we call you?",
+ welcomeBackTitle: "Welcome back to Clips",
+ welcomeBackBody:
+ "We've reconfigured how user profiles work since your last login. Is this the right username for you?",
+ label: "Your name",
+ placeholder: "e.g. Tim Milazzo",
+ visibilityDisclaimer:
+ "This name may be visible to anyone you share a clip with.",
+ save: "Save",
+ confirm: "Looks right",
+ saving: "Saving...",
+ skip: "Not now",
+ saved: "Thanks — we'll call you that from now on",
+ saveFailed: "Couldn't save your name",
+ },
+```
+
+**Step 2: Add the same key set to the other 10 catalogs**
+
+Files: `zh-CN.ts`, `zh-TW.ts`, `es-ES.ts`, `fr-FR.ts`, `de-DE.ts`, `ja-JP.ts`,
+`ko-KR.ts`, `pt-BR.ts`, `hi-IN.ts`, `ar-SA.ts`. Same keys, translated values,
+no placeholders to preserve in this set.
+
+**Step 3: Verify**
+
+```bash
+cd code && pnpm guard:i18n-catalogs
+```
+
+Expected: `[guard:i18n-catalogs] checked 18 catalog directories` and exit 0.
+
+**Step 4: Commit**
+
+```bash
+git add code/templates/clips/app/i18n
+git commit -m "Add display-name prompt strings to all locales"
+```
+
+---
+
+## Task 3: Gate hook
+
+**Files:**
+
+- Create: `templates/clips/app/hooks/use-profile-name-prompt.ts`
+
+**Step 1: Create the file with this exact content**
+
+```ts
+import {
+ useActionQuery,
+ useSession,
+} from "@agent-native/core/client/hooks";
+import { useCallback, useState } from "react";
+
+const SKIP_SESSION_KEY = "clips:profile-name-prompt-skipped";
+
+export function emailLocalPart(email: string): string {
+ return email.split("@")[0] ?? "";
+}
+
+/**
+ * Signup writes `email.split("@")[0]` into the auth user's name
+ * (packages/core/src/server/auth.ts:3332), and the profile reader substitutes
+ * the full email when nothing is stored. So "no name" is never null — it is
+ * one of those two derived values.
+ *
+ * Used for the net-new surface, where the user cannot yet have chosen a name.
+ */
+export function isAutoDerivedName(
+ name: string | null | undefined,
+ email: string,
+): boolean {
+ const trimmed = name?.trim();
+ if (!trimmed) return true;
+ return trimmed === email || trimmed === emailLocalPart(email);
+}
+
+/**
+ * Narrower predicate for returning users. Only the exact email local part
+ * qualifies, because that is the value signup wrote and the only one the
+ * confirmation dialog can sensibly pre-fill. Anyone whose name differs —
+ * including the rare full-email fallback — is left alone.
+ */
+export function matchesEmailLocalPart(
+ name: string | null | undefined,
+ email: string,
+): boolean {
+ const trimmed = name?.trim();
+ if (!trimmed) return false;
+ return trimmed === emailLocalPart(email);
+}
+
+function readSkipped(): boolean {
+ if (typeof window === "undefined") return false;
+ try {
+ return window.sessionStorage.getItem(SKIP_SESSION_KEY) === "1";
+ } catch {
+ return false;
+ }
+}
+
+export interface ProfileNamePromptGate {
+ /** True when this user should be asked for a display name right now. */
+ shouldPrompt: boolean;
+ /** Value to pre-fill the field with. Empty for the net-new surface. */
+ initialName: string;
+ /** Call with the dialog's `saved` result to close and remember the outcome. */
+ resolve: (saved: boolean) => void;
+}
+
+/**
+ * @param enabled Caller-supplied condition for the surface (e.g. "this is
+ * their first recording"). The hook still runs its queries when false so
+ * hook order stays stable; it just never reports `shouldPrompt`.
+ * @param surface Picks the predicate and whether the field is pre-filled.
+ */
+export function useProfileNamePrompt(
+ enabled: boolean,
+ surface: "new" | "returning",
+): ProfileNamePromptGate {
+ const { session } = useSession();
+ const email = session?.email ?? "";
+ const [skipped, setSkipped] = useState(readSkipped);
+ const [savedThisMount, setSavedThisMount] = useState(false);
+
+ const profileQ = useActionQuery<{ email: string; name: string }>(
+ "get-user-profile",
+ undefined,
+ { enabled: Boolean(email), retry: false, throwOnError: false },
+ );
+
+ const resolve = useCallback((saved: boolean) => {
+ if (saved) {
+ setSavedThisMount(true);
+ return;
+ }
+ setSkipped(true);
+ try {
+ window.sessionStorage.setItem(SKIP_SESSION_KEY, "1");
+ } catch {
+ // Private-mode / storage-disabled: fall back to in-memory skip only.
+ }
+ }, []);
+
+ const nameQualifies =
+ surface === "new"
+ ? isAutoDerivedName(profileQ.data?.name, email)
+ : matchesEmailLocalPart(profileQ.data?.name, email);
+
+ const shouldPrompt =
+ enabled &&
+ !skipped &&
+ !savedThisMount &&
+ Boolean(email) &&
+ profileQ.isSuccess &&
+ nameQualifies;
+
+ return {
+ shouldPrompt,
+ initialName: surface === "returning" ? emailLocalPart(email) : "",
+ resolve,
+ };
+}
+```
+
+**Step 2: Verify it compiles**
+
+```bash
+cd code/templates/clips && npx tsc --noEmit -p . 2>&1 | grep -v "actions-registry\|virtual:react-router"
+```
+
+Expected: no output.
+
+**Step 3: Commit**
+
+```bash
+git add code/templates/clips/app/hooks/use-profile-name-prompt.ts
+git commit -m "Add display-name prompt gate hook"
+```
+
+---
+
+## Task 4: Net-new branch on the watch page
+
+The watch page renders a dedicated "still processing" shell when
+`recording.status !== "ready" || !recording.videoUrl`
+(`app/routes/r.$recordingId.tsx:952-1040`). Mount the prompt there so it
+overlays while the first clip finishes, exactly as specified.
+
+"First recording" comes from `useRecordingsCount({ view: "library" })`
+(`app/hooks/use-library.ts:88-100`), which calls `list-recordings` with
+`countOnly` so it skips the row payload.
+
+**Files:**
+
+- Modify: `templates/clips/app/routes/r.$recordingId.tsx`
+
+**Step 1: Add the imports**
+
+Next to the existing `@/components/...` imports:
+
+```ts
+import { NamePromptDialog } from "@/components/profile/name-prompt-dialog";
+import { useProfileNamePrompt } from "@/hooks/use-profile-name-prompt";
+import { useRecordingsCount } from "@/hooks/use-library";
+```
+
+**Step 2: Add the gate near the other derived flags**
+
+Place immediately after `const canArchiveRecording = canEdit;` (currently
+`r.$recordingId.tsx:531`). It must sit above the early `return` for the
+processing shell so hook order is unconditional:
+
+```ts
+const recordingsCountQ = useRecordingsCount({ view: "library" });
+const isFirstRecording =
+ recordingsCountQ.isSuccess && (recordingsCountQ.data ?? 0) <= 1;
+const namePrompt = useProfileNamePrompt(
+ isFirstRecording && role === "owner" && recording?.status !== "ready",
+ "new",
+);
+```
+
+**Step 3: Render it inside the processing shell**
+
+Inside the `if (recording.status !== "ready" || !recording.videoUrl)` block,
+just before that branch's closing ``:
+
+```tsx
+
+```
+
+**Step 4: Format**
+
+```bash
+cd code/templates/clips && npx oxfmt 'app/routes/r.$recordingId.tsx'
+```
+
+**Step 5: Verify**
+
+```bash
+cd code/templates/clips && npx tsc --noEmit -p . 2>&1 | grep -v "actions-registry\|virtual:react-router"
+```
+
+Expected: no output.
+
+**Step 6: Commit**
+
+```bash
+git add code/templates/clips/app/routes/r.\$recordingId.tsx
+git commit -m "Prompt first-time users for a display name while their clip processes"
+```
+
+---
+
+## Task 5: Returning branch on the library index
+
+**Files:**
+
+- Modify: `templates/clips/app/routes/_app.library._index.tsx`
+
+**Step 1: Add the imports**
+
+```ts
+import { NamePromptDialog } from "@/components/profile/name-prompt-dialog";
+import { useProfileNamePrompt } from "@/hooks/use-profile-name-prompt";
+```
+
+**Step 2: Add the gate inside `LibraryIndexRoute`**
+
+```ts
+const namePrompt = useProfileNamePrompt(true, "returning");
+```
+
+No recording-count condition here. Someone with zero recordings who lands on
+the library is still a returning user by every signal we have, and the net-new
+path only fires on the watch page — so the two surfaces cannot double-prompt.
+
+The `"returning"` surface narrows the predicate to an exact email-local-part
+match and supplies that value as the pre-filled field, turning the dialog into
+a one-click confirmation. Anyone whose stored name differs never sees it.
+
+**Step 3: Wrap the returned JSX**
+
+Change the `return ` into a fragment:
+
+```tsx
+return (
+ <>
+
+
+ >
+);
+```
+
+**Step 4: Format and verify**
+
+```bash
+cd code/templates/clips && npx oxfmt app/routes/_app.library._index.tsx && npx tsc --noEmit -p . 2>&1 | grep -v "actions-registry\|virtual:react-router"
+```
+
+Expected: no output from tsc.
+
+**Step 5: Commit**
+
+```bash
+git add code/templates/clips/app/routes/_app.library._index.tsx
+git commit -m "Prompt returning users without a display name on the library"
+```
+
+---
+
+## Task 6: Delete the dead Clips profile field
+
+`clips-user-prefs.displayName` is written by the General-tab Profile card and
+read by nothing. The Account tab already has a working display-name field
+backed by the core profile store, so no capability is lost.
+
+**Files:**
+
+- Modify: `templates/clips/app/routes/_app.settings._index.tsx`
+- Modify: `templates/clips/shared/clips-ai-prefs.ts`
+
+**Step 1: Delete the Profile card**
+
+Remove the whole `` block,
+`_app.settings._index.tsx:1480-1503`.
+
+**Step 2: Delete its state and wiring**
+
+- `:433` — `const [displayName, setDisplayName] = useState("");`
+- `:499` — `setDisplayName(v.displayName ?? "");`
+- `:534` — `displayName: displayName.trim() || undefined,` in `handleSave`
+- `:169` — `displayName?: string;` from the local `ClipsUserSettings` interface
+
+**Step 3: Delete the settings-search entry**
+
+Remove the `clips-profile` entry at `_app.settings._index.tsx:804-809`.
+
+**Step 4: Remove the field from the shared type**
+
+In `shared/clips-ai-prefs.ts:28-33`, delete `displayName?: string;` from
+`ClipsUserPrefs`.
+
+Leave any already-persisted `displayName` values in the settings blob alone.
+The PUT route merges partial updates
+(`server/routes/_agent-native/clips/user-prefs.put.ts`), so stale keys are inert
+and removing them is not worth a migration.
+
+**Step 5: Check for stragglers**
+
+```bash
+cd code/templates/clips && grep -rn "displayName" app/ shared/ server/routes/_agent-native/clips/ | grep -v "calendar\|meetings\|feature-flags\|comments-panel\|db/index"
+```
+
+Expected: no output.
+
+**Step 6: Verify**
+
+```bash
+cd code/templates/clips && npx oxfmt app/routes/_app.settings._index.tsx shared/clips-ai-prefs.ts && npx tsc --noEmit -p . 2>&1 | grep -v "actions-registry\|virtual:react-router"
+```
+
+Expected: no output from tsc.
+
+The `settings.profile`, `settings.displayName`, and
+`settings.displayNamePlaceholder` i18n keys (`en-US.ts:648-651`) are still used
+by other surfaces — leave them.
+
+**Step 7: Commit**
+
+```bash
+git add code/templates/clips/app/routes/_app.settings._index.tsx code/templates/clips/shared/clips-ai-prefs.ts
+git commit -m "Remove duplicate Clips profile field that wrote to an unread key"
+```
+
+---
+
+## Task 7: Unit test the predicate
+
+The predicate is the one piece of logic that silently disables the whole
+feature if it drifts. Test it directly.
+
+**Files:**
+
+- Create: `templates/clips/app/hooks/use-profile-name-prompt.test.ts`
+
+**Step 1: Create the file with this exact content**
+
+```ts
+import { describe, expect, it } from "vitest";
+
+import {
+ isAutoDerivedName,
+ matchesEmailLocalPart,
+} from "./use-profile-name-prompt";
+
+describe("isAutoDerivedName (net-new surface)", () => {
+ it("treats the signup-derived local part as no name", () => {
+ // packages/core/src/server/auth.ts:3332 seeds name with the local part.
+ expect(isAutoDerivedName("tim", "tim@builder.io")).toBe(true);
+ });
+
+ it("treats the full-email fallback as no name", () => {
+ // normalizeUserProfileName() substitutes the email when nothing is stored.
+ expect(isAutoDerivedName("tim@builder.io", "tim@builder.io")).toBe(true);
+ });
+
+ it("treats blank and missing as no name", () => {
+ expect(isAutoDerivedName("", "tim@builder.io")).toBe(true);
+ expect(isAutoDerivedName(null, "tim@builder.io")).toBe(true);
+ expect(isAutoDerivedName(" ", "tim@builder.io")).toBe(true);
+ });
+
+ it("accepts a real name", () => {
+ expect(isAutoDerivedName("Tim Milazzo", "tim@builder.io")).toBe(false);
+ });
+});
+
+describe("matchesEmailLocalPart (returning surface)", () => {
+ it("matches the exact local part", () => {
+ expect(matchesEmailLocalPart("tim", "tim@builder.io")).toBe(true);
+ });
+
+ it("does not match the full-email fallback", () => {
+ // Deliberately out of scope: the confirm dialog would pre-fill a whole
+ // email address, which is not a username anyone would accept.
+ expect(matchesEmailLocalPart("tim@builder.io", "tim@builder.io")).toBe(
+ false,
+ );
+ });
+
+ it("does not match blank or missing", () => {
+ expect(matchesEmailLocalPart("", "tim@builder.io")).toBe(false);
+ expect(matchesEmailLocalPart(null, "tim@builder.io")).toBe(false);
+ });
+
+ it("leaves a real name alone", () => {
+ expect(matchesEmailLocalPart("Tim Milazzo", "tim@builder.io")).toBe(false);
+ });
+});
+```
+
+**Step 2: Run it**
+
+```bash
+cd code/templates/clips && npx vitest run app/hooks/use-profile-name-prompt.test.ts
+```
+
+Expected: `Test Files 1 passed`, `Tests 8 passed`.
+
+**Step 3: Commit**
+
+```bash
+git add code/templates/clips/app/hooks/use-profile-name-prompt.test.ts
+git commit -m "Test display-name detection predicate"
+```
+
+---
+
+## Task 8: Browser verification
+
+Type checks prove nothing about whether the modal appears. Drive it for real.
+
+Chromium for Playwright is already installed in this environment. The launcher
+lives at
+`node_modules/.pnpm/playwright@1.61.1/node_modules/playwright/index.mjs` —
+import it by path, because `playwright` is not resolvable from the repo root.
+
+**Step 1: Force a test user into the no-name state**
+
+```bash
+cd code/templates/clips && node --input-type=module -e "
+import {neon} from '@neondatabase/serverless';
+const sql=neon(process.env.CLIPS_DATABASE_URL);
+console.log(await sql\`select id, email, name from \\\"user\\\" where email='dev@local.test'\`);
+"
+```
+
+If `name` is anything other than `dev` or `dev@local.test`, set it to `dev` so
+the predicate matches, and record the original value to restore in Step 4.
+
+**Step 2: Verify the returning-user path**
+
+Script (run from `code/`, delete the file afterwards):
+
+```js
+import { chromium } from "./node_modules/.pnpm/playwright@1.61.1/node_modules/playwright/index.mjs";
+const b = await chromium.launch();
+const p = await b.newPage({ viewport: { width: 1440, height: 900 } });
+await p.goto("http://127.0.0.1:8080/clips/library", { waitUntil: "networkidle" });
+await p.waitForTimeout(1500);
+console.log("dialog:", await p.getByRole("dialog").innerText().catch(() => "NONE"));
+await p.getByRole("button", { name: /not now/i }).click();
+await p.waitForTimeout(500);
+console.log("after skip:", await p.getByRole("dialog").count());
+await p.reload({ waitUntil: "networkidle" });
+await p.waitForTimeout(1500);
+console.log("after reload (same session, expect 0):", await p.getByRole("dialog").count());
+await b.close();
+```
+
+Expected: the welcome-back copy on first load, **with the field pre-filled to
+`dev`**; `0` dialogs after skip; `0` after reload within the same browser
+session.
+
+**Step 2b: Verify a real name suppresses the returning prompt**
+
+```bash
+cd code/templates/clips && node --input-type=module -e "
+import {neon} from '@neondatabase/serverless';
+const sql=neon(process.env.CLIPS_DATABASE_URL);
+await sql\`update \\"user\\" set name='Tim Milazzo' where email='dev@local.test'\`;
+"
+```
+
+Reload `/clips/library` in a fresh context. Expected: `0` dialogs. Set the name
+back to `dev` before continuing.
+
+**Step 3: Verify save persists**
+
+Re-run with a fresh browser context, type a name, click Save, then confirm:
+
+```bash
+cd code/templates/clips && node --input-type=module -e "
+import {neon} from '@neondatabase/serverless';
+const sql=neon(process.env.CLIPS_DATABASE_URL);
+console.log(await sql\`select email, name from \\\"user\\\" where email='dev@local.test'\`);
+"
+```
+
+Expected: `name` is the string that was typed.
+
+**Step 4: Restore the test user's original name**
+
+Do not leave modified fixture state behind.
+
+**Step 5: Commit nothing**
+
+This task produces no source changes.
+
+---
+
+## Task 9: Changelog
+
+**Step 1: Record the user-facing change**
+
+```bash
+cd code/templates/clips && npx agent-native changelog add "Clips now asks for your display name when it doesn't have one, instead of hiding it in Settings." --type added
+```
+
+**Step 2: Commit**
+
+```bash
+git add code/templates/clips/changelog
+git commit -m "Add changelog entry for display-name prompt"
+```
+
+---
+
+## Final verification checklist
+
+```bash
+cd code && pnpm guard:i18n-catalogs
+cd code/templates/clips && npx tsc --noEmit -p . 2>&1 | grep -v "actions-registry\|virtual:react-router"
+cd code/templates/clips && npx vitest run app/ actions/
+```
+
+- [ ] `pnpm guard:i18n-catalogs` exits 0
+- [ ] `tsc` reports only the two pre-existing generated-file errors
+- [ ] Clips test suite passes
+- [ ] Welcome-back modal appears on `/clips/library` when the name is the email
+ local part, pre-filled with that value
+- [ ] It does **not** appear once the name differs from the local part
+- [ ] Skip suppresses it for the rest of the browser session
+- [ ] Saving writes the name to the auth user row
+- [ ] Settings General tab no longer has a Profile card; Account tab still does
+
+---
+
+## Known limitations
+
+Call these out in the PR description; do not try to solve them here.
+
+1. **False positives on the returning surface only.** A user genuinely named
+ `tim` at `tim@…` is indistinguishable from one who never set a name — signup
+ writes exactly that value, so no marker exists to tell them apart. The
+ confirmation framing ("Is this the right username for you?", pre-filled with
+ `tim`) is written so that person can answer in one click rather than being
+ accused of having no name. The net-new surface has no such ambiguity: core
+ auth wires no social provider, so a fresh account's name is always the
+ derived local part.
+2. **Returning users whose profile resolves to the full email are never
+ prompted.** `matchesEmailLocalPart` deliberately excludes them, because a
+ confirm dialog pre-filled with `tim@builder.io` is not a username anyone
+ would accept. This only occurs when both the stored setting and the auth
+ user's name are blank — not reachable through the normal signup path. They
+ can still set a name in Settings.
+3. **The disclaimer is currently true only for email.** The name reaches share
+ recipients as the sender name on notification emails
+ (`server/jobs/transactional-emails.ts:410`). It does **not** yet appear on
+ comments — `app/components/player/comments-panel.tsx:153` sends
+ `authorName: null`, so comments fall back to the email local part. The copy
+ in Task 2 says "may be visible", which is accurate today. Wiring the profile
+ name into comment authorship is a separate change.
+4. **Net-new detection is a proxy.** `list-recordings --countOnly` with
+ `view: "library"` scopes to the current user's personal recordings in the
+ active org (`actions/list-recordings.ts:159-171`). Someone who imports two
+ Looms before their first native recording will get the returning-user copy.
+ Acceptable; the alternative needs a new server field for account age.
diff --git a/templates/clips/actions/get-recording-player-data.ts b/templates/clips/actions/get-recording-player-data.ts
index 2c9d221c05..d2a1949cd3 100644
--- a/templates/clips/actions/get-recording-player-data.ts
+++ b/templates/clips/actions/get-recording-player-data.ts
@@ -345,6 +345,7 @@ export default defineAction({
spaceIds: parseSpaceIds(rec.spaceIds),
createdAt: rec.createdAt,
updatedAt: rec.updatedAt,
+ archivedAt: rec.archivedAt,
},
transcript: transcript
? {
diff --git a/templates/clips/app/components/player/delete-recording-menu.tsx b/templates/clips/app/components/player/delete-recording-menu.tsx
index 1a4ebe63ff..c8320ea071 100644
--- a/templates/clips/app/components/player/delete-recording-menu.tsx
+++ b/templates/clips/app/components/player/delete-recording-menu.tsx
@@ -1,6 +1,13 @@
import { useActionMutation } from "@agent-native/core/client/hooks";
import { useT } from "@agent-native/core/client/i18n";
-import { IconDots, IconDownload, IconTrash } from "@tabler/icons-react";
+import {
+ IconArchive,
+ IconArchiveOff,
+ IconDots,
+ IconDownload,
+ IconGif,
+ IconTrash,
+} from "@tabler/icons-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
@@ -35,6 +42,12 @@ interface RecordingOptionsMenuProps extends DeleteRecordingMenuProps {
downloadLabel?: string;
downloadingLabel?: string;
onDownload?: () => void;
+ canDownloadGif?: boolean;
+ gifPending?: boolean;
+ onDownloadGif?: () => void;
+ canArchive?: boolean;
+ isArchived?: boolean;
+ onArchiveChanged?: () => void;
}
export function RecordingOptionsMenu({
@@ -46,11 +59,19 @@ export function RecordingOptionsMenu({
downloadLabel,
downloadingLabel,
onDownload,
+ canDownloadGif = false,
+ gifPending = false,
+ onDownloadGif,
+ canArchive = false,
+ isArchived = false,
+ onArchiveChanged,
}: RecordingOptionsMenuProps) {
const t = useT();
const [open, setOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const showDownload = canDownload && Boolean(onDownload);
+ const showDownloadGif = canDownloadGif && Boolean(onDownloadGif);
+ const showArchive = canArchive;
const showDelete = canDelete;
const trashRecording = useActionMutation(
"trash-recording",
@@ -70,12 +91,57 @@ export function RecordingOptionsMenu({
trashRecording.mutate({ id: recordingId });
}, [recordingId, trashRecording]);
+ const archiveRecording = useActionMutation(
+ "archive-recording",
+ {
+ onSuccess: () => {
+ toast.success(t("deleteRecordingMenu.archived"));
+ onArchiveChanged?.();
+ },
+ onError: (err: any) =>
+ toast.error(err?.message ?? t("deleteRecordingMenu.archiveFailed")),
+ },
+ );
+ const restoreRecording = useActionMutation(
+ "restore-recording",
+ {
+ onSuccess: () => {
+ toast.success(t("deleteRecordingMenu.restoredFromArchive"));
+ onArchiveChanged?.();
+ },
+ onError: (err: any) =>
+ toast.error(err?.message ?? t("deleteRecordingMenu.unarchiveFailed")),
+ },
+ );
+ const archivePending =
+ archiveRecording.isPending || restoreRecording.isPending;
+
const handleDownload = useCallback(() => {
setMenuOpen(false);
onDownload?.();
}, [onDownload]);
- if (!showDownload && !showDelete) return null;
+ const handleDownloadGif = useCallback(() => {
+ setMenuOpen(false);
+ onDownloadGif?.();
+ }, [onDownloadGif]);
+
+ const handleArchive = useCallback(() => {
+ if (archivePending) return;
+ setMenuOpen(false);
+ if (isArchived) restoreRecording.mutate({ id: recordingId });
+ else archiveRecording.mutate({ id: recordingId });
+ }, [
+ archivePending,
+ archiveRecording,
+ isArchived,
+ recordingId,
+ restoreRecording,
+ ]);
+
+ if (!showDownload && !showDownloadGif && !showArchive && !showDelete) {
+ return null;
+ }
return (
) : null}
- {showDownload && showDelete ? : null}
+ {showDownloadGif ? (
+
+
+ {gifPending
+ ? t("deleteRecordingMenu.buildingGif")
+ : t("deleteRecordingMenu.downloadAsGif")}
+
+ ) : null}
+ {showArchive ? (
+
+ {isArchived ? (
+
+ ) : (
+
+ )}
+ {isArchived
+ ? t("deleteRecordingMenu.unarchive")
+ : t("deleteRecordingMenu.archive")}
+
+ ) : null}
+ {(showDownload || showDownloadGif || showArchive) && showDelete ? (
+
+ ) : null}
{showDelete ? (
{
diff --git a/templates/clips/app/i18n/ar-SA.ts b/templates/clips/app/i18n/ar-SA.ts
index 104474a2e6..b2a8002a95 100644
--- a/templates/clips/app/i18n/ar-SA.ts
+++ b/templates/clips/app/i18n/ar-SA.ts
@@ -139,6 +139,8 @@ const messages = {
pageTitle: "اجتماع · Clips",
},
recordingPage: {
+ buildingGif: "جارٍ إنشاء GIF… {{percent}}%",
+ gifExportFailed: "تعذر إنشاء GIF من هذا المقطع",
untitledClip: "مقطع بدون عنوان",
recordingNotFound: "لم يتم العثور على التسجيل",
noAccess: "قد لا يكون لديك حق الوصول إلى هذا المقطع.",
@@ -963,6 +965,14 @@ const messages = {
updateTranscriptSharingFailed: "تعذر تحديث مشاركة النص",
},
deleteRecordingMenu: {
+ downloadAsGif: "تنزيل كملف GIF",
+ buildingGif: "جارٍ إنشاء GIF…",
+ archive: "أرشفة",
+ unarchive: "إلغاء الأرشفة",
+ archived: "تمت أرشفة المقطع",
+ restoredFromArchive: "تمت استعادة المقطع من الأرشيف",
+ archiveFailed: "تعذرت أرشفة المقطع",
+ unarchiveFailed: "تعذرت استعادة المقطع",
movedToTrash: "تم نقل المقطع إلى المهملات",
deleteFailed: "فشل حذف المقطع",
clipOptions: "خيارات المقطع",
diff --git a/templates/clips/app/i18n/de-DE.ts b/templates/clips/app/i18n/de-DE.ts
index 05d1faae37..74acd2221b 100644
--- a/templates/clips/app/i18n/de-DE.ts
+++ b/templates/clips/app/i18n/de-DE.ts
@@ -142,6 +142,8 @@ const messages = {
pageTitle: "Treffen · Clips",
},
recordingPage: {
+ buildingGif: "GIF wird erstellt… {{percent}} %",
+ gifExportFailed: "GIF konnte aus diesem Clip nicht erstellt werden",
untitledClip: "Unbenannter Clip",
recordingNotFound: "Aufnahme nicht gefunden",
noAccess: "Möglicherweise haben Sie keinen Zugriff auf diesen Clip.",
@@ -986,6 +988,14 @@ Alle sichtbaren Änderungen für Clips-Nutzer werden hier dokumentiert. Du kanns
"Die Transkriptfreigabe konnte nicht aktualisiert werden",
},
deleteRecordingMenu: {
+ downloadAsGif: "Als GIF herunterladen",
+ buildingGif: "GIF wird erstellt…",
+ archive: "Archivieren",
+ unarchive: "Archivierung aufheben",
+ archived: "Clip archiviert",
+ restoredFromArchive: "Clip aus dem Archiv wiederhergestellt",
+ archiveFailed: "Clip konnte nicht archiviert werden",
+ unarchiveFailed: "Clip konnte nicht wiederhergestellt werden",
movedToTrash: "Clip in den Papierkorb verschoben",
deleteFailed: "Clip konnte nicht gelöscht werden",
clipOptions: "Clip-Optionen",
diff --git a/templates/clips/app/i18n/en-US.ts b/templates/clips/app/i18n/en-US.ts
index 2670f35217..500d0e3b57 100644
--- a/templates/clips/app/i18n/en-US.ts
+++ b/templates/clips/app/i18n/en-US.ts
@@ -140,6 +140,8 @@ const messages = {
pageTitle: "Meeting · Clips",
},
recordingPage: {
+ buildingGif: "Building GIF… {{percent}}%",
+ gifExportFailed: "Couldn't build a GIF from this clip",
untitledClip: "Untitled Clip",
recordingNotFound: "Recording not found",
noAccess: "You may not have access to this clip.",
@@ -954,6 +956,14 @@ All notable user-facing changes to Clips are documented here. Open it any time f
updateTranscriptSharingFailed: "Couldn't update transcript sharing",
},
deleteRecordingMenu: {
+ downloadAsGif: "Download as GIF",
+ buildingGif: "Building GIF...",
+ archive: "Archive",
+ unarchive: "Unarchive",
+ archived: "Clip archived",
+ restoredFromArchive: "Clip restored from archive",
+ archiveFailed: "Failed to archive clip",
+ unarchiveFailed: "Failed to restore clip",
movedToTrash: "Clip moved to trash",
deleteFailed: "Failed to delete clip",
clipOptions: "Clip options",
diff --git a/templates/clips/app/i18n/es-ES.ts b/templates/clips/app/i18n/es-ES.ts
index 4e8dd459f1..fbfe236c09 100644
--- a/templates/clips/app/i18n/es-ES.ts
+++ b/templates/clips/app/i18n/es-ES.ts
@@ -141,6 +141,8 @@ const messages = {
pageTitle: "Reunión · Clips",
},
recordingPage: {
+ buildingGif: "Creando GIF… {{percent}}%",
+ gifExportFailed: "No se pudo crear un GIF de este clip",
untitledClip: "Clip sin título",
recordingNotFound: "Grabación no encontrada",
noAccess: "Es posible que no tengas acceso a este clip.",
@@ -979,6 +981,14 @@ Todos los cambios visibles para los usuarios de Clips se documentan aquí. Puede
"No se pudo actualizar el uso compartido de la transcripción",
},
deleteRecordingMenu: {
+ downloadAsGif: "Descargar como GIF",
+ buildingGif: "Creando GIF…",
+ archive: "Archivar",
+ unarchive: "Desarchivar",
+ archived: "Clip archivado",
+ restoredFromArchive: "Clip restaurado desde el archivo",
+ archiveFailed: "No se pudo archivar el clip",
+ unarchiveFailed: "No se pudo restaurar el clip",
movedToTrash: "Clip movido a la papelera",
deleteFailed: "No se pudo eliminar el clip",
clipOptions: "Opciones del clip",
diff --git a/templates/clips/app/i18n/fr-FR.ts b/templates/clips/app/i18n/fr-FR.ts
index c1b8356b35..beb1dc11a9 100644
--- a/templates/clips/app/i18n/fr-FR.ts
+++ b/templates/clips/app/i18n/fr-FR.ts
@@ -141,6 +141,8 @@ const messages = {
pageTitle: "Réunion · Clips",
},
recordingPage: {
+ buildingGif: "Création du GIF… {{percent}} %",
+ gifExportFailed: "Impossible de créer un GIF à partir de ce clip",
untitledClip: "Extrait sans titre",
recordingNotFound: "Enregistrement introuvable",
noAccess: "Vous n’aurez peut-être pas accès à ce clip.",
@@ -981,6 +983,14 @@ Tous les changements visibles par les utilisateurs de Clips sont documentés ici
"Impossible de modifier le partage de la transcription",
},
deleteRecordingMenu: {
+ downloadAsGif: "Télécharger en GIF",
+ buildingGif: "Création du GIF…",
+ archive: "Archiver",
+ unarchive: "Désarchiver",
+ archived: "Clip archivé",
+ restoredFromArchive: "Clip restauré depuis l’archive",
+ archiveFailed: "Échec de l’archivage du clip",
+ unarchiveFailed: "Échec de la restauration du clip",
movedToTrash: "Clip déplacé vers la corbeille",
deleteFailed: "Échec de la suppression du clip",
clipOptions: "Options du clip",
diff --git a/templates/clips/app/i18n/hi-IN.ts b/templates/clips/app/i18n/hi-IN.ts
index efc1b295e3..86e18165e8 100644
--- a/templates/clips/app/i18n/hi-IN.ts
+++ b/templates/clips/app/i18n/hi-IN.ts
@@ -140,6 +140,8 @@ const messages = {
pageTitle: "मीटिंग · Clips",
},
recordingPage: {
+ buildingGif: "GIF बन रहा है… {{percent}}%",
+ gifExportFailed: "इस क्लिप से GIF नहीं बनाया जा सका",
untitledClip: "शीर्षक रहित क्लिप",
recordingNotFound: "रिकॉर्डिंग नहीं मिली",
noAccess: "हो सकता है कि आपके पास इस क्लिप तक पहुंच न हो.",
@@ -947,6 +949,14 @@ Clips में उपयोगकर्ताओं को दिखने व
updateTranscriptSharingFailed: "ट्रांसक्रिप्ट शेयरिंग अपडेट नहीं हो सकी",
},
deleteRecordingMenu: {
+ downloadAsGif: "GIF के रूप में डाउनलोड करें",
+ buildingGif: "GIF बन रहा है…",
+ archive: "आर्काइव",
+ unarchive: "आर्काइव से हटाएं",
+ archived: "क्लिप आर्काइव किया गया",
+ restoredFromArchive: "क्लिप आर्काइव से पुनर्स्थापित",
+ archiveFailed: "क्लिप आर्काइव नहीं किया जा सका",
+ unarchiveFailed: "क्लिप पुनर्स्थापित नहीं किया जा सका",
movedToTrash: "क्लिप ट्रैश में ले जाया गया",
deleteFailed: "क्लिप हटाने में विफल",
clipOptions: "क्लिप विकल्प",
diff --git a/templates/clips/app/i18n/ja-JP.ts b/templates/clips/app/i18n/ja-JP.ts
index 57e4b80106..5ea1aedb53 100644
--- a/templates/clips/app/i18n/ja-JP.ts
+++ b/templates/clips/app/i18n/ja-JP.ts
@@ -141,6 +141,8 @@ const messages = {
pageTitle: "ミーティング · Clips",
},
recordingPage: {
+ buildingGif: "GIF を作成中… {{percent}}%",
+ gifExportFailed: "このクリップから GIF を作成できませんでした",
untitledClip: "無題のクリップ",
recordingNotFound: "録画が見つかりません",
noAccess: "このクリップにアクセスできない可能性があります。",
@@ -966,6 +968,14 @@ Clips のユーザー向けの主な変更はここに記録されます。コ
updateTranscriptSharingFailed: "文字起こしの共有を更新できませんでした",
},
deleteRecordingMenu: {
+ downloadAsGif: "GIF としてダウンロード",
+ buildingGif: "GIF を作成中…",
+ archive: "アーカイブ",
+ unarchive: "アーカイブ解除",
+ archived: "クリップをアーカイブしました",
+ restoredFromArchive: "アーカイブからクリップを復元しました",
+ archiveFailed: "クリップをアーカイブできませんでした",
+ unarchiveFailed: "クリップを復元できませんでした",
movedToTrash: "クリップをゴミ箱に移動しました",
deleteFailed: "クリップの削除に失敗しました",
clipOptions: "クリップオプション",
diff --git a/templates/clips/app/i18n/ko-KR.ts b/templates/clips/app/i18n/ko-KR.ts
index 5129421afe..f3ad17c380 100644
--- a/templates/clips/app/i18n/ko-KR.ts
+++ b/templates/clips/app/i18n/ko-KR.ts
@@ -140,6 +140,8 @@ const messages = {
pageTitle: "회의 · Clips",
},
recordingPage: {
+ buildingGif: "GIF 만드는 중… {{percent}}%",
+ gifExportFailed: "이 클립에서 GIF를 만들지 못했습니다",
untitledClip: "제목 없는 클립",
recordingNotFound: "녹화를 찾을 수 없습니다",
noAccess: "이 클립에 액세스할 수 없을 수도 있습니다.",
@@ -955,6 +957,14 @@ Clips의 모든 사용자 대상 변경 사항은 여기에 기록됩니다. 명
updateTranscriptSharingFailed: "스크립트 공유를 업데이트하지 못했습니다",
},
deleteRecordingMenu: {
+ downloadAsGif: "GIF로 다운로드",
+ buildingGif: "GIF 만드는 중…",
+ archive: "보관",
+ unarchive: "보관 해제",
+ archived: "클립을 보관했습니다",
+ restoredFromArchive: "보관함에서 클립을 복원했습니다",
+ archiveFailed: "클립을 보관하지 못했습니다",
+ unarchiveFailed: "클립을 복원하지 못했습니다",
movedToTrash: "클립이 휴지통으로 이동됨",
deleteFailed: "클립 삭제 실패",
clipOptions: "클립 옵션",
diff --git a/templates/clips/app/i18n/pt-BR.ts b/templates/clips/app/i18n/pt-BR.ts
index df60e50a50..b3cee9ada8 100644
--- a/templates/clips/app/i18n/pt-BR.ts
+++ b/templates/clips/app/i18n/pt-BR.ts
@@ -141,6 +141,8 @@ const messages = {
pageTitle: "Reunião · Clips",
},
recordingPage: {
+ buildingGif: "Gerando GIF… {{percent}}%",
+ gifExportFailed: "Não foi possível gerar um GIF deste clipe",
untitledClip: "Clipe sem título",
recordingNotFound: "Gravação não encontrada",
noAccess: "Você pode não ter acesso a este clipe.",
@@ -975,6 +977,14 @@ Todas as mudanças visíveis para usuários do Clips são documentadas aqui. Voc
"Não foi possível atualizar o compartilhamento da transcrição",
},
deleteRecordingMenu: {
+ downloadAsGif: "Baixar como GIF",
+ buildingGif: "Gerando GIF…",
+ archive: "Arquivar",
+ unarchive: "Desarquivar",
+ archived: "Clipe arquivado",
+ restoredFromArchive: "Clipe restaurado do arquivo",
+ archiveFailed: "Não foi possível arquivar o clipe",
+ unarchiveFailed: "Não foi possível restaurar o clipe",
movedToTrash: "Clipe movido para a lixeira",
deleteFailed: "Falha ao excluir clipe",
clipOptions: "Opções do clipe",
diff --git a/templates/clips/app/i18n/zh-CN.ts b/templates/clips/app/i18n/zh-CN.ts
index 9e2eff067d..c44dda428a 100644
--- a/templates/clips/app/i18n/zh-CN.ts
+++ b/templates/clips/app/i18n/zh-CN.ts
@@ -136,6 +136,8 @@ const messages = {
pageTitle: "会议 · Clips",
},
recordingPage: {
+ buildingGif: "正在生成 GIF… {{percent}}%",
+ gifExportFailed: "无法从此剪辑生成 GIF",
untitledClip: "无标题剪辑",
recordingNotFound: "找不到录制",
noAccess: "您可能无权访问此剪辑。",
@@ -917,6 +919,14 @@ Clips 中所有面向用户的重要更改都会记录在这里。你可以随
updateTranscriptSharingFailed: "无法更新转写共享设置",
},
deleteRecordingMenu: {
+ downloadAsGif: "下载为 GIF",
+ buildingGif: "正在生成 GIF…",
+ archive: "归档",
+ unarchive: "取消归档",
+ archived: "剪辑已归档",
+ restoredFromArchive: "已从归档恢复剪辑",
+ archiveFailed: "归档剪辑失败",
+ unarchiveFailed: "恢复剪辑失败",
movedToTrash: "剪辑已移至废纸篓",
deleteFailed: "删除剪辑失败",
clipOptions: "剪辑选项",
diff --git a/templates/clips/app/i18n/zh-TW.ts b/templates/clips/app/i18n/zh-TW.ts
index 8dd3985ee5..3a313b529b 100644
--- a/templates/clips/app/i18n/zh-TW.ts
+++ b/templates/clips/app/i18n/zh-TW.ts
@@ -136,6 +136,8 @@ const messages = {
pageTitle: "會議 · Clips",
},
recordingPage: {
+ buildingGif: "正在產生 GIF… {{percent}}%",
+ gifExportFailed: "無法從此剪輯產生 GIF",
untitledClip: "無標題剪輯",
recordingNotFound: "找不到錄製",
noAccess: "您可能無權存取此剪輯。",
@@ -911,6 +913,14 @@ const messages = {
updateTranscriptSharingFailed: "無法更新逐字稿分享設定",
},
deleteRecordingMenu: {
+ downloadAsGif: "下載為 GIF",
+ buildingGif: "正在產生 GIF…",
+ archive: "封存",
+ unarchive: "取消封存",
+ archived: "剪輯已封存",
+ restoredFromArchive: "已從封存恢復剪輯",
+ archiveFailed: "封存剪輯失敗",
+ unarchiveFailed: "恢復剪輯失敗",
movedToTrash: "剪輯已移至廢紙簍",
deleteFailed: "刪除剪輯失敗",
clipOptions: "剪輯選項",
diff --git a/templates/clips/app/lib/ffmpeg-export.ts b/templates/clips/app/lib/ffmpeg-export.ts
index cdb9ad0331..2dfe364197 100644
--- a/templates/clips/app/lib/ffmpeg-export.ts
+++ b/templates/clips/app/lib/ffmpeg-export.ts
@@ -292,11 +292,18 @@ export async function exportMp4(
* picker's "Animated GIF" tab. Keeps GIF small by scaling to 320px wide and
* 15fps — more than enough for a library thumbnail.
*/
+export interface GifOptions {
+ /** Output width in pixels; height follows the source aspect ratio. */
+ width?: number;
+ fps?: number;
+}
+
export async function exportGif(
recording: ExportRecording,
startMs: number,
durationMs: number,
onProgress?: (p: ExportProgress) => void,
+ options?: GifOptions,
): Promise {
if (!recording.videoUrl) throw new Error("Recording has no videoUrl");
@@ -325,7 +332,7 @@ export async function exportGif(
"-i",
inputName,
"-vf",
- "fps=15,scale=320:-2:flags=lanczos",
+ `fps=${options?.fps ?? 15},scale=${options?.width ?? 320}:-2:flags=lanczos`,
"-loop",
"0",
outputName,
diff --git a/templates/clips/app/routes/r.$recordingId.tsx b/templates/clips/app/routes/r.$recordingId.tsx
index cb7cae3420..bb0e1b4f32 100644
--- a/templates/clips/app/routes/r.$recordingId.tsx
+++ b/templates/clips/app/routes/r.$recordingId.tsx
@@ -92,6 +92,7 @@ import { isDefaultTitle, useAutoTitleBridge } from "@/hooks/use-auto-title";
import { usePlayerShortcuts } from "@/hooks/use-player-shortcuts";
import { useViewTracking } from "@/hooks/use-view-tracking";
import enMessages from "@/i18n/en-US";
+import { exportGif } from "@/lib/ffmpeg-export";
import { parsePlaybackSpeed } from "@/lib/playback-speed";
import { recordingShareUrl } from "@/lib/recording-link";
import { isStorageSetupFailureReason } from "@/lib/storage-failures";
@@ -103,6 +104,11 @@ const UPLOAD_STUCK_TIMEOUT_MS = 5 * 60 * 1000;
const PROCESSING_STUCK_TIMEOUT_MS = 12 * 60 * 1000;
const READY_MEDIA_SETTLE_POLL_MS = 20 * 1000;
const READY_MEDIA_SETTLE_POLL_INTERVAL_MS = 1000;
+// GIF has no interframe compression, so a full-length clip at source
+// resolution is orders of magnitude larger than the MP4 it came from. 480px /
+// 12fps keeps a shareable GIF within a size a browser can actually encode.
+const GIF_DOWNLOAD_WIDTH = 480;
+const GIF_DOWNLOAD_FPS = 12;
export function meta() {
return [{ title: enMessages.recordingRoute.pageTitle }];
@@ -277,6 +283,7 @@ export default function RecordingPage() {
const [processingTimeout, setProcessingTimeout] = useState(false);
const [retryingFinalize, setRetryingFinalize] = useState(false);
const [downloading, setDownloading] = useState(false);
+ const [exportingGif, setExportingGif] = useState(false);
const [pendingLinkCopied, setPendingLinkCopied] = useState(false);
const browserTabId = useMemo(() => getBrowserTabId(), []);
const recordingScope = useMemo(
@@ -520,6 +527,8 @@ export default function RecordingPage() {
const canDownloadRecording = Boolean(
recording?.enableDownloads && recording.videoUrl && !isLoomEmbedBacked,
);
+ const canDownloadGif = Boolean(canDownloadRecording && recording?.durationMs);
+ const canArchiveRecording = canEdit;
const downloadRecording = useCallback(async () => {
if (!recording?.videoUrl) return;
setDownloading(true);
@@ -547,6 +556,55 @@ export default function RecordingPage() {
toast.dismiss(downloadToastId);
}
}, [recording?.title, recording?.videoFormat, recording?.videoUrl, t]);
+ const downloadGif = useCallback(async () => {
+ if (!recording?.videoUrl || !recording.durationMs) return;
+ setExportingGif(true);
+ const gifToastId = toast.loading(
+ t("recordingPage.buildingGif", { percent: 0 }),
+ );
+ try {
+ const blob = await exportGif(
+ {
+ id: recording.id,
+ videoUrl: recording.videoUrl,
+ videoFormat: recording.videoFormat,
+ durationMs: recording.durationMs,
+ title: recording.title,
+ },
+ 0,
+ recording.durationMs,
+ (p) =>
+ toast.loading(
+ t("recordingPage.buildingGif", {
+ percent: Math.round(p.progress * 100),
+ }),
+ { id: gifToastId },
+ ),
+ { width: GIF_DOWNLOAD_WIDTH, fps: GIF_DOWNLOAD_FPS },
+ );
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${sanitizeFilename(recording.title || "clip")}.gif`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+ } catch (err) {
+ console.error(err);
+ toast.error(t("recordingPage.gifExportFailed"));
+ } finally {
+ setExportingGif(false);
+ toast.dismiss(gifToastId);
+ }
+ }, [
+ recording?.durationMs,
+ recording?.id,
+ recording?.title,
+ recording?.videoFormat,
+ recording?.videoUrl,
+ t,
+ ]);
const retryFinalizeAfterStorage = useCallback(async () => {
if (!recordingId) return;
setRetryingFinalize(true);
@@ -1557,7 +1615,7 @@ export default function RecordingPage() {
) : null}
- {canDelete || canDownloadRecording ? (
+ {canDelete || canDownloadRecording || canArchiveRecording ? (
{
void downloadRecording();
}}
+ canDownloadGif={canDownloadGif}
+ gifPending={exportingGif}
+ onDownloadGif={() => {
+ void downloadGif();
+ }}
+ canArchive={canArchiveRecording}
+ isArchived={Boolean(recording.archivedAt)}
+ onArchiveChanged={() => navigate("/library", { replace: true })}
onDeleted={() => navigate("/library", { replace: true })}
/>
) : null}
diff --git a/templates/clips/changelog/2026-07-29-clip-menus-on-the-watch-page-now-include-archive-and-downloa.md b/templates/clips/changelog/2026-07-29-clip-menus-on-the-watch-page-now-include-archive-and-downloa.md
new file mode 100644
index 0000000000..c810887e9f
--- /dev/null
+++ b/templates/clips/changelog/2026-07-29-clip-menus-on-the-watch-page-now-include-archive-and-downloa.md
@@ -0,0 +1,6 @@
+---
+type: added
+date: 2026-07-29
+---
+
+Clip menus on the watch page now include Archive and Download as GIF.