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
+141
-27
@@ -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}>
|
||||
|
||||
Reference in new issue
Block a user