- PWA share target: shared text/links start a new chat or go into a
recent session (chooser dialog)
- Prompt templates (personal + global for admins) with {{placeholders}}
that are asked for on insert; managed under Settings → Templates
- Ctrl/Cmd+K command palette; Alt+N new session, Alt+Up/Down switch
sessions, Ctrl+, settings
- Sessions can be pinned, archived and filed into folders; the list can
be grouped by date or by project, groups are collapsible
- Fix: the "What's new" dialog was never shown to signed-in users
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
124 lines
5.2 KiB
TypeScript
124 lines
5.2 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "../dialogs";
|
|
import { useT } from "../i18n";
|
|
import { navigate } from "../router";
|
|
import { fill, loadTemplates, placeholders, type Template, type TemplateLists } from "../templates";
|
|
import { Icon } from "./Icon";
|
|
|
|
/** Choose a prompt template, fill its placeholders, return the text. */
|
|
export function TemplatePicker({ onPick, onClose }: { onPick: (text: string) => void; onClose: () => void }) {
|
|
const t = useT();
|
|
const [lists, setLists] = useState<TemplateLists>();
|
|
const [q, setQ] = useState("");
|
|
const [active, setActive] = useState(0);
|
|
const [chosen, setChosen] = useState<Template>();
|
|
const [values, setValues] = useState<Record<string, string>>({});
|
|
const first = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
|
|
|
useEffect(() => {
|
|
loadTemplates()
|
|
.then(setLists)
|
|
.catch((e) => (toast(e.message), onClose()));
|
|
const h = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
|
window.addEventListener("keydown", h);
|
|
return () => window.removeEventListener("keydown", h);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const items = useMemo(() => {
|
|
const all = [...(lists?.personal ?? []).map((x) => ({ ...x, global: false })), ...(lists?.global ?? []).map((x) => ({ ...x, global: true }))];
|
|
const needle = q.trim().toLowerCase();
|
|
return needle ? all.filter((x) => x.name.toLowerCase().includes(needle) || x.text.toLowerCase().includes(needle)) : all;
|
|
}, [lists, q]);
|
|
|
|
const choose = (tpl: Template) => {
|
|
const ph = placeholders(tpl.text);
|
|
if (!ph.length) return onPick(tpl.text);
|
|
setChosen(tpl);
|
|
setValues(Object.fromEntries(ph.map((p) => [p.name, ""])));
|
|
setTimeout(() => first.current?.focus(), 0);
|
|
};
|
|
|
|
if (!lists) return null;
|
|
const ph = chosen ? placeholders(chosen.text) : [];
|
|
|
|
return (
|
|
<div className="modal-backdrop" onClick={onClose}>
|
|
<div className="modal tpl-picker" role="dialog" aria-label={t("templates.title")} onClick={(e) => e.stopPropagation()}>
|
|
{!chosen ? (
|
|
<>
|
|
<h3>
|
|
<Icon name="template" /> {t("templates.title")}
|
|
</h3>
|
|
<input
|
|
autoFocus
|
|
style={{ width: "100%" }}
|
|
value={q}
|
|
placeholder={t("templates.filter")}
|
|
onChange={(e) => (setQ(e.target.value), setActive(0))}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "ArrowDown") (e.preventDefault(), setActive((a) => Math.min(a + 1, items.length - 1)));
|
|
if (e.key === "ArrowUp") (e.preventDefault(), setActive((a) => Math.max(a - 1, 0)));
|
|
if (e.key === "Enter" && items[active]) (e.preventDefault(), choose(items[active]));
|
|
}}
|
|
/>
|
|
<div className="tpl-list">
|
|
{items.length === 0 && (
|
|
<div className="empty" style={{ padding: 16 }}>
|
|
{t("templates.none")}
|
|
</div>
|
|
)}
|
|
{items.map((x, i) => (
|
|
<button key={(x.global ? "g" : "p") + x.id} className={i === active ? "active" : ""} onClick={() => choose(x)} onMouseEnter={() => setActive(i)}>
|
|
<span className="tpl-name">
|
|
{x.name}
|
|
{x.global && <span className="chip">{t("templates.global")}</span>}
|
|
</span>
|
|
<span className="tpl-preview">{x.text.slice(0, 140)}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="row" style={{ marginTop: 12 }}>
|
|
<button className="ghost small" onClick={() => (onClose(), navigate("/settings/templates"))}>
|
|
<Icon name="edit" /> {t("templates.manage")}
|
|
</button>
|
|
<span className="grow" />
|
|
<button onClick={onClose}>{t("common.cancel")}</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<form onSubmit={(e) => (e.preventDefault(), onPick(fill(chosen.text, values)))}>
|
|
<h3>{chosen.name}</h3>
|
|
{ph.map((p, i) => (
|
|
<label key={p.name} className="tpl-field">
|
|
<span>{p.name}</span>
|
|
<textarea
|
|
ref={i === 0 ? (first as React.RefObject<HTMLTextAreaElement>) : undefined}
|
|
rows={2}
|
|
value={values[p.name] ?? ""}
|
|
placeholder={p.hint}
|
|
onChange={(e) => setValues({ ...values, [p.name]: e.target.value })}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
e.currentTarget.form?.requestSubmit();
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
))}
|
|
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
|
|
<button type="button" onClick={() => setChosen(undefined)}>
|
|
{t("common.back")}
|
|
</button>
|
|
<button type="submit" className="primary">
|
|
{t("templates.insert")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|