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,148 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { fmtRelative, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore, getState } from "../store";
|
||||
import { cycleThemeMode } from "../theme";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
keys?: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
const basename = (p: string) => p.replace(/\/+$/, "").split("/").pop() || p;
|
||||
const isMac = /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
export const MOD = isMac ? "⌘" : "Ctrl";
|
||||
|
||||
/** Root sessions, most recent first. */
|
||||
function recent() {
|
||||
return getState()
|
||||
.sessions.filter((s) => !s.parent_id)
|
||||
.sort((a, b) => b.updated_at - a.updated_at);
|
||||
}
|
||||
|
||||
/** Move to the next/previous session relative to the open one. */
|
||||
function step(dir: 1 | -1) {
|
||||
const list = recent();
|
||||
const cur = location.hash.match(/^#\/s\/([^/]+)/)?.[1];
|
||||
const i = list.findIndex((s) => s.id === cur);
|
||||
const next = list[i < 0 ? 0 : Math.min(Math.max(i + dir, 0), list.length - 1)];
|
||||
if (next) navigate(`/s/${next.id}`);
|
||||
}
|
||||
|
||||
/** Global keyboard shortcuts and the command palette (Ctrl/⌘+K). */
|
||||
export function Palette() {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const sessions = useStore((s) => s.sessions);
|
||||
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((o) => !o);
|
||||
setQ("");
|
||||
setActive(0);
|
||||
} else if (e.altKey && !mod && e.key.toLowerCase() === "n") {
|
||||
e.preventDefault();
|
||||
navigate("/new");
|
||||
} else if (e.altKey && !mod && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
||||
e.preventDefault();
|
||||
step(e.key === "ArrowUp" ? -1 : 1);
|
||||
} else if (mod && e.key === ",") {
|
||||
e.preventDefault();
|
||||
navigate("/settings");
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, []);
|
||||
|
||||
const items = useMemo<Item[]>(() => {
|
||||
const nav = (id: string, icon: string, label: Key, path: string, keys?: string): Item => ({ id, icon, label: t(label), keys, run: () => navigate(path) });
|
||||
const actions: Item[] = [
|
||||
nav("new", "plus", "palette.newSession", "/new", "Alt+N"),
|
||||
nav("files", "folder", "nav.files", "/files"),
|
||||
nav("terminal", "terminal", "nav.terminal", "/terminal"),
|
||||
nav("jobs", "clock", "nav.jobs", "/jobs"),
|
||||
nav("images", "image", "nav.images", "/images"),
|
||||
nav("stats", "chart", "nav.stats", "/stats"),
|
||||
nav("settings", "gear", "nav.settings", "/settings", `${MOD}+,`),
|
||||
nav("templates", "template", "templates.manage", "/settings/templates"),
|
||||
nav("account", "user", "settings.tab.account", "/settings/account"),
|
||||
...(isAdmin
|
||||
? [nav("providers", "brain", "settings.tab.providers", "/settings/providers"), nav("agents", "robot", "settings.tab.agents", "/settings/agents"), nav("users", "users", "settings.tab.users", "/settings/users")]
|
||||
: []),
|
||||
{ id: "theme", icon: "theme-auto", label: t("palette.toggleTheme"), run: () => cycleThemeMode() },
|
||||
];
|
||||
const sess: Item[] = sessions
|
||||
.filter((s) => !s.parent_id)
|
||||
.sort((a, b) => Number(!!a.archived) - Number(!!b.archived) || b.updated_at - a.updated_at)
|
||||
.map((s) => ({ id: s.id, icon: "file-text", label: s.title || t("session.untitled"), hint: `${basename(s.cwd)} · ${fmtRelative(s.updated_at)}`, run: () => navigate(`/s/${s.id}`) }));
|
||||
const needle = q.trim().toLowerCase();
|
||||
// Empty query: new session, the most recent sessions, then everything else.
|
||||
if (!needle) return [actions[0], ...sess.slice(0, 5), ...actions.slice(1)];
|
||||
const match = (x: Item) => `${x.label} ${x.hint ?? ""}`.toLowerCase().includes(needle);
|
||||
return [...actions.filter(match), ...sess.filter(match).slice(0, 30)];
|
||||
}, [q, sessions, isAdmin, t]);
|
||||
|
||||
if (!open) return null;
|
||||
const run = (x?: Item) => {
|
||||
if (!x) return;
|
||||
setOpen(false);
|
||||
x.run();
|
||||
};
|
||||
const close = () => setOpen(false);
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop palette-backdrop" onClick={close}>
|
||||
<div className="modal palette" role="dialog" aria-label={t("palette.title")} onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
autoFocus
|
||||
value={q}
|
||||
placeholder={t("palette.placeholder")}
|
||||
onChange={(e) => (setQ(e.target.value), setActive(0))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") close();
|
||||
else if (e.key === "ArrowDown") (e.preventDefault(), setActive((a) => Math.min(a + 1, items.length - 1)));
|
||||
else if (e.key === "ArrowUp") (e.preventDefault(), setActive((a) => Math.max(a - 1, 0)));
|
||||
else if (e.key === "Enter") (e.preventDefault(), run(items[active]));
|
||||
}}
|
||||
/>
|
||||
<div className="palette-list" role="listbox">
|
||||
{items.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("palette.nothing")}</div>}
|
||||
{items.map((x, i) => (
|
||||
<button
|
||||
key={x.id}
|
||||
role="option"
|
||||
aria-selected={i === active}
|
||||
className={i === active ? "active" : ""}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => run(x)}
|
||||
ref={i === active ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
|
||||
>
|
||||
<Icon name={x.icon} />
|
||||
<span className="palette-label">{x.label}</span>
|
||||
{x.hint && <span className="palette-hint">{x.hint}</span>}
|
||||
{x.keys && <kbd>{x.keys}</kbd>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="palette-foot">
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd> {t("palette.move")}</span>
|
||||
<span><kbd>Enter</kbd> {t("palette.open")}</span>
|
||||
<span><kbd>Alt+↑</kbd><kbd>Alt+↓</kbd> {t("palette.prevNext")}</span>
|
||||
<span><kbd>Esc</kbd> {t("palette.close")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user