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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 05:27:45 +01:00
1 parent b5de531d2e
commit 428e2df40a
25 files changed
+1106 -33

No files matched your search

+141 -27
View File
@@ -1,7 +1,8 @@
import { Icon } from "./Icon";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { get, post } from "../api";
import { get, patch, post } from "../api";
import { promptDialog, toast } from "../dialogs";
import { fmtRelative, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
@@ -20,6 +21,83 @@ function groupKey(ms: number): Key {
return "sidebar.older";
}
const GROUP_BY = "codecraft.sidebarGroupBy";
const COLLAPSED = "codecraft.sidebarCollapsed";
const organise = (id: string, body: Partial<Pick<Session, "pinned" | "archived">> & { folder?: string }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message));
/** One session in the list, with a menu to pin, file or archive it. */
function SessionRow({ s, active, attention, busy, folders }: { s: Session; active: boolean; attention: boolean; busy: boolean; folders: string[] }) {
const t = useT();
const [menu, setMenu] = useState(false);
useEffect(() => {
if (!menu) return;
const close = () => setMenu(false);
window.addEventListener("click", close);
window.addEventListener("keydown", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("keydown", close);
};
}, [menu]);
const moveTo = async () => {
const name = await promptDialog({
title: t("sidebar.moveTitle"),
message: folders.length ? t("sidebar.moveHintExisting", { folders: folders.join(", ") }) : t("sidebar.moveHint"),
defaultValue: s.folder ?? "",
placeholder: t("sidebar.folderName"),
confirmLabel: t("sidebar.move"),
});
if (name !== null) void organise(s.id, { folder: name });
};
return (
<div className={`session-row ${menu ? "menu-open" : ""}`}>
<button className={`session-item ${active ? "active" : ""}`} onClick={() => navigate(`/s/${s.id}`)}>
<div className="title">
{attention ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy && <i className="busy-ring" title={t("activity.working")} />}
<span>{s.title || t("session.untitled")}</span>
</div>
<div className="sub">
{s.shared && <span title={t("sidebar.shared")}><Icon name="share" className="tiny" /> </span>}
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
</div>
</button>
<button
className="session-more ghost"
aria-label={t("sidebar.organise")}
aria-haspopup="menu"
aria-expanded={menu}
onClick={(e) => {
e.stopPropagation();
setMenu(!menu);
}}
>
<Icon name="more" />
</button>
{menu && (
<div className="session-menu" role="menu" onClick={(e) => e.stopPropagation()}>
{!s.archived && (
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { pinned: !s.pinned }))}>
<Icon name="pin" /> {t(s.pinned ? "sidebar.unpin" : "sidebar.pin")}
</button>
)}
<button role="menuitem" onClick={() => (setMenu(false), void moveTo())}>
<Icon name="folder" /> {t("sidebar.moveTo")}
</button>
{s.folder && (
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { folder: "" }))}>
<Icon name="folder-up" /> {t("sidebar.removeFromFolder")}
</button>
)}
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
</button>
</div>
)}
</div>
);
}
export function Sidebar({ open, activeId, section }: { open: boolean; activeId?: string; section: string }) {
const t = useT();
const sessions = useStore((s) => s.sessions);
@@ -57,16 +135,50 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
return ids;
}, [permissions, questions]);
const [groupBy, setGroupBy] = useState<"time" | "project">(() => (localStorage.getItem(GROUP_BY) === "project" ? "project" : "time"));
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(JSON.parse(localStorage.getItem(COLLAPSED) ?? '["archive"]')));
const toggle = (id: string) =>
setCollapsed((c) => {
const n = new Set(c);
if (n.has(id)) n.delete(id);
else n.add(id);
localStorage.setItem(COLLAPSED, JSON.stringify([...n]));
return n;
});
const switchGroupBy = () => {
const next = groupBy === "time" ? "project" : "time";
localStorage.setItem(GROUP_BY, next);
setGroupBy(next);
};
const groups = useMemo(() => {
const out: { key: Key; items: Session[] }[] = [];
for (const s of sessions) {
const key = groupKey(s.updated_at);
const g = out.find((x) => x.key === key);
const out: { id: string; label: string; icon?: string; items: Session[] }[] = [];
const add = (id: string, label: string, s: Session, icon?: string) => {
const g = out.find((x) => x.id === id);
if (g) g.items.push(s);
else out.push({ key, items: [s] });
else out.push({ id, label, icon, items: [s] });
};
const live = sessions.filter((s) => !s.archived);
for (const s of live.filter((s) => s.pinned)) add("pinned", t("sidebar.pinned"), s, "pin");
const rest = live.filter((s) => !s.pinned);
const folders = [...new Set(rest.map((s) => s.folder).filter(Boolean) as string[])].sort((a, b) => a.localeCompare(b));
for (const f of folders) for (const s of rest.filter((s) => s.folder === f)) add(`folder:${f}`, f, s, "folder");
const loose = rest.filter((s) => !s.folder);
if (groupBy === "project") {
const byProject = new Map<string, Session[]>();
for (const s of loose) byProject.set(s.cwd, [...(byProject.get(s.cwd) ?? []), s]);
// Projects with the most recent activity first.
for (const [cwd, items] of [...byProject].sort((a, b) => b[1][0].updated_at - a[1][0].updated_at)) for (const s of items) add(`project:${cwd}`, basename(cwd) || "/", s, "folders");
} else {
for (const s of loose) {
const key = groupKey(s.updated_at);
add(key, t(key), s);
}
}
for (const s of sessions.filter((s) => s.archived)) add("archive", t("sidebar.archive"), s, "archive");
return out;
}, [sessions]);
}, [sessions, groupBy, t]);
const folderNames = useMemo(() => [...new Set(sessions.map((s) => s.folder).filter(Boolean) as string[])].sort(), [sessions]);
return (
<aside className={`sidebar ${open ? "open" : ""}`}>
@@ -111,27 +223,29 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
</>
)}
{!hits && sessions.length === 0 && <div className="empty">{t("sidebar.noSessions")}</div>}
{!hits && groups.map((g) => (
<div key={g.key}>
<div className="session-group">{t(g.key)}</div>
{g.items.map((s) => (
<button
key={s.id}
className={`session-item ${s.id === activeId ? "active" : ""}`}
onClick={() => navigate(`/s/${s.id}`)}
>
<div className="title">
{attention.has(s.id) ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy[s.id] && <i className="busy-ring" title={t("activity.working")} />}
<span>{s.title || t("session.untitled")}</span>
</div>
<div className="sub">
{s.shared && <span title={t("sidebar.shared")}><Icon name="share" className="tiny" /> </span>}
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
</div>
</button>
))}
{!hits && sessions.length > 0 && (
<div className="session-view">
<button className="ghost small" onClick={switchGroupBy} title={t("sidebar.groupByHint")}>
<Icon name={groupBy === "time" ? "clock" : "folders"} className="tiny" />
{t(groupBy === "time" ? "sidebar.byTime" : "sidebar.byProject")}
</button>
</div>
))}
)}
{!hits &&
groups.map((g) => (
<div key={g.id}>
<button className="session-group" onClick={() => toggle(g.id)} aria-expanded={!collapsed.has(g.id)}>
<Icon name={collapsed.has(g.id) ? "chevron-right" : "chevron-down"} className="tiny" />
{g.icon && <Icon name={g.icon} className="tiny" />}
<span className="grow">{g.label}</span>
{collapsed.has(g.id) && <span className="count">{g.items.length}</span>}
</button>
{!collapsed.has(g.id) &&
g.items.map((s) => (
<SessionRow key={s.id} s={s} active={s.id === activeId} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
))}
</div>
))}
</nav>
{update && (
<a className="update-chip" href={update.url} target="_blank" rel="noreferrer" title={update.notes}>