Prompt gallery (#957)

- [x] show prompt instead of app in autocomplete
- [x] use proper array/list for db (tags)
- [x] don't do <dyad-prompt> - replace inline
This commit is contained in:
Will Chen
2025-08-18 13:25:11 -07:00
committed by GitHub
parent a547735714
commit 573642ae5f
26 changed files with 1540 additions and 42 deletions

View File

@@ -0,0 +1,8 @@
CREATE TABLE `prompts` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`title` text NOT NULL,
`description` text,
`content` text NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);

View File

@@ -0,0 +1,578 @@
{
"version": "6",
"dialect": "sqlite",
"id": "6ac2fe61-675b-4e3f-baf7-0f7d5f76bb2c",
"prevId": "a7f4a6e1-2a38-4dc8-a37e-b473b6304bab",
"tables": {
"apps": {
"name": "apps",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"github_org": {
"name": "github_org",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_repo": {
"name": "github_repo",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_branch": {
"name": "github_branch",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supabase_project_id": {
"name": "supabase_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_project_id": {
"name": "neon_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_development_branch_id": {
"name": "neon_development_branch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_preview_branch_id": {
"name": "neon_preview_branch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_id": {
"name": "vercel_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_name": {
"name": "vercel_project_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_team_id": {
"name": "vercel_team_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_deployment_url": {
"name": "vercel_deployment_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"install_command": {
"name": "install_command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"start_command": {
"name": "start_command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"chat_context": {
"name": "chat_context",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"chats": {
"name": "chats",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"initial_commit_hash": {
"name": "initial_commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"chats_app_id_apps_id_fk": {
"name": "chats_app_id_apps_id_fk",
"tableFrom": "chats",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_model_providers": {
"name": "language_model_providers",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_base_url": {
"name": "api_base_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"env_var_name": {
"name": "env_var_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_models": {
"name": "language_models",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_name": {
"name": "api_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"builtin_provider_id": {
"name": "builtin_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_provider_id": {
"name": "custom_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_output_tokens": {
"name": "max_output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"context_window": {
"name": "context_window",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"language_models_custom_provider_id_language_model_providers_id_fk": {
"name": "language_models_custom_provider_id_language_model_providers_id_fk",
"tableFrom": "language_models",
"tableTo": "language_model_providers",
"columnsFrom": [
"custom_provider_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"chat_id": {
"name": "chat_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"approval_state": {
"name": "approval_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"messages_chat_id_chats_id_fk": {
"name": "messages_chat_id_chats_id_fk",
"tableFrom": "messages",
"tableTo": "chats",
"columnsFrom": [
"chat_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"prompts": {
"name": "prompts",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"versions": {
"name": "versions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"neon_db_timestamp": {
"name": "neon_db_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"versions_app_commit_unique": {
"name": "versions_app_commit_unique",
"columns": [
"app_id",
"commit_hash"
],
"isUnique": true
}
},
"foreignKeys": {
"versions_app_id_apps_id_fk": {
"name": "versions_app_id_apps_id_fk",
"tableFrom": "versions",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -78,6 +78,13 @@
"when": 1755110011615, "when": 1755110011615,
"tag": "0010_nappy_fat_cobra", "tag": "0010_nappy_fat_cobra",
"breakpoints": true "breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1755545060076,
"tag": "0011_light_zeigeist",
"breakpoints": true
} }
] ]
} }

View File

@@ -727,6 +727,30 @@ export class PageObject {
await this.page.getByRole("link", { name: "Settings" }).click(); await this.page.getByRole("link", { name: "Settings" }).click();
} }
async goToLibraryTab() {
await this.page.getByRole("link", { name: "Library" }).click();
}
async createPrompt({
title,
description,
content,
}: {
title: string;
description?: string;
content: string;
}) {
await this.page.getByRole("button", { name: "New Prompt" }).click();
await this.page.getByRole("textbox", { name: "Title" }).fill(title);
if (description) {
await this.page
.getByRole("textbox", { name: "Description (optional)" })
.fill(description);
}
await this.page.getByRole("textbox", { name: "Content" }).fill(content);
await this.page.getByRole("button", { name: "Save" }).click();
}
getTitleBarAppNameButton() { getTitleBarAppNameButton() {
return this.page.getByTestId("title-bar-app-name-button"); return this.page.getByTestId("title-bar-app-name-button");
} }

View File

@@ -0,0 +1,56 @@
import { test } from "./helpers/test_helper";
import { expect } from "@playwright/test";
test("create and edit prompt", async ({ po }) => {
await po.setUp();
await po.goToLibraryTab();
await po.createPrompt({
title: "title1",
description: "desc",
content: "prompt1content",
});
await expect(po.page.getByTestId("prompt-card")).toMatchAriaSnapshot();
await po.page.getByTestId("edit-prompt-button").click();
await po.page
.getByRole("textbox", { name: "Content" })
.fill("prompt1content-edited");
await po.page.getByRole("button", { name: "Save" }).click();
await expect(po.page.getByTestId("prompt-card")).toMatchAriaSnapshot();
});
test("delete prompt", async ({ po }) => {
await po.setUp();
await po.goToLibraryTab();
await po.createPrompt({
title: "title1",
description: "desc",
content: "prompt1content",
});
await po.page.getByTestId("delete-prompt-button").click();
await po.page.getByRole("button", { name: "Delete" }).click();
await expect(po.page.getByTestId("prompt-card")).not.toBeVisible();
});
test("use prompt", async ({ po }) => {
await po.setUp();
await po.goToLibraryTab();
await po.createPrompt({
title: "title1",
description: "desc",
content: "prompt1content",
});
await po.goToAppsTab();
await po.getChatInput().click();
await po.getChatInput().fill("[dump] @");
await po.page.getByRole("menuitem", { name: "Choose title1" }).click();
await po.page.getByRole("button", { name: "Send message" }).click();
await po.waitForChatCompletion();
await po.snapshotServerDump("last-message");
});

View File

@@ -0,0 +1,7 @@
- heading "title1" [level=3]
- paragraph: desc
- button:
- img
- button:
- img
- text: prompt1content

View File

@@ -0,0 +1,7 @@
- heading "title1" [level=3]
- paragraph: desc
- button:
- img
- button:
- img
- text: prompt1content-edited

View File

@@ -0,0 +1,3 @@
===
role: user
message: [dump] prompt1content

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { replacePromptReference } from "@/ipc/utils/replacePromptReference";
describe("replacePromptReference", () => {
it("returns original when no references present", () => {
const input = "Hello world";
const output = replacePromptReference(input, {});
expect(output).toBe(input);
});
it("replaces a single @prompt:id with content", () => {
const input = "Use this: @prompt:42";
const prompts = { 42: "Meaning of life" };
const output = replacePromptReference(input, prompts);
expect(output).toBe("Use this: Meaning of life");
});
it("replaces multiple occurrences and keeps surrounding text", () => {
const input = "A @prompt:1 and B @prompt:2 end";
const prompts = { 1: "One", 2: "Two" };
const output = replacePromptReference(input, prompts);
expect(output).toBe("A One and B Two end");
});
it("leaves unknown references intact", () => {
const input = "Unknown @prompt:99 here";
const prompts = { 1: "One" };
const output = replacePromptReference(input, prompts);
expect(output).toBe("Unknown @prompt:99 here");
});
it("supports string keys in map as well as numeric", () => {
const input = "Mix @prompt:7 and @prompt:8";
const prompts = { "7": "Seven", 8: "Eight" } as Record<
string | number,
string
>;
const output = replacePromptReference(input, prompts);
expect(output).toBe("Mix Seven and Eight");
});
});

View File

@@ -0,0 +1,235 @@
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Plus, Save, Edit2 } from "lucide-react";
interface CreateOrEditPromptDialogProps {
mode: "create" | "edit";
prompt?: {
id: number;
title: string;
description: string | null;
content: string;
};
onCreatePrompt?: (prompt: {
title: string;
description?: string;
content: string;
}) => Promise<any>;
onUpdatePrompt?: (prompt: {
id: number;
title: string;
description?: string;
content: string;
}) => Promise<any>;
trigger?: React.ReactNode;
}
export function CreateOrEditPromptDialog({
mode,
prompt,
onCreatePrompt,
onUpdatePrompt,
trigger,
}: CreateOrEditPromptDialogProps) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState({
title: "",
description: "",
content: "",
});
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize textarea function
const adjustTextareaHeight = () => {
const textarea = textareaRef.current;
if (textarea) {
// Store current height to avoid flicker
const currentHeight = textarea.style.height;
textarea.style.height = "auto";
const scrollHeight = textarea.scrollHeight;
const maxHeight = window.innerHeight * 0.6 - 100; // 60vh in pixels
const minHeight = 150; // 150px minimum
const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
// Only update if height actually changed to reduce reflows
if (`${newHeight}px` !== currentHeight) {
textarea.style.height = `${newHeight}px`;
}
}
};
// Initialize draft with prompt data when editing
useEffect(() => {
if (mode === "edit" && prompt) {
setDraft({
title: prompt.title,
description: prompt.description || "",
content: prompt.content,
});
} else {
setDraft({ title: "", description: "", content: "" });
}
}, [mode, prompt, open]);
// Auto-resize textarea when content changes
useEffect(() => {
adjustTextareaHeight();
}, [draft.content]);
// Trigger resize when dialog opens
useEffect(() => {
if (open) {
// Small delay to ensure the dialog is fully rendered
setTimeout(adjustTextareaHeight, 0);
}
}, [open]);
const resetDraft = () => {
if (mode === "edit" && prompt) {
setDraft({
title: prompt.title,
description: prompt.description || "",
content: prompt.content,
});
} else {
setDraft({ title: "", description: "", content: "" });
}
};
const onSave = async () => {
if (!draft.title.trim() || !draft.content.trim()) return;
if (mode === "create" && onCreatePrompt) {
await onCreatePrompt({
title: draft.title.trim(),
description: draft.description.trim() || undefined,
content: draft.content,
});
} else if (mode === "edit" && onUpdatePrompt && prompt) {
await onUpdatePrompt({
id: prompt.id,
title: draft.title.trim(),
description: draft.description.trim() || undefined,
content: draft.content,
});
}
setOpen(false);
};
const handleCancel = () => {
resetDraft();
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
{trigger ? (
<DialogTrigger asChild>{trigger}</DialogTrigger>
) : mode === "create" ? (
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" /> New Prompt
</Button>
</DialogTrigger>
) : (
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
size="icon"
variant="ghost"
data-testid="edit-prompt-button"
>
<Edit2 className="h-4 w-4" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Edit prompt</p>
</TooltipContent>
</Tooltip>
)}
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{mode === "create" ? "Create New Prompt" : "Edit Prompt"}
</DialogTitle>
<DialogDescription>
{mode === "create"
? "Create a new prompt template for your library."
: "Edit your prompt template."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Input
placeholder="Title"
value={draft.title}
onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))}
/>
<Input
placeholder="Description (optional)"
value={draft.description}
onChange={(e) =>
setDraft((d) => ({ ...d, description: e.target.value }))
}
/>
<Textarea
ref={textareaRef}
placeholder="Content"
value={draft.content}
onChange={(e) => {
setDraft((d) => ({ ...d, content: e.target.value }));
// Use requestAnimationFrame for smoother updates
requestAnimationFrame(adjustTextareaHeight);
}}
className="resize-none overflow-y-auto"
style={{ minHeight: "150px" }}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button
onClick={onSave}
disabled={!draft.title.trim() || !draft.content.trim()}
>
<Save className="mr-2 h-4 w-4" /> Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Backward compatibility wrapper for create mode
export function CreatePromptDialog({
onCreatePrompt,
}: {
onCreatePrompt: (prompt: {
title: string;
description?: string;
content: string;
}) => Promise<any>;
}) {
return (
<CreateOrEditPromptDialog mode="create" onCreatePrompt={onCreatePrompt} />
);
}

View File

@@ -0,0 +1,71 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface DeleteConfirmationDialogProps {
itemName: string;
itemType?: string;
onDelete: () => void | Promise<void>;
trigger?: React.ReactNode;
}
export function DeleteConfirmationDialog({
itemName,
itemType = "item",
onDelete,
trigger,
}: DeleteConfirmationDialogProps) {
return (
<AlertDialog>
{trigger ? (
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
) : (
<Tooltip>
<TooltipTrigger asChild>
<AlertDialogTrigger asChild>
<Button
size="icon"
variant="ghost"
data-testid="delete-prompt-button"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Delete {itemType.toLowerCase()}</p>
</TooltipContent>
</Tooltip>
)}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {itemType}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{itemName}"? This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -1,4 +1,11 @@
import { Home, Inbox, Settings, HelpCircle, Store } from "lucide-react"; import {
Home,
Inbox,
Settings,
HelpCircle,
Store,
BookOpen,
} from "lucide-react";
import { Link, useRouterState } from "@tanstack/react-router"; import { Link, useRouterState } from "@tanstack/react-router";
import { useSidebar } from "@/components/ui/sidebar"; // import useSidebar hook import { useSidebar } from "@/components/ui/sidebar"; // import useSidebar hook
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
@@ -39,6 +46,11 @@ const items = [
to: "/settings", to: "/settings",
icon: Settings, icon: Settings,
}, },
{
title: "Library",
to: "/library",
icon: BookOpen,
},
{ {
title: "Hub", title: "Hub",
to: "/hub", to: "/hub",
@@ -51,6 +63,7 @@ type HoverState =
| "start-hover:app" | "start-hover:app"
| "start-hover:chat" | "start-hover:chat"
| "start-hover:settings" | "start-hover:settings"
| "start-hover:library"
| "clear-hover" | "clear-hover"
| "no-hover"; | "no-hover";
@@ -92,6 +105,8 @@ export function AppSidebar() {
selectedItem = "Chat"; selectedItem = "Chat";
} else if (hoverState === "start-hover:settings") { } else if (hoverState === "start-hover:settings") {
selectedItem = "Settings"; selectedItem = "Settings";
} else if (hoverState === "start-hover:library") {
selectedItem = "Library";
} else if (state === "expanded") { } else if (state === "expanded") {
if (isAppRoute) { if (isAppRoute) {
selectedItem = "Apps"; selectedItem = "Apps";
@@ -195,6 +210,8 @@ function AppIcons({
onHoverChange("start-hover:chat"); onHoverChange("start-hover:chat");
} else if (item.title === "Settings") { } else if (item.title === "Settings") {
onHoverChange("start-hover:settings"); onHoverChange("start-hover:settings");
} else if (item.title === "Library") {
onHoverChange("start-hover:library");
} }
}} }}
> >

View File

@@ -21,6 +21,7 @@ import {
} from "lexical-beautiful-mentions"; } from "lexical-beautiful-mentions";
import { KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH } from "lexical"; import { KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH } from "lexical";
import { useLoadApps } from "@/hooks/useLoadApps"; import { useLoadApps } from "@/hooks/useLoadApps";
import { usePrompts } from "@/hooks/usePrompts";
import { forwardRef } from "react"; import { forwardRef } from "react";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { selectedAppIdAtom } from "@/atoms/appAtoms";
@@ -36,26 +37,35 @@ const beautifulMentionsTheme: BeautifulMentionsTheme = {
const CustomMenuItem = forwardRef< const CustomMenuItem = forwardRef<
HTMLLIElement, HTMLLIElement,
BeautifulMentionsMenuItemProps BeautifulMentionsMenuItemProps
>(({ selected, item, ...props }, ref) => ( >(({ selected, item, ...props }, ref) => {
<li const isPrompt = typeof item !== "string" && item.data?.type === "prompt";
className={`m-0 flex items-center px-3 py-2 cursor-pointer whitespace-nowrap ${ const label = isPrompt ? "Prompt" : "App";
selected const value = typeof item === "string" ? item : (item as any)?.value;
? "bg-accent text-accent-foreground" return (
: "bg-popover text-popover-foreground hover:bg-accent/50" <li
}`} className={`m-0 flex items-center px-3 py-2 cursor-pointer whitespace-nowrap ${
{...props} selected
ref={ref} ? "bg-accent text-accent-foreground"
> : "bg-popover text-popover-foreground hover:bg-accent/50"
<div className="flex items-center space-x-2 min-w-0"> }`}
<span className="px-2 py-0.5 text-xs font-medium bg-primary text-primary-foreground rounded-md flex-shrink-0"> {...props}
App ref={ref}
</span> >
<span className="truncate text-sm"> <div className="flex items-center space-x-2 min-w-0">
{typeof item === "string" ? item : item.value} <span
</span> className={`px-2 py-0.5 text-xs font-medium rounded-md flex-shrink-0 ${
</div> isPrompt
</li> ? "bg-purple-500 text-white"
)); : "bg-primary text-primary-foreground"
}`}
>
{label}
</span>
<span className="truncate text-sm">{value}</span>
</div>
</li>
);
});
// Custom menu component // Custom menu component
function CustomMenu({ loading: _loading, ...props }: any) { function CustomMenu({ loading: _loading, ...props }: any) {
@@ -136,13 +146,24 @@ function ClearEditorPlugin({
} }
// Plugin to sync external value prop into the editor // Plugin to sync external value prop into the editor
function ExternalValueSyncPlugin({ value }: { value: string }) { function ExternalValueSyncPlugin({
value,
promptsById,
}: {
value: string;
promptsById: Record<number, string>;
}) {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
useEffect(() => { useEffect(() => {
// Derive the display text that should appear in the editor (@Name) from the // Derive the display text that should appear in the editor (@Name) from the
// internal value representation (@app:Name) // internal value representation (@app:Name)
const displayText = (value || "").replace(MENTION_REGEX, "@$1"); let displayText = (value || "").replace(MENTION_REGEX, "@$1");
displayText = displayText.replace(/@prompt:(\d+)/g, (_m, idStr) => {
const id = Number(idStr);
const title = promptsById[id];
return title ? `@${title}` : _m;
});
const currentText = editor.getEditorState().read(() => { const currentText = editor.getEditorState().read(() => {
const root = $getRoot(); const root = $getRoot();
@@ -157,34 +178,32 @@ function ExternalValueSyncPlugin({ value }: { value: string }) {
const paragraph = $createParagraphNode(); const paragraph = $createParagraphNode();
// Build nodes from the internal value, turning @app:Name into a mention node // Build nodes from internal value, turning @app:Name and @prompt:<id> into mention nodes
const mentionRegex = /@app:([a-zA-Z0-9_-]+)/g;
let lastIndex = 0; let lastIndex = 0;
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
const combined = /@app:([a-zA-Z0-9_-]+)|@prompt:(\d+)/g;
while ((match = mentionRegex.exec(value)) !== null) { while ((match = combined.exec(value)) !== null) {
const [full, name] = match;
const start = match.index; const start = match.index;
const full = match[0];
// Append any text before the mention
if (start > lastIndex) { if (start > lastIndex) {
const textBefore = value.slice(lastIndex, start); const textBefore = value.slice(lastIndex, start);
if (textBefore) paragraph.append($createTextNode(textBefore)); if (textBefore) paragraph.append($createTextNode(textBefore));
} }
if (match[1]) {
// Append the actual mention node (@ trigger with value = Name) const appName = match[1];
paragraph.append($createBeautifulMentionNode("@", name)); paragraph.append($createBeautifulMentionNode("@", appName));
} else if (match[2]) {
const id = Number(match[2]);
const title = promptsById[id] || `prompt:${id}`;
paragraph.append($createBeautifulMentionNode("@", title));
}
lastIndex = start + full.length; lastIndex = start + full.length;
} }
// Append any trailing text after the last mention
if (lastIndex < value.length) { if (lastIndex < value.length) {
const trailing = value.slice(lastIndex); const trailing = value.slice(lastIndex);
if (trailing) paragraph.append($createTextNode(trailing)); if (trailing) paragraph.append($createTextNode(trailing));
} }
// If there were no mentions at all, just append the raw value as text
if (value && paragraph.getTextContent() === "") { if (value && paragraph.getTextContent() === "") {
paragraph.append($createTextNode(value)); paragraph.append($createTextNode(value));
} }
@@ -192,7 +211,7 @@ function ExternalValueSyncPlugin({ value }: { value: string }) {
root.append(paragraph); root.append(paragraph);
paragraph.selectEnd(); paragraph.selectEnd();
}); });
}, [editor, value]); }, [editor, value, promptsById]);
return null; return null;
} }
@@ -221,6 +240,7 @@ export function LexicalChatInput({
disabled = false, disabled = false,
}: LexicalChatInputProps) { }: LexicalChatInputProps) {
const { apps } = useLoadApps(); const { apps } = useLoadApps();
const { prompts } = usePrompts();
const [shouldClear, setShouldClear] = useState(false); const [shouldClear, setShouldClear] = useState(false);
const selectedAppId = useAtomValue(selectedAppIdAtom); const selectedAppId = useAtomValue(selectedAppIdAtom);
@@ -252,10 +272,17 @@ export function LexicalChatInput({
}); });
const appMentions = filteredApps.map((app) => app.name); const appMentions = filteredApps.map((app) => app.name);
const promptItems = (prompts || []).map((p) => ({
value: p.title,
type: "prompt",
id: p.id,
}));
return { return {
"@": appMentions, "@": [...appMentions, ...promptItems],
}; };
}, [apps, selectedAppId, value, excludeCurrentApp]); }, [apps, selectedAppId, value, excludeCurrentApp, prompts]);
const initialConfig = { const initialConfig = {
namespace: "ChatInput", namespace: "ChatInput",
@@ -291,11 +318,18 @@ export function LexicalChatInput({
); );
textContent = textContent.replace(mentionRegex, "@app:$1"); textContent = textContent.replace(mentionRegex, "@app:$1");
} }
// Convert @PromptTitle to @prompt:<id>
const map = new Map((prompts || []).map((p) => [p.title, p.id]));
for (const [title, id] of map.entries()) {
const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`@(${escapedTitle})(?![\\w-])`, "g");
textContent = textContent.replace(regex, `@prompt:${id}`);
}
} }
onChange(textContent); onChange(textContent);
}); });
}, },
[onChange, apps], [onChange, apps, prompts],
); );
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
@@ -343,7 +377,12 @@ export function LexicalChatInput({
<OnChangePlugin onChange={handleEditorChange} /> <OnChangePlugin onChange={handleEditorChange} />
<HistoryPlugin /> <HistoryPlugin />
<EnterKeyPlugin onSubmit={handleSubmit} /> <EnterKeyPlugin onSubmit={handleSubmit} />
<ExternalValueSyncPlugin value={value} /> <ExternalValueSyncPlugin
value={value}
promptsById={Object.fromEntries(
(prompts || []).map((p) => [p.id, p.title]),
)}
/>
<ClearEditorPlugin <ClearEditorPlugin
shouldClear={shouldClear} shouldClear={shouldClear}
onCleared={handleCleared} onCleared={handleCleared}

View File

@@ -0,0 +1,26 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background",
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };

View File

@@ -2,6 +2,19 @@ import { sql } from "drizzle-orm";
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
export const prompts = sqliteTable("prompts", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
description: text("description"),
content: text("content").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
export const apps = sqliteTable("apps", { export const apps = sqliteTable("apps", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(), name: text("name").notNull(),

81
src/hooks/usePrompts.ts Normal file
View File

@@ -0,0 +1,81 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client";
export interface PromptItem {
id: number;
title: string;
description: string | null;
content: string;
createdAt: Date;
updatedAt: Date;
}
export function usePrompts() {
const queryClient = useQueryClient();
const listQuery = useQuery({
queryKey: ["prompts"],
queryFn: async (): Promise<PromptItem[]> => {
const ipc = IpcClient.getInstance();
return ipc.listPrompts();
},
meta: { showErrorToast: true },
});
const createMutation = useMutation({
mutationFn: async (params: {
title: string;
description?: string;
content: string;
}): Promise<PromptItem> => {
const ipc = IpcClient.getInstance();
return ipc.createPrompt(params);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["prompts"] });
},
meta: {
showErrorToast: true,
},
});
const updateMutation = useMutation({
mutationFn: async (params: {
id: number;
title: string;
description?: string;
content: string;
}): Promise<void> => {
const ipc = IpcClient.getInstance();
return ipc.updatePrompt(params);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["prompts"] });
},
meta: {
showErrorToast: true,
},
});
const deleteMutation = useMutation({
mutationFn: async (id: number): Promise<void> => {
const ipc = IpcClient.getInstance();
return ipc.deletePrompt(id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["prompts"] });
},
meta: {
showErrorToast: true,
},
});
return {
prompts: listQuery.data ?? [],
isLoading: listQuery.isLoading,
error: listQuery.error,
refetch: listQuery.refetch,
createPrompt: createMutation.mutateAsync,
updatePrompt: updateMutation.mutateAsync,
deletePrompt: deleteMutation.mutateAsync,
};
}

View File

@@ -61,6 +61,9 @@ import { FileUploadsState } from "../utils/file_uploads_state";
import { OpenAIResponsesProviderOptions } from "@ai-sdk/openai"; import { OpenAIResponsesProviderOptions } from "@ai-sdk/openai";
import { extractMentionedAppsCodebases } from "../utils/mention_apps"; import { extractMentionedAppsCodebases } from "../utils/mention_apps";
import { parseAppMentions } from "@/shared/parse_mention_apps"; import { parseAppMentions } from "@/shared/parse_mention_apps";
import { prompts as promptsTable } from "../../db/schema";
import { inArray } from "drizzle-orm";
import { replacePromptReference } from "../utils/replacePromptReference";
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>; type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
@@ -274,6 +277,26 @@ export function registerChatStreamHandlers() {
// Add user message to database with attachment info // Add user message to database with attachment info
let userPrompt = req.prompt + (attachmentInfo ? attachmentInfo : ""); let userPrompt = req.prompt + (attachmentInfo ? attachmentInfo : "");
// Inline referenced prompt contents for mentions like @prompt:<id>
try {
const matches = Array.from(userPrompt.matchAll(/@prompt:(\d+)/g));
if (matches.length > 0) {
const ids = Array.from(new Set(matches.map((m) => Number(m[1]))));
const referenced = await db
.select()
.from(promptsTable)
.where(inArray(promptsTable.id, ids));
if (referenced.length > 0) {
const promptsMap: Record<number, string> = {};
for (const p of referenced) {
promptsMap[p.id] = p.content;
}
userPrompt = replacePromptReference(userPrompt, promptsMap);
}
}
} catch (e) {
logger.error("Failed to inline referenced prompts:", e);
}
if (req.selectedComponent) { if (req.selectedComponent) {
let componentSnippet = "[component snippet not available]"; let componentSnippet = "[component snippet not available]";
try { try {

View File

@@ -0,0 +1,91 @@
import { IpcMainInvokeEvent } from "electron";
import log from "electron-log";
import { createLoggedHandler } from "./safe_handle";
import { db } from "@/db";
import { prompts } from "@/db/schema";
import { eq } from "drizzle-orm";
import {
CreatePromptParamsDto,
PromptDto,
UpdatePromptParamsDto,
} from "../ipc_types";
const logger = log.scope("prompt_handlers");
const handle = createLoggedHandler(logger);
export function registerPromptHandlers() {
handle("prompts:list", async (): Promise<PromptDto[]> => {
const rows = db.select().from(prompts).all();
return rows.map((r) => ({
id: r.id!,
title: r.title,
description: r.description ?? null,
content: r.content,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
}));
});
handle(
"prompts:create",
async (
_e: IpcMainInvokeEvent,
params: CreatePromptParamsDto,
): Promise<PromptDto> => {
const { title, description, content } = params;
if (!title || !content) {
throw new Error("Title and content are required");
}
const result = db
.insert(prompts)
.values({
title,
description: description ?? null,
content,
})
.run();
const id = Number(result.lastInsertRowid);
const row = db.select().from(prompts).where(eq(prompts.id, id)).get();
if (!row) throw new Error("Failed to fetch created prompt");
return {
id: row.id!,
title: row.title,
description: row.description ?? null,
content: row.content,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
},
);
handle(
"prompts:update",
async (
_e: IpcMainInvokeEvent,
params: UpdatePromptParamsDto,
): Promise<void> => {
const { id, title, description, content } = params;
if (!id) throw new Error("Prompt id is required");
if (!title || !content) throw new Error("Title and content are required");
const now = new Date();
db.update(prompts)
.set({
title,
description: description ?? null,
content,
updatedAt: now,
})
.where(eq(prompts.id, id))
.run();
},
);
handle(
"prompts:delete",
async (_e: IpcMainInvokeEvent, id: number): Promise<void> => {
if (!id) throw new Error("Prompt id is required");
db.delete(prompts).where(eq(prompts.id, id)).run();
},
);
}

View File

@@ -58,6 +58,9 @@ import type {
RevertVersionResponse, RevertVersionResponse,
RevertVersionParams, RevertVersionParams,
RespondToAppInputParams, RespondToAppInputParams,
PromptDto,
CreatePromptParamsDto,
UpdatePromptParamsDto,
} from "./ipc_types"; } from "./ipc_types";
import type { Template } from "../shared/templates"; import type { Template } from "../shared/templates";
import type { AppChatContext, ProposalResult } from "@/lib/schemas"; import type { AppChatContext, ProposalResult } from "@/lib/schemas";
@@ -1069,4 +1072,21 @@ export class IpcClient {
public async getTemplates(): Promise<Template[]> { public async getTemplates(): Promise<Template[]> {
return this.ipcRenderer.invoke("get-templates"); return this.ipcRenderer.invoke("get-templates");
} }
// --- Prompts Library ---
public async listPrompts(): Promise<PromptDto[]> {
return this.ipcRenderer.invoke("prompts:list");
}
public async createPrompt(params: CreatePromptParamsDto): Promise<PromptDto> {
return this.ipcRenderer.invoke("prompts:create", params);
}
public async updatePrompt(params: UpdatePromptParamsDto): Promise<void> {
await this.ipcRenderer.invoke("prompts:update", params);
}
public async deletePrompt(id: number): Promise<void> {
await this.ipcRenderer.invoke("prompts:delete", id);
}
} }

View File

@@ -28,6 +28,7 @@ import { registerProblemsHandlers } from "./handlers/problems_handlers";
import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers"; import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers";
import { registerTemplateHandlers } from "./handlers/template_handlers"; import { registerTemplateHandlers } from "./handlers/template_handlers";
import { registerPortalHandlers } from "./handlers/portal_handlers"; import { registerPortalHandlers } from "./handlers/portal_handlers";
import { registerPromptHandlers } from "./handlers/prompt_handlers";
export function registerIpcHandlers() { export function registerIpcHandlers() {
// Register all IPC handlers by category // Register all IPC handlers by category
@@ -61,4 +62,5 @@ export function registerIpcHandlers() {
registerAppEnvVarsHandlers(); registerAppEnvVarsHandlers();
registerTemplateHandlers(); registerTemplateHandlers();
registerPortalHandlers(); registerPortalHandlers();
registerPromptHandlers();
} }

View File

@@ -353,6 +353,26 @@ export interface UploadFileToCodebaseResult {
filePath: string; filePath: string;
} }
// --- Prompts ---
export interface PromptDto {
id: number;
title: string;
description: string | null;
content: string;
createdAt: Date;
updatedAt: Date;
}
export interface CreatePromptParamsDto {
title: string;
description?: string;
content: string;
}
export interface UpdatePromptParamsDto extends CreatePromptParamsDto {
id: number;
}
export interface FileAttachment { export interface FileAttachment {
file: File; file: File;
type: "upload-to-codebase" | "chat-context"; type: "upload-to-codebase" | "chat-context";

View File

@@ -0,0 +1,16 @@
export function replacePromptReference(
userPrompt: string,
promptsById: Record<number | string, string>,
): string {
if (typeof userPrompt !== "string" || userPrompt.length === 0)
return userPrompt;
return userPrompt.replace(
/@prompt:(\d+)/g,
(_match: string, idStr: string) => {
const idNum = Number(idStr);
const replacement = promptsById[idNum] ?? promptsById[idStr];
return replacement !== undefined ? replacement : _match;
},
);
}

97
src/pages/library.tsx Normal file
View File

@@ -0,0 +1,97 @@
import React from "react";
import { usePrompts } from "@/hooks/usePrompts";
import {
CreatePromptDialog,
CreateOrEditPromptDialog,
} from "@/components/CreatePromptDialog";
import { DeleteConfirmationDialog } from "@/components/DeleteConfirmationDialog";
export default function LibraryPage() {
const { prompts, isLoading, createPrompt, updatePrompt, deletePrompt } =
usePrompts();
return (
<div className="min-h-screen px-8 py-6">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold mr-4">Library: Prompts</h1>
<CreatePromptDialog onCreatePrompt={createPrompt} />
</div>
{isLoading ? (
<div>Loading...</div>
) : prompts.length === 0 ? (
<div className="text-muted-foreground">
No prompts yet. Create one to get started.
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
{prompts.map((p) => (
<PromptCard
key={p.id}
prompt={p}
onUpdate={updatePrompt}
onDelete={deletePrompt}
/>
))}
</div>
)}
</div>
</div>
);
}
function PromptCard({
prompt,
onUpdate,
onDelete,
}: {
prompt: {
id: number;
title: string;
description: string | null;
content: string;
};
onUpdate: (p: {
id: number;
title: string;
description?: string;
content: string;
}) => Promise<void>;
onDelete: (id: number) => Promise<void>;
}) {
return (
<div
data-testid="prompt-card"
className="border rounded-lg p-4 bg-(--background-lightest) min-w-80"
>
<div className="space-y-2">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold">{prompt.title}</h3>
{prompt.description && (
<p className="text-sm text-muted-foreground">
{prompt.description}
</p>
)}
</div>
<div className="flex gap-2">
<CreateOrEditPromptDialog
mode="edit"
prompt={prompt}
onUpdatePrompt={onUpdate}
/>
<DeleteConfirmationDialog
itemName={prompt.title}
itemType="Prompt"
onDelete={() => onDelete(prompt.id)}
/>
</div>
</div>
<pre className="text-sm whitespace-pre-wrap bg-transparent border rounded p-2 max-h-48 overflow-auto">
{prompt.content}
</pre>
</div>
</div>
);
}

View File

@@ -106,6 +106,11 @@ const validInvokeChannels = [
"restart-dyad", "restart-dyad",
"get-templates", "get-templates",
"portal:migrate-create", "portal:migrate-create",
// Prompts
"prompts:list",
"prompts:create",
"prompts:update",
"prompts:delete",
// Test-only channels // Test-only channels
// These should ALWAYS be guarded with IS_TEST_BUILD in the main process. // These should ALWAYS be guarded with IS_TEST_BUILD in the main process.
// We can't detect with IS_TEST_BUILD in the preload script because // We can't detect with IS_TEST_BUILD in the preload script because

View File

@@ -6,10 +6,12 @@ import { settingsRoute } from "./routes/settings";
import { providerSettingsRoute } from "./routes/settings/providers/$provider"; import { providerSettingsRoute } from "./routes/settings/providers/$provider";
import { appDetailsRoute } from "./routes/app-details"; import { appDetailsRoute } from "./routes/app-details";
import { hubRoute } from "./routes/hub"; import { hubRoute } from "./routes/hub";
import { libraryRoute } from "./routes/library";
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
homeRoute, homeRoute,
hubRoute, hubRoute,
libraryRoute,
chatRoute, chatRoute,
appDetailsRoute, appDetailsRoute,
settingsRoute.addChildren([providerSettingsRoute]), settingsRoute.addChildren([providerSettingsRoute]),

9
src/routes/library.ts Normal file
View File

@@ -0,0 +1,9 @@
import { Route } from "@tanstack/react-router";
import { rootRoute } from "./root";
import LibraryPage from "@/pages/library";
export const libraryRoute = new Route({
getParentRoute: () => rootRoute,
path: "/library",
component: LibraryPage,
});