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

+7 -1
View File
@@ -16,6 +16,9 @@ import { Spinner, useTitleActivity } from "./components/Activity";
import { DialogHost } from "./dialogs";
import { startPrefsSync } from "./prefsSync";
import { WhatsNew } from "./components/WhatsNew";
import { ShareTarget } from "./components/ShareTarget";
import { Palette } from "./components/Palette";
import { pendingShare } from "./share";
// The file manager (editor, git) is loaded on demand to keep the chat small.
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
@@ -27,6 +30,7 @@ export function App() {
const [info, setInfo] = useState<Info>();
const [error, setError] = useState<string>();
const [menuOpen, setMenuOpen] = useState(false);
const [shared, setShared] = useState(pendingShare);
useTitleActivity();
const refresh = async () => {
@@ -71,7 +75,6 @@ export function App() {
<>
<Login info={info} onSuccess={refresh} />
<DialogHost />
{info?.user && <WhatsNew version={info.version} />}
</>
);
@@ -101,6 +104,9 @@ export function App() {
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
<Sidebar open={menuOpen} activeId={section === "s" ? arg : undefined} section={section ?? ""} />
<main className="main">{content}</main>
<WhatsNew version={info.version} />
<Palette />
{shared && <ShareTarget shared={shared} onClose={() => setShared(null)} />}
<DialogHost />
</div>
);
+2 -1
View File
@@ -1,3 +1,4 @@
import { takePrefill } from "../share";
import { Icon } from "./Icon";
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
@@ -149,7 +150,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
const [sharing, setSharing] = useState(false);
const models = useModels();
const [loadError, setLoadError] = useState<string>();
const [text, setText] = useState("");
const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
const [stats, setStats] = useState<SessionStats>();
const [showStats, setShowStats] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
+15
View File
@@ -1,3 +1,4 @@
import { TemplatePicker } from "./TemplatePicker";
import { Icon } from "./Icon";
import { formatSize } from "./FileFigure";
import { uploadFile } from "../api";
@@ -65,6 +66,7 @@ export function Composer({
// Reasoning effort for the next message only ("" = model default).
const [effort, setEffort] = useState("");
const [interim, setInterim] = useState("");
const [picker, setPicker] = useState(false);
const models = useModels();
const [prov, ...mid] = session.model.split("/");
const reasoning = !!models.find((p) => p.provider === prov)?.models.find((m) => m.id === mid.join("/"))?.reasoning;
@@ -322,6 +324,19 @@ export function Composer({
</select>
<ModelSelect value={session.model} onChange={(m) => onUpdate({ model: m })} title={t("composer.model")} />
{reasoning && <EffortSelect value={effort} onChange={setEffort} compact />}
<button className="ghost" style={{ padding: "3px 6px" }} onClick={() => setPicker(true)} title={t("templates.title")}>
<Icon name="template" />
</button>
{picker && (
<TemplatePicker
onClose={() => setPicker(false)}
onPick={(x) => {
setPicker(false);
setText(text.trim() ? `${text.replace(/\s+$/, "")}\n\n${x}` : x);
setTimeout(() => ref.current?.focus(), 0);
}}
/>
)}
<VoiceButton onText={(d) => setText(text ? `${text.replace(/\s+$/, "")} ${d}` : d)} onInterim={setInterim} />
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
<Icon name="clip" />
+3
View File
@@ -193,6 +193,9 @@ const P: Record<string, ReactNode> = {
),
dot: <circle cx="12" cy="12" r="4.5" className="fill" />,
plus: <path d="M12 5v14M5 12h14" />,
pin: <path d="M9 4h6l-1 5 3 3v2H7v-2l3-3-1-5ZM12 14v6" />,
archive: <path d="M4 5h16v4H4ZM5 9v10h14V9M10 13h4" />,
template: <path d="M5 4h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1ZM8 9h8M8 12.5h8M8 16h4.5" />,
mic: (
<>
<rect x="9" y="3" width="6" height="11" rx="3" />
+5 -2
View File
@@ -7,6 +7,7 @@ import type { ImageData } from "../types";
import { readImage } from "./Composer";
import { formatSize } from "./FileFigure";
import { ModelSelect } from "./ModelSelect";
import { takePrefill } from "../share";
interface DirListing {
path: string;
@@ -158,11 +159,13 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
const t = useT();
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
const config = useFetch<{ default_model?: string }>("/config").data;
const [task, setTask] = useState<Task>("craft");
// Text shared from another app (PWA share target) starts a normal task.
const [prefill] = useState(() => takePrefill("new"));
const [task, setTask] = useState<Task>(prefill ? "build" : "craft");
const [target, setTarget] = useState<Target>("fresh");
const [deep, setDeep] = useState(false);
const [url, setUrl] = useState("");
const [text, setText] = useState("");
const [text, setText] = useState(prefill ?? "");
const [images, setImages] = useState<ImageData[]>([]);
const [files, setFiles] = useState<File[]>([]);
const [model, setModel] = useState("");
+148
View File
@@ -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>
);
}
+5 -2
View File
@@ -11,6 +11,7 @@ import { applyTheme, getTheme, getThemeMode, onThemeChange, THEME_MODES, THEMES,
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
import { ModelSelect } from "./ModelSelect";
import { Account } from "./settings/Account";
import { Templates } from "./settings/Templates";
import { AgentsEditor } from "./settings/AgentsEditor";
import { Folders } from "./settings/Folders";
import { Users } from "./settings/Users";
@@ -923,8 +924,8 @@ function Raw({ reload }: { reload: () => void }) {
// --- page -------------------------------------------------------------------
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "account", "raw"] as const;
const USER_TABS = ["general", "account"] as const;
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "templates", "account", "raw"] as const;
const USER_TABS = ["general", "templates", "account"] as const;
const TAB_ICONS: Record<string, string> = {
general: "gear",
providers: "brain",
@@ -935,6 +936,7 @@ const TAB_ICONS: Record<string, string> = {
folders: "folders",
tools: "toolbox",
audit: "scroll",
templates: "template",
account: "user",
raw: "file-text",
};
@@ -970,6 +972,7 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
{isAdmin && !cfg && !error && <div className="empty">{t("common.loading")}</div>}
{!isAdmin && active === "general" && <InterfaceSettings />}
{active === "account" && <Account />}
{active === "templates" && <Templates />}
{cfg && active === "users" && <Users />}
{cfg && active === "folders" && <Folders />}
{cfg && active === "audit" && <Audit />}
+59
View File
@@ -0,0 +1,59 @@
import { useState } from "react";
import { fmtRelative, useT } from "../i18n";
import { navigate } from "../router";
import { clearShare, setPrefill } from "../share";
import { useStore } from "../store";
import { Icon } from "./Icon";
const basename = (p: string) => p.replace(/\/+$/, "").split("/").pop() || p;
/** Shown when content was shared to the app: start a new chat or append to a recent one. */
export function ShareTarget({ shared, onClose }: { shared: string; onClose: () => void }) {
const t = useT();
const [text, setText] = useState(shared);
const sessions = useStore((s) => s.sessions)
.filter((s) => !s.parent_id && !s.archived)
.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || b.updated_at - a.updated_at)
.slice(0, 6);
const done = (target: string) => {
setPrefill(target, text);
clearShare();
onClose();
navigate(target === "new" ? "/new" : `/s/${target}`);
};
const cancel = () => {
clearShare();
onClose();
};
return (
<div className="modal-backdrop" onClick={cancel}>
<div className="modal share-target" role="dialog" aria-label={t("shareIn.title")} onClick={(e) => e.stopPropagation()}>
<h3>{t("shareIn.title")}</h3>
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={4} />
<button className="primary share-new" onClick={() => done("new")} disabled={!text.trim()}>
<Icon name="plus" /> {t("shareIn.newChat")}
</button>
{sessions.length > 0 && (
<>
<div className="share-sep">{t("shareIn.appendTo")}</div>
<div className="share-list">
{sessions.map((s) => (
<button key={s.id} onClick={() => done(s.id)} disabled={!text.trim()}>
<span className="share-title">{s.title || t("session.untitled")}</span>
<span className="share-meta">
{basename(s.cwd)} · {fmtRelative(s.updated_at)}
</span>
</button>
))}
</div>
</>
)}
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
<button onClick={cancel}>{t("common.cancel")}</button>
</div>
</div>
</div>
);
}
+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}>
+123
View File
@@ -0,0 +1,123 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "../dialogs";
import { useT } from "../i18n";
import { navigate } from "../router";
import { fill, loadTemplates, placeholders, type Template, type TemplateLists } from "../templates";
import { Icon } from "./Icon";
/** Choose a prompt template, fill its placeholders, return the text. */
export function TemplatePicker({ onPick, onClose }: { onPick: (text: string) => void; onClose: () => void }) {
const t = useT();
const [lists, setLists] = useState<TemplateLists>();
const [q, setQ] = useState("");
const [active, setActive] = useState(0);
const [chosen, setChosen] = useState<Template>();
const [values, setValues] = useState<Record<string, string>>({});
const first = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
useEffect(() => {
loadTemplates()
.then(setLists)
.catch((e) => (toast(e.message), onClose()));
const h = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const items = useMemo(() => {
const all = [...(lists?.personal ?? []).map((x) => ({ ...x, global: false })), ...(lists?.global ?? []).map((x) => ({ ...x, global: true }))];
const needle = q.trim().toLowerCase();
return needle ? all.filter((x) => x.name.toLowerCase().includes(needle) || x.text.toLowerCase().includes(needle)) : all;
}, [lists, q]);
const choose = (tpl: Template) => {
const ph = placeholders(tpl.text);
if (!ph.length) return onPick(tpl.text);
setChosen(tpl);
setValues(Object.fromEntries(ph.map((p) => [p.name, ""])));
setTimeout(() => first.current?.focus(), 0);
};
if (!lists) return null;
const ph = chosen ? placeholders(chosen.text) : [];
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal tpl-picker" role="dialog" aria-label={t("templates.title")} onClick={(e) => e.stopPropagation()}>
{!chosen ? (
<>
<h3>
<Icon name="template" /> {t("templates.title")}
</h3>
<input
autoFocus
style={{ width: "100%" }}
value={q}
placeholder={t("templates.filter")}
onChange={(e) => (setQ(e.target.value), setActive(0))}
onKeyDown={(e) => {
if (e.key === "ArrowDown") (e.preventDefault(), setActive((a) => Math.min(a + 1, items.length - 1)));
if (e.key === "ArrowUp") (e.preventDefault(), setActive((a) => Math.max(a - 1, 0)));
if (e.key === "Enter" && items[active]) (e.preventDefault(), choose(items[active]));
}}
/>
<div className="tpl-list">
{items.length === 0 && (
<div className="empty" style={{ padding: 16 }}>
{t("templates.none")}
</div>
)}
{items.map((x, i) => (
<button key={(x.global ? "g" : "p") + x.id} className={i === active ? "active" : ""} onClick={() => choose(x)} onMouseEnter={() => setActive(i)}>
<span className="tpl-name">
{x.name}
{x.global && <span className="chip">{t("templates.global")}</span>}
</span>
<span className="tpl-preview">{x.text.slice(0, 140)}</span>
</button>
))}
</div>
<div className="row" style={{ marginTop: 12 }}>
<button className="ghost small" onClick={() => (onClose(), navigate("/settings/templates"))}>
<Icon name="edit" /> {t("templates.manage")}
</button>
<span className="grow" />
<button onClick={onClose}>{t("common.cancel")}</button>
</div>
</>
) : (
<form onSubmit={(e) => (e.preventDefault(), onPick(fill(chosen.text, values)))}>
<h3>{chosen.name}</h3>
{ph.map((p, i) => (
<label key={p.name} className="tpl-field">
<span>{p.name}</span>
<textarea
ref={i === 0 ? (first as React.RefObject<HTMLTextAreaElement>) : undefined}
rows={2}
value={values[p.name] ?? ""}
placeholder={p.hint}
onChange={(e) => setValues({ ...values, [p.name]: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
/>
</label>
))}
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
<button type="button" onClick={() => setChosen(undefined)}>
{t("common.back")}
</button>
<button type="submit" className="primary">
{t("templates.insert")}
</button>
</div>
</form>
)}
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { confirmDialog, toast } from "../../dialogs";
import { useT } from "../../i18n";
import { loadTemplates, placeholders, saveTemplates, type Template, type TemplateLists } from "../../templates";
import { Icon } from "../Icon";
function List({ scope, initial }: { scope: "personal" | "global"; initial: Template[] }) {
const t = useT();
const [list, setList] = useState<Template[]>(initial);
const [dirty, setDirty] = useState(false);
const set = (i: number, patch: Partial<Template>) => {
setList((l) => l.map((x, j) => (j === i ? { ...x, ...patch } : x)));
setDirty(true);
};
const save = () =>
saveTemplates(scope, list.filter((x) => x.name.trim() || x.text.trim()))
.then((l) => (setList(l), setDirty(false), toast(t("settings.saved"), "success")))
.catch((e) => toast(e.message));
const remove = async (i: number) => {
if (!(await confirmDialog({ message: t("templates.deleteConfirm", { name: list[i].name || "?" }), danger: true, confirmLabel: t("common.delete") }))) return;
setList((l) => l.filter((_, j) => j !== i));
setDirty(true);
};
return (
<div className="panel">
<h3>
<Icon name={scope === "global" ? "users" : "user"} /> {t(scope === "global" ? "templates.globalTitle" : "templates.personalTitle")}
</h3>
<div className="hint" style={{ marginBottom: 10 }}>
{t(scope === "global" ? "templates.globalHint" : "templates.personalHint")}
</div>
{list.map((x, i) => (
<div key={x.id || `new${i}`} className="tpl-edit">
<div className="row">
<input className="grow" value={x.name} placeholder={t("templates.name")} onChange={(e) => set(i, { name: e.target.value })} />
<button className="ghost small" onClick={() => remove(i)} aria-label={t("common.delete")}>
<Icon name="trash" />
</button>
</div>
<textarea rows={4} value={x.text} placeholder={t("templates.textPlaceholder")} onChange={(e) => set(i, { text: e.target.value })} />
{placeholders(x.text).length > 0 && (
<div className="tpl-ph">
{placeholders(x.text).map((p) => (
<span key={p.name} className="chip">
{p.name}
</span>
))}
</div>
)}
</div>
))}
<div className="row" style={{ marginTop: 8 }}>
<button onClick={() => (setList([...list, { id: "", name: "", text: "" }]), setDirty(true))}>
<Icon name="plus" /> {t("templates.add")}
</button>
<span className="grow" />
<button className="primary" disabled={!dirty} onClick={save}>
{t("common.save")}
</button>
</div>
</div>
);
}
/** Prompt templates: personal ones and (admins) the ones every user sees. */
export function Templates() {
const [lists, setLists] = useState<TemplateLists>();
useEffect(() => {
loadTemplates().then(setLists).catch((e) => toast(e.message));
}, []);
if (!lists) return null;
return (
<>
<List scope="personal" initial={lists.personal} />
{lists.can_edit_global && <List scope="global" initial={lists.global} />}
</>
);
}
+45
View File
@@ -418,6 +418,51 @@ export const de: Messages = {
"jobs.webhookRenew": "Neues Secret",
"jobs.webhookShown": "Secret jetzt kopieren – es wird nur einmal angezeigt:",
"jobs.webhookHelp": "Beliebiges JSON per POST an die URL schicken; es wird an den Auftrag angehängt. Anmeldung per Header X-CodeCraft-Secret (oder ?secret=…), oder das Secret in Gitea/GitHub als Webhook-Secret eintragen (Signatur X-Gitea-Signature / X-Hub-Signature-256).",
"shareIn.title": "Mit CodeCraft geteilt",
"shareIn.newChat": "Neuen Chat starten",
"shareIn.appendTo": "oder an eine letzte Session anhängen",
"common.back": "Zurück",
"settings.tab.templates": "Vorlagen",
"templates.title": "Prompt-Vorlagen",
"templates.filter": "Vorlagen durchsuchen…",
"templates.none": "Noch keine Vorlagen.",
"templates.global": "für alle",
"templates.manage": "Vorlagen verwalten",
"templates.insert": "Einfügen",
"templates.personalTitle": "Meine Vorlagen",
"templates.personalHint": "Wiederverwendbare Prompts. Platzhalter wie {{datei}} oder {{ziel: was soll sich ändern}} werden beim Einfügen abgefragt.",
"templates.globalTitle": "Vorlagen für alle",
"templates.globalHint": "Diese Vorlagen sehen alle Benutzer.",
"templates.name": "Name",
"templates.textPlaceholder": "Prüfe {{datei}} auf Fehler und schlage Korrekturen vor.",
"templates.add": "Vorlage hinzufügen",
"templates.deleteConfirm": "Vorlage „{name}“ löschen?",
"palette.title": "Befehlspalette",
"palette.placeholder": "Zu Session, Seite oder Aktion springen…",
"palette.newSession": "Neue Session",
"palette.toggleTheme": "Hell/Dunkel umschalten",
"palette.nothing": "Nichts gefunden.",
"palette.move": "bewegen",
"palette.open": "öffnen",
"palette.prevNext": "vorherige/nächste Session",
"palette.close": "schließen",
"sidebar.pinned": "Angepinnt",
"sidebar.archive": "Archiv",
"sidebar.byTime": "Nach Datum",
"sidebar.byProject": "Nach Projekt",
"sidebar.groupByHint": "Sessions nach Datum oder Projektordner gruppieren",
"sidebar.organise": "Session organisieren",
"sidebar.pin": "Oben anpinnen",
"sidebar.unpin": "Nicht mehr anpinnen",
"sidebar.moveTo": "In Ordner verschieben…",
"sidebar.removeFromFolder": "Aus Ordner entfernen",
"sidebar.archiveOne": "Archivieren",
"sidebar.unarchive": "Aus dem Archiv holen",
"sidebar.moveTitle": "In Ordner verschieben",
"sidebar.moveHint": "Ordner gruppieren Sessions in der Liste. Gib einen neuen Namen ein.",
"sidebar.moveHintExisting": "Vorhandene Ordner: {folders}. Gib einen davon oder einen neuen Namen ein.",
"sidebar.folderName": "Ordnername",
"sidebar.move": "Verschieben",
"new.title": "Neue Session",
"new.directory": "Projektverzeichnis",
"new.up": "Hoch",
+45
View File
@@ -417,6 +417,51 @@ export const en = {
"jobs.webhookRenew": "New secret",
"jobs.webhookShown": "Copy the secret now – it is shown only once:",
"jobs.webhookHelp": "POST any JSON to the URL; it is appended to the instructions. Authenticate with the header X-CodeCraft-Secret (or ?secret=…), or enter the secret as webhook secret in Gitea/GitHub (signature X-Gitea-Signature / X-Hub-Signature-256).",
"shareIn.title": "Shared with CodeCraft",
"shareIn.newChat": "Start a new chat",
"shareIn.appendTo": "or add to a recent session",
"common.back": "Back",
"settings.tab.templates": "Templates",
"templates.title": "Prompt templates",
"templates.filter": "Search templates…",
"templates.none": "No templates yet.",
"templates.global": "everyone",
"templates.manage": "Manage templates",
"templates.insert": "Insert",
"templates.personalTitle": "My templates",
"templates.personalHint": "Reusable prompts. Placeholders like {{file}} or {{goal: what should change}} are asked for when you insert the template.",
"templates.globalTitle": "Templates for everyone",
"templates.globalHint": "Every user sees these templates.",
"templates.name": "Name",
"templates.textPlaceholder": "Review {{file}} for bugs and suggest fixes.",
"templates.add": "Add template",
"templates.deleteConfirm": "Delete the template \"{name}\"?",
"palette.title": "Command palette",
"palette.placeholder": "Go to a session, page or action…",
"palette.newSession": "New session",
"palette.toggleTheme": "Switch light/dark mode",
"palette.nothing": "Nothing found.",
"palette.move": "move",
"palette.open": "open",
"palette.prevNext": "previous/next session",
"palette.close": "close",
"sidebar.pinned": "Pinned",
"sidebar.archive": "Archive",
"sidebar.byTime": "By date",
"sidebar.byProject": "By project",
"sidebar.groupByHint": "Group sessions by date or by project folder",
"sidebar.organise": "Organise session",
"sidebar.pin": "Pin to top",
"sidebar.unpin": "Unpin",
"sidebar.moveTo": "Move to folder…",
"sidebar.removeFromFolder": "Remove from folder",
"sidebar.archiveOne": "Archive",
"sidebar.unarchive": "Restore from archive",
"sidebar.moveTitle": "Move to folder",
"sidebar.moveHint": "Folders group sessions in the list. Type a new name.",
"sidebar.moveHintExisting": "Existing folders: {folders}. Type one of them or a new name.",
"sidebar.folderName": "Folder name",
"sidebar.move": "Move",
"new.title": "New session",
"new.directory": "Project directory",
"new.up": "Up",
+2
View File
@@ -5,8 +5,10 @@ import "./install";
import "./styles.css";
import { App } from "./App";
import { applyTheme } from "./theme";
import { captureShare } from "./share";
applyTheme();
captureShare();
registerSW({ immediate: true });
createRoot(document.getElementById("root")!).render(
+35
View File
@@ -0,0 +1,35 @@
/** PWA share target: Android's "Share" sheet opens /?share_title=…&share_text=…&share_url=… */
const SHARED = "codecraft.shared";
const PREFILL = "codecraft.prefill.";
/** Move shared content from the URL into sessionStorage (survives the login screen). */
export function captureShare() {
const q = new URLSearchParams(location.search);
if (!q.has("share_title") && !q.has("share_text") && !q.has("share_url")) return;
const parts = [q.get("share_title"), q.get("share_text"), q.get("share_url")].map((s) => (s ?? "").trim()).filter(Boolean);
// Many apps put the URL into the text as well.
const unique = parts.filter((p, i) => !parts.some((o, j) => j !== i && o.includes(p) && (o.length > p.length || j < i)));
if (unique.length) sessionStorage.setItem(SHARED, unique.join("\n\n"));
history.replaceState(null, "", "/" + location.hash);
}
export function pendingShare(): string | null {
return sessionStorage.getItem(SHARED);
}
export function clearShare() {
sessionStorage.removeItem(SHARED);
}
/** Text to put into a composer once ("new" = new-session wizard, else a session id). */
export function setPrefill(target: string, text: string) {
sessionStorage.setItem(PREFILL + target, text);
}
export function takePrefill(target: string): string | null {
const v = sessionStorage.getItem(PREFILL + target);
// Removed a moment later: React StrictMode runs state initialisers twice.
if (v !== null) setTimeout(() => sessionStorage.removeItem(PREFILL + target), 500);
return v;
}
+264
View File
@@ -148,12 +148,97 @@ textarea:focus {
padding: 4px 8px;
}
.session-group {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
border: none;
background: transparent;
text-align: left;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
padding: 10px 8px 4px;
}
button.session-group:hover {
color: var(--fg2);
}
.session-group .grow {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-group .count {
text-transform: none;
letter-spacing: 0;
}
.session-view {
display: flex;
justify-content: flex-end;
padding: 0 0 2px;
}
.session-view button {
display: inline-flex;
align-items: center;
font-size: 11.5px;
color: var(--muted);
gap: 5px;
}
.session-row {
position: relative;
}
.session-row .session-item {
padding-right: 30px;
}
.session-more {
position: absolute;
top: 6px;
right: 4px;
padding: 2px 4px;
opacity: 0;
color: var(--muted);
}
.session-row:hover .session-more,
.session-row.menu-open .session-more,
.session-more:focus-visible {
opacity: 1;
}
@media (pointer: coarse) {
.session-more {
opacity: 1;
}
}
.session-menu {
position: absolute;
right: 4px;
top: 30px;
z-index: 20;
min-width: 200px;
background: var(--bg1);
border: 1px solid var(--bg3);
border-radius: 8px;
box-shadow: var(--shadow);
padding: 4px;
display: flex;
flex-direction: column;
}
.session-menu button {
display: flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
text-align: left;
padding: 7px 8px;
border-radius: 5px;
font-size: 13px;
}
.session-menu button:hover {
background: var(--bg2);
}
.session-item {
display: block;
width: 100%;
@@ -3096,3 +3181,182 @@ button.danger-solid {
font-size: 13px;
color: var(--fg2);
}
/* --- share target ------------------------------------------------------------- */
.share-target textarea {
width: 100%;
resize: vertical;
margin-bottom: 10px;
}
.share-new {
width: 100%;
justify-content: center;
}
.share-sep {
margin: 14px 0 6px;
font-size: 12px;
color: var(--muted);
}
.share-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.share-list button {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
text-align: left;
background: var(--bg2);
}
.share-list button:hover:not(:disabled) {
background: var(--bg3);
}
.share-title {
font-weight: 600;
color: var(--fg1);
}
.share-meta {
font-size: 12px;
color: var(--muted);
}
/* --- prompt templates ---------------------------------------------------------- */
.tpl-picker {
width: min(560px, 100%);
}
.tpl-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 10px;
max-height: 50vh;
overflow-y: auto;
}
.tpl-list button {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
text-align: left;
background: var(--bg2);
}
.tpl-list button.active {
background: var(--bg3);
}
.tpl-name {
display: flex;
gap: 6px;
align-items: center;
font-weight: 600;
color: var(--fg1);
}
.tpl-preview {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.tpl-field {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.tpl-field span {
font-size: 13px;
font-weight: 600;
}
.tpl-field textarea,
.tpl-edit textarea {
width: 100%;
resize: vertical;
}
.tpl-edit {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 0;
border-bottom: 1px solid var(--bg2);
}
.tpl-ph {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
/* --- command palette ----------------------------------------------------------- */
.palette-backdrop {
align-items: flex-start;
padding-top: 12vh;
}
.palette {
width: min(600px, 100%);
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 70vh;
}
.palette > input {
width: 100%;
font-size: 15px;
padding: 10px 12px;
}
.palette-list {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
min-height: 0;
}
.palette-list button {
display: flex;
align-items: center;
gap: 10px;
text-align: left;
background: transparent;
border-color: transparent;
}
.palette-list button.active {
background: var(--bg2);
}
.palette-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg1);
}
.palette-hint {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
}
.palette kbd {
font: 11px var(--mono, monospace);
padding: 1px 5px;
border-radius: 4px;
border: 1px solid var(--bg3);
background: var(--bg0);
color: var(--fg2);
margin-right: 2px;
}
.palette-foot {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 11px;
color: var(--muted);
padding: 4px 4px 0;
border-top: 1px solid var(--bg2);
}
@media (pointer: coarse) {
.palette-foot {
display: none;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { get, put } from "./api";
export interface Template {
id: string;
name: string;
text: string;
}
export interface TemplateLists {
personal: Template[];
global: Template[];
can_edit_global: boolean;
}
export const loadTemplates = () => get<TemplateLists>("/templates");
export const saveTemplates = (scope: "personal" | "global", list: Template[]) => put<Template[]>(`/templates/${scope}`, list);
/** Placeholders like {{file}} or {{goal: what should change}} (name, optional hint). */
export function placeholders(text: string): { name: string; hint: string }[] {
const seen = new Map<string, string>();
for (const m of text.matchAll(/\{\{\s*([^}:]+?)\s*(?::\s*([^}]*?))?\s*\}\}/g)) {
if (!seen.has(m[1])) seen.set(m[1], m[2] ?? "");
}
return [...seen].map(([name, hint]) => ({ name, hint }));
}
export function fill(text: string, values: Record<string, string>): string {
return text.replace(/\{\{\s*([^}:]+?)\s*(?::\s*([^}]*?))?\s*\}\}/g, (_, name: string) => values[name] ?? "");
}
+3
View File
@@ -64,6 +64,9 @@ export interface Session {
worktree?: { repo: string; path: string; branch: string; base: string; sub?: string } | null;
/** Listed because it was shared with the current user. */
shared?: boolean;
pinned?: boolean;
archived?: boolean;
folder?: string | null;
}
export interface QuestionOption {
+4
View File
@@ -7,6 +7,8 @@ export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"Review loops per mode, project memory and personal memory",
"Web terminal, git worktrees, accept changes hunk by hunk, voice input",
"15 colour themes with light/dark/auto, push notifications on your phone",
"Ctrl+K command palette, prompt templates with placeholders, pin/archive sessions and sort them into folders",
"Share text and links from other apps to CodeCraft (installed app on Android)",
],
de: [
"Terminal-Client `codecraft`: lokal arbeiten wie mit Claude Code – Einstellungen → Konto → Terminal-Client",
@@ -14,6 +16,8 @@ export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"Review-Schleifen pro Modus, Projekt-Gedächtnis und persönliches Gedächtnis",
"Web-Terminal, Git-Worktrees, Änderungen stückweise übernehmen, Spracheingabe",
"15 Farbthemes mit Hell/Dunkel/Auto, Push-Benachrichtigungen aufs Handy",
"Ctrl+K-Befehlspalette, Prompt-Vorlagen mit Platzhaltern, Sessions anpinnen/archivieren und in Ordner sortieren",
"Texte und Links aus anderen Apps an CodeCraft teilen (installierte App unter Android)",
],
},
};