Files
codecraft/web/src/components/Palette.tsx
T
Richard ZinkandClaude Opus 5.5 d152ad836d Split view, personal modes, budget warnings, server move script
- 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>
2026-11-09 12:06:20 +01:00

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>
);
}