- Up to three chats side by side (#/s/a+b+c), "Open beside" in the sidebar menu
- File manager tabs of a chat stored per user on the server (/sessions/{id}/tabs)
- Personal modes for every user (Settings → My modes, /account/modes)
- Budget meters (total, provider, images, opencode Go windows) with push warnings
for admins at the warning share and 100 %, shown in the status overlay
- scripts/move-server.sh: move to another Docker host with bind mounts (Unraid);
scripts/smoke-test.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
6.3 KiB
TypeScript
149 lines
6.3 KiB
TypeScript
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><Icon name="arrow-up" className="tiny" /></kbd><kbd><Icon name="arrow-down" className="tiny" /></kbd> {t("palette.move")}</span>
|
|
<span><kbd>Enter</kbd> {t("palette.open")}</span>
|
|
<span><kbd>Alt+<Icon name="arrow-up" className="tiny" /></kbd><kbd>Alt+<Icon name="arrow-down" className="tiny" /></kbd> {t("palette.prevNext")}</span>
|
|
<span><kbd>Esc</kbd> {t("palette.close")}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|