Add share target, prompt templates, command palette and session organisation
- 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>
This commit is contained in:
1 parent
b5de531d2e
commit
428e2df40a
25 files changed
+1106
-33
No files matched your search
@@ -0,0 +1,123 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user