From 51ab47540e672cd22175d98298dfd8547a89e67a Mon Sep 17 00:00:00 2001 From: pandec Date: Sun, 23 Aug 2026 12:11:10 +0200 Subject: [PATCH 1/2] feat(web): expand saved prompt previews --- .../settings/PromptsSettings.test.tsx | 175 ++++++++++++++++++ .../components/settings/PromptsSettings.tsx | 104 ++++++++--- apps/web/vite.config.ts | 1 + 3 files changed, 250 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/components/settings/PromptsSettings.test.tsx diff --git a/apps/web/src/components/settings/PromptsSettings.test.tsx b/apps/web/src/components/settings/PromptsSettings.test.tsx new file mode 100644 index 000000000000..8f88806fa540 --- /dev/null +++ b/apps/web/src/components/settings/PromptsSettings.test.tsx @@ -0,0 +1,175 @@ +import type { SavedPrompt } from "@t3tools/contracts/settings"; +import type { ReactElement } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { visitElements } from "../../test/reactElementTree"; +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useId: () => "saved-prompt-content", + useMemo: reactHookHarness.useMemo, + useRef: reactHookHarness.useRef, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("~/hooks/useSavedPrompts", () => ({ + useSavedPrompts: vi.fn(), +})); + +import { useSavedPrompts, type SavedPrompts } from "~/hooks/useSavedPrompts"; +import { Collapsible, CollapsibleContent } from "../ui/collapsible"; +import { PromptsSettings, SavedPromptRow } from "./PromptsSettings"; + +type Elem = ReactElement>; + +const multilinePrompt: SavedPrompt = { + id: "prompt-1", + title: "Review checklist", + content: "First line of the prompt\nsecond line with more detail\n\nfourth line", +}; + +function mockSavedPrompts(overrides: Partial = {}): void { + vi.mocked(useSavedPrompts).mockReturnValue({ + prompts: [multilinePrompt], + hasConnectedEnvironment: true, + canEdit: true, + saveAll: vi.fn(), + ...overrides, + }); +} + +function renderRow(props: Parameters[0]): Elem { + hooks.beginRender(); + return SavedPromptRow(props) as Elem; +} + +function findChevron(row: Elem): Elem | null { + return visitElements(row, (element) => typeof element.props["aria-expanded"] === "boolean"); +} + +function findEdit(row: Elem): Elem | null { + return visitElements( + row, + (element) => element.props["aria-label"] === `Edit ${multilinePrompt.title}`, + ); +} + +describe("SavedPromptRow", () => { + beforeEach(() => { + hooks.reset(); + }); + + it("starts collapsed and expands in place to the exact multiline content", () => { + const onEdit = vi.fn(); + let row = renderRow({ prompt: multilinePrompt, canEdit: true, onEdit }); + + const collapsedChevron = findChevron(row); + expect(collapsedChevron?.props["aria-controls"]).toBe("saved-prompt-content"); + expect(collapsedChevron?.props["aria-expanded"]).toBe(false); + expect(collapsedChevron?.props["aria-label"]).toBe("Expand Review checklist"); + expect(visitElements(row, (element) => element.type === Collapsible)?.props.open).toBe(false); + + const collapsedPanel = visitElements(row, (element) => element.type === CollapsibleContent); + expect(collapsedPanel?.props.id).toBe("saved-prompt-content"); + expect(collapsedPanel?.props.keepMounted).toBe(true); + + (collapsedChevron?.props.onClick as (() => void) | undefined)?.(); + row = renderRow({ prompt: multilinePrompt, canEdit: true, onEdit }); + + const openChevron = findChevron(row); + expect(openChevron?.props["aria-expanded"]).toBe(true); + expect(openChevron?.props["aria-label"]).toBe("Collapse Review checklist"); + expect(visitElements(row, (element) => element.type === Collapsible)?.props.open).toBe(true); + + const panel = visitElements(row, (element) => element.type === CollapsibleContent); + const content = visitElements( + panel, + (element) => element.props.children === multilinePrompt.content, + ); + expect(content?.type).toBe("p"); + expect(content?.props.className).toContain("whitespace-pre-wrap"); + + // Expanding is view-only: the editor was never requested. + expect(onEdit).not.toHaveBeenCalled(); + + (openChevron?.props.onClick as (() => void) | undefined)?.(); + row = renderRow({ prompt: multilinePrompt, canEdit: true, onEdit }); + expect(findChevron(row)?.props["aria-expanded"]).toBe(false); + }); + + it("renders the chevron immediately before Edit, with Edit last", () => { + const row = renderRow({ prompt: multilinePrompt, canEdit: true, onEdit: vi.fn() }); + const chevron = findChevron(row); + const edit = findEdit(row); + const rail = visitElements(row, (element) => { + const children = element.props.children; + return Array.isArray(children) && children.includes(edit); + }); + + const actions = rail?.props.children as ReadonlyArray; + expect(actions.at(-1)).toBe(edit); + expect(actions.indexOf(chevron as Elem)).toBe(actions.length - 2); + }); + + it("keeps expansion enabled while Edit is disabled in read-only mode", () => { + const onEdit = vi.fn(); + let row = renderRow({ prompt: multilinePrompt, canEdit: false, onEdit }); + + expect(findEdit(row)?.props.disabled).toBe(true); + const chevron = findChevron(row); + expect(chevron?.props.disabled).toBeUndefined(); + + (chevron?.props.onClick as (() => void) | undefined)?.(); + row = renderRow({ prompt: multilinePrompt, canEdit: false, onEdit }); + expect(findChevron(row)?.props["aria-expanded"]).toBe(true); + expect(onEdit).not.toHaveBeenCalled(); + }); +}); + +describe("PromptsSettings", () => { + beforeEach(() => { + hooks.reset(); + vi.mocked(useSavedPrompts).mockReset(); + }); + + it("renders one row per prompt and Edit still opens the editor dialog", () => { + const second: SavedPrompt = { id: "prompt-2", title: "Second", content: "Body" }; + mockSavedPrompts({ prompts: [multilinePrompt, second] }); + + hooks.beginRender(); + let tree = PromptsSettings() as Elem; + const rows: Elem[] = []; + visitElements(tree, (element) => { + if (element.type === SavedPromptRow) rows.push(element); + return false; + }); + expect(rows.map((row) => (row.props.prompt as SavedPrompt).id)).toEqual([ + "prompt-1", + "prompt-2", + ]); + expect(rows.map((row) => row.props.canEdit)).toEqual([true, true]); + + (rows[0]?.props.onEdit as (() => void) | undefined)?.(); + hooks.beginRender(); + tree = PromptsSettings() as Elem; + const dialog = visitElements( + tree, + (element) => "request" in element.props && element.props.request !== null, + ); + expect(dialog?.props.request).toEqual({ + promptId: "prompt-1", + initial: { title: multilinePrompt.title, content: multilinePrompt.content }, + }); + }); +}); diff --git a/apps/web/src/components/settings/PromptsSettings.tsx b/apps/web/src/components/settings/PromptsSettings.tsx index 44a505401523..c12c4c185a37 100644 --- a/apps/web/src/components/settings/PromptsSettings.tsx +++ b/apps/web/src/components/settings/PromptsSettings.tsx @@ -1,9 +1,9 @@ import type { SavedPrompt } from "@t3tools/contracts/settings"; -import { PencilIcon, PlusIcon } from "lucide-react"; -import React, { type FormEvent, useEffect, useState } from "react"; +import { ChevronDownIcon, PencilIcon, PlusIcon } from "lucide-react"; +import React, { type FormEvent, useEffect, useId, useState } from "react"; import { useSavedPrompts } from "~/hooks/useSavedPrompts"; -import { randomUUID } from "~/lib/utils"; +import { cn, randomUUID } from "~/lib/utils"; import { searchableSetting } from "./settingsSearch"; import { AlertDialog, @@ -15,6 +15,7 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; +import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { Dialog, DialogDescription, @@ -27,7 +28,7 @@ import { import { Input } from "../ui/input"; import { Label } from "../ui/label"; import { Textarea } from "../ui/textarea"; -import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; +import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; interface SavedPromptInput { readonly title: string; @@ -91,33 +92,15 @@ export function PromptsSettings() {

) : ( prompts.map((prompt) => ( - - {prompt.title} - - {promptPreview(prompt)} - - - } - control={ - + prompt={prompt} + canEdit={canEdit} + onEdit={() => + setEditorRequest({ + promptId: prompt.id, + initial: { title: prompt.title, content: prompt.content }, + }) } /> )) @@ -137,6 +120,67 @@ function promptPreview(prompt: SavedPrompt): string { return prompt.content.split("\n", 1)[0] ?? ""; } +/** + * Compact saved-prompt row (title + first-line preview), expandable in place + * to the full content even while the library is read-only. Exported for tests. + */ +export function SavedPromptRow({ + prompt, + canEdit, + onEdit, +}: { + prompt: SavedPrompt; + canEdit: boolean; + onEdit: () => void; +}) { + const [isExpanded, setIsExpanded] = useState(false); + const contentId = useId(); + return ( +
+
+

+ {prompt.title} + + {promptPreview(prompt)} + +

+
+ + +
+
+ + +

+ {prompt.content} +

+
+
+
+ ); +} + /** * Add/edit dialog for a saved prompt. The parent owns which prompt (if any) * is being edited via `request`; the dialog owns form state and validation. diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 8309fad48109..7b2b3559d832 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -112,6 +112,7 @@ const ISOLATED_TEST_FILES = [ "src/components/ProviderUpdateEnvironmentRows.test.tsx", "src/components/settings/AddProviderInstanceDialog.environment.test.tsx", "src/components/settings/ProjectFaviconPickerDialog.test.tsx", + "src/components/settings/PromptsSettings.test.tsx", "src/components/settings/ProviderSettingsPanel.environment.test.tsx", "src/components/ServerUpdateAction.test.tsx", "src/components/Sidebar.logic.test.ts", From 828f055bca5ab77db9abda080530f342a8625e7f Mon Sep 17 00:00:00 2001 From: pandec Date: Sun, 23 Aug 2026 12:20:50 +0200 Subject: [PATCH 2/2] fix(web): hide the saved prompt preview while expanded The full content starts with the same first line, so keeping the inline preview visible read it twice. --- apps/web/src/components/settings/PromptsSettings.test.tsx | 8 ++++++++ apps/web/src/components/settings/PromptsSettings.tsx | 8 +++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/PromptsSettings.test.tsx b/apps/web/src/components/settings/PromptsSettings.test.tsx index 8f88806fa540..225d2a5a1825 100644 --- a/apps/web/src/components/settings/PromptsSettings.test.tsx +++ b/apps/web/src/components/settings/PromptsSettings.test.tsx @@ -58,6 +58,10 @@ function findChevron(row: Elem): Elem | null { return visitElements(row, (element) => typeof element.props["aria-expanded"] === "boolean"); } +function findPreview(row: Elem): Elem | null { + return visitElements(row, (element) => element.props.children === "First line of the prompt"); +} + function findEdit(row: Elem): Elem | null { return visitElements( row, @@ -83,6 +87,7 @@ describe("SavedPromptRow", () => { const collapsedPanel = visitElements(row, (element) => element.type === CollapsibleContent); expect(collapsedPanel?.props.id).toBe("saved-prompt-content"); expect(collapsedPanel?.props.keepMounted).toBe(true); + expect(findPreview(row)).not.toBeNull(); (collapsedChevron?.props.onClick as (() => void) | undefined)?.(); row = renderRow({ prompt: multilinePrompt, canEdit: true, onEdit }); @@ -91,6 +96,9 @@ describe("SavedPromptRow", () => { expect(openChevron?.props["aria-expanded"]).toBe(true); expect(openChevron?.props["aria-label"]).toBe("Collapse Review checklist"); expect(visitElements(row, (element) => element.type === Collapsible)?.props.open).toBe(true); + // The full content includes the first line, so the inline preview hides + // while expanded to avoid reading it twice. + expect(findPreview(row)).toBeNull(); const panel = visitElements(row, (element) => element.type === CollapsibleContent); const content = visitElements( diff --git a/apps/web/src/components/settings/PromptsSettings.tsx b/apps/web/src/components/settings/PromptsSettings.tsx index c12c4c185a37..d6b3ce01858d 100644 --- a/apps/web/src/components/settings/PromptsSettings.tsx +++ b/apps/web/src/components/settings/PromptsSettings.tsx @@ -140,9 +140,11 @@ export function SavedPromptRow({

{prompt.title} - - {promptPreview(prompt)} - + {!isExpanded && ( + + {promptPreview(prompt)} + + )}