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)}
+
+ )}