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

+9
View File
@@ -133,6 +133,15 @@ pub struct Session {
/// Isolated git worktree of this session: {repo, path, branch, base}. /// Isolated git worktree of this session: {repo, path, branch, base}.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree: Option<Value>, pub worktree: Option<Value>,
/// Shown at the top of the session list.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub pinned: bool,
/// Hidden from the session list (still searchable).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub archived: bool,
/// User-defined group in the session list.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder: Option<String>,
} }
impl Session { impl Session {
+31
View File
@@ -129,6 +129,8 @@ pub fn router(app: AppState) -> Router {
.route("/account/git", get(get_git_settings).put(put_git_settings)) .route("/account/git", get(get_git_settings).put(put_git_settings))
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs)) .route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory)) .route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
.route("/templates", get(crate::cli_api::get_templates))
.route("/templates/{scope}", put(crate::cli_api::put_templates))
.route("/cli/config", get(crate::cli_api::config)) .route("/cli/config", get(crate::cli_api::config))
.route("/cli/skill/{name}", get(crate::cli_api::skill)) .route("/cli/skill/{name}", get(crate::cli_api::skill))
.route("/cli/tool", post(crate::cli_api::tool)) .route("/cli/tool", post(crate::cli_api::tool))
@@ -471,6 +473,9 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
clean_room: body.clean_room, clean_room: body.clean_room,
phase: None, phase: None,
worktree: None, worktree: None,
pinned: false,
archived: false,
folder: None,
}; };
let s = app.save_session(s)?; let s = app.save_session(s)?;
Ok(Json(json!(s))) Ok(Json(json!(s)))
@@ -617,6 +622,8 @@ async fn fork_session(Extension(user): Extension<User>, State(app): State<AppSta
last_usage: None, last_usage: None,
phase: None, phase: None,
worktree: None, worktree: None,
pinned: false,
archived: false,
..src.clone() ..src.clone()
}; };
let fork = app.save_session(fork)?; let fork = app.save_session(fork)?;
@@ -673,10 +680,16 @@ struct UpdateSession {
agent: Option<String>, agent: Option<String>,
permission_mode: Option<String>, permission_mode: Option<String>,
clean_room: Option<bool>, clean_room: Option<bool>,
pinned: Option<bool>,
archived: Option<bool>,
/// "" removes the folder.
folder: Option<String>,
} }
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult { async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
let mut s = writable(&app, &user, &id)?; let mut s = writable(&app, &user, &id)?;
// Organising the list must not move the session to "today".
let organise_only = body.title.is_none() && body.model.is_none() && body.agent.is_none() && body.clean_room.is_none() && body.permission_mode.is_none();
if let Some(t) = body.title { if let Some(t) = body.title {
s.title = t; s.title = t;
} }
@@ -690,6 +703,24 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
if let Some(c) = body.clean_room { if let Some(c) = body.clean_room {
s.clean_room = Some(c); s.clean_room = Some(c);
} }
if let Some(p) = body.pinned {
s.pinned = p;
}
if let Some(a) = body.archived {
s.archived = a;
if a {
s.pinned = false;
}
}
if let Some(f) = body.folder {
let f = f.trim().chars().take(60).collect::<String>();
s.folder = (!f.is_empty()).then_some(f);
}
if organise_only {
app.store.save_session(&s)?;
app.emit(Event::SessionUpdated { session: s.clone() });
return Ok(Json(json!(s)));
}
if let Some(p) = body.permission_mode { if let Some(p) = body.permission_mode {
if !crate::permission::MODES.contains(&p.as_str()) { if !crate::permission::MODES.contains(&p.as_str()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into())); return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
+44
View File
@@ -248,3 +248,47 @@ pub async fn put_memory(Extension(user): Extension<User>, State(app): State<AppS
app.store.set_user_setting(&user.id, "memory", &b.memory)?; app.store.set_user_setting(&user.id, "memory", &b.memory)?;
Ok(Json(json!({"memory": b.memory}))) Ok(Json(json!({"memory": b.memory})))
} }
// --- prompt templates ---------------------------------------------------------
/// Owner id of the templates every user sees (edited by admins).
const GLOBAL_TEMPLATES: &str = "*";
#[derive(Debug, Clone, serde::Serialize, Deserialize, Default)]
pub struct Template {
pub id: String,
pub name: String,
pub text: String,
}
pub async fn get_templates(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let personal: Vec<Template> = app.store.user_setting(&user.id, "templates")?;
let global: Vec<Template> = app.store.user_setting(GLOBAL_TEMPLATES, "templates")?;
Ok(Json(json!({"personal": personal, "global": global, "can_edit_global": user.is_admin()})))
}
pub async fn put_templates(Extension(user): Extension<User>, State(app): State<AppState>, Path(scope): Path<String>, Json(list): Json<Vec<Template>>) -> ApiResult {
let owner = match scope.as_str() {
"personal" => user.id.clone(),
"global" => {
crate::auth::require_admin(&user)?;
GLOBAL_TEMPLATES.to_string()
}
_ => return Err(ApiError(StatusCode::NOT_FOUND, "unknown scope".into())),
};
if list.len() > 200 || list.iter().any(|t| t.name.trim().is_empty() || t.name.len() > 100 || t.text.len() > 20_000) {
return Err(ApiError(StatusCode::BAD_REQUEST, "templates need a name (max. 100 characters) and at most 20,000 characters of text".into()));
}
let list: Vec<Template> = list
.into_iter()
.map(|mut t| {
if t.id.is_empty() {
t.id = uuid::Uuid::new_v4().simple().to_string();
}
t.name = t.name.trim().to_string();
t
})
.collect();
app.store.set_user_setting(&owner, "templates", &list)?;
Ok(Json(json!(list)))
}
+3
View File
@@ -161,6 +161,9 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
clean_room: None, clean_room: None,
phase: None, phase: None,
worktree: None, worktree: None,
pinned: false,
archived: false,
folder: None,
}; };
let session = app.save_session(session)?; let session = app.save_session(session)?;
app.store.job_started(&job.id, &session.id, now)?; app.store.job_started(&job.id, &session.id, now)?;
+3
View File
@@ -700,6 +700,9 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
clean_room: parent.clean_room, clean_room: parent.clean_room,
phase: None, phase: None,
worktree: None, worktree: None,
pinned: false,
archived: false,
folder: None,
}; };
let child = app.save_session(child)?; let child = app.save_session(child)?;
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]); let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
+7 -1
View File
@@ -16,6 +16,9 @@ import { Spinner, useTitleActivity } from "./components/Activity";
import { DialogHost } from "./dialogs"; import { DialogHost } from "./dialogs";
import { startPrefsSync } from "./prefsSync"; import { startPrefsSync } from "./prefsSync";
import { WhatsNew } from "./components/WhatsNew"; 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. // 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 }))); const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
@@ -27,6 +30,7 @@ export function App() {
const [info, setInfo] = useState<Info>(); const [info, setInfo] = useState<Info>();
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [shared, setShared] = useState(pendingShare);
useTitleActivity(); useTitleActivity();
const refresh = async () => { const refresh = async () => {
@@ -71,7 +75,6 @@ export function App() {
<> <>
<Login info={info} onSuccess={refresh} /> <Login info={info} onSuccess={refresh} />
<DialogHost /> <DialogHost />
{info?.user && <WhatsNew version={info.version} />}
</> </>
); );
@@ -101,6 +104,9 @@ export function App() {
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />} {menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
<Sidebar open={menuOpen} activeId={section === "s" ? arg : undefined} section={section ?? ""} /> <Sidebar open={menuOpen} activeId={section === "s" ? arg : undefined} section={section ?? ""} />
<main className="main">{content}</main> <main className="main">{content}</main>
<WhatsNew version={info.version} />
<Palette />
{shared && <ShareTarget shared={shared} onClose={() => setShared(null)} />}
<DialogHost /> <DialogHost />
</div> </div>
); );
+2 -1
View File
@@ -1,3 +1,4 @@
import { takePrefill } from "../share";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure"; import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; 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 [sharing, setSharing] = useState(false);
const models = useModels(); const models = useModels();
const [loadError, setLoadError] = useState<string>(); const [loadError, setLoadError] = useState<string>();
const [text, setText] = useState(""); const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
const [stats, setStats] = useState<SessionStats>(); const [stats, setStats] = useState<SessionStats>();
const [showStats, setShowStats] = useState(false); const [showStats, setShowStats] = useState(false);
const [editingTitle, setEditingTitle] = useState(false); const [editingTitle, setEditingTitle] = useState(false);
+15
View File
@@ -1,3 +1,4 @@
import { TemplatePicker } from "./TemplatePicker";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { formatSize } from "./FileFigure"; import { formatSize } from "./FileFigure";
import { uploadFile } from "../api"; import { uploadFile } from "../api";
@@ -65,6 +66,7 @@ export function Composer({
// Reasoning effort for the next message only ("" = model default). // Reasoning effort for the next message only ("" = model default).
const [effort, setEffort] = useState(""); const [effort, setEffort] = useState("");
const [interim, setInterim] = useState(""); const [interim, setInterim] = useState("");
const [picker, setPicker] = useState(false);
const models = useModels(); const models = useModels();
const [prov, ...mid] = session.model.split("/"); const [prov, ...mid] = session.model.split("/");
const reasoning = !!models.find((p) => p.provider === prov)?.models.find((m) => m.id === mid.join("/"))?.reasoning; const reasoning = !!models.find((p) => p.provider === prov)?.models.find((m) => m.id === mid.join("/"))?.reasoning;
@@ -322,6 +324,19 @@ export function Composer({
</select> </select>
<ModelSelect value={session.model} onChange={(m) => onUpdate({ model: m })} title={t("composer.model")} /> <ModelSelect value={session.model} onChange={(m) => onUpdate({ model: m })} title={t("composer.model")} />
{reasoning && <EffortSelect value={effort} onChange={setEffort} compact />} {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} /> <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")}> <label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
<Icon name="clip" /> <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" />, dot: <circle cx="12" cy="12" r="4.5" className="fill" />,
plus: <path d="M12 5v14M5 12h14" />, 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: ( mic: (
<> <>
<rect x="9" y="3" width="6" height="11" rx="3" /> <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 { readImage } from "./Composer";
import { formatSize } from "./FileFigure"; import { formatSize } from "./FileFigure";
import { ModelSelect } from "./ModelSelect"; import { ModelSelect } from "./ModelSelect";
import { takePrefill } from "../share";
interface DirListing { interface DirListing {
path: string; path: string;
@@ -158,11 +159,13 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
const t = useT(); const t = useT();
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? []; const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
const config = useFetch<{ default_model?: string }>("/config").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 [target, setTarget] = useState<Target>("fresh");
const [deep, setDeep] = useState(false); const [deep, setDeep] = useState(false);
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [text, setText] = useState(""); const [text, setText] = useState(prefill ?? "");
const [images, setImages] = useState<ImageData[]>([]); const [images, setImages] = useState<ImageData[]>([]);
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [model, setModel] = useState(""); 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 { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
import { ModelSelect } from "./ModelSelect"; import { ModelSelect } from "./ModelSelect";
import { Account } from "./settings/Account"; import { Account } from "./settings/Account";
import { Templates } from "./settings/Templates";
import { AgentsEditor } from "./settings/AgentsEditor"; import { AgentsEditor } from "./settings/AgentsEditor";
import { Folders } from "./settings/Folders"; import { Folders } from "./settings/Folders";
import { Users } from "./settings/Users"; import { Users } from "./settings/Users";
@@ -923,8 +924,8 @@ function Raw({ reload }: { reload: () => void }) {
// --- page ------------------------------------------------------------------- // --- page -------------------------------------------------------------------
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "account", "raw"] as const; const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "templates", "account", "raw"] as const;
const USER_TABS = ["general", "account"] as const; const USER_TABS = ["general", "templates", "account"] as const;
const TAB_ICONS: Record<string, string> = { const TAB_ICONS: Record<string, string> = {
general: "gear", general: "gear",
providers: "brain", providers: "brain",
@@ -935,6 +936,7 @@ const TAB_ICONS: Record<string, string> = {
folders: "folders", folders: "folders",
tools: "toolbox", tools: "toolbox",
audit: "scroll", audit: "scroll",
templates: "template",
account: "user", account: "user",
raw: "file-text", 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 && !cfg && !error && <div className="empty">{t("common.loading")}</div>}
{!isAdmin && active === "general" && <InterfaceSettings />} {!isAdmin && active === "general" && <InterfaceSettings />}
{active === "account" && <Account />} {active === "account" && <Account />}
{active === "templates" && <Templates />}
{cfg && active === "users" && <Users />} {cfg && active === "users" && <Users />}
{cfg && active === "folders" && <Folders />} {cfg && active === "folders" && <Folders />}
{cfg && active === "audit" && <Audit />} {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 { Icon } from "./Icon";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme"; import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; 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 { fmtRelative, useT, type Key } from "../i18n";
import { navigate } from "../router"; import { navigate } from "../router";
import { useStore } from "../store"; import { useStore } from "../store";
@@ -20,6 +21,83 @@ function groupKey(ms: number): Key {
return "sidebar.older"; 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 }) { export function Sidebar({ open, activeId, section }: { open: boolean; activeId?: string; section: string }) {
const t = useT(); const t = useT();
const sessions = useStore((s) => s.sessions); const sessions = useStore((s) => s.sessions);
@@ -57,16 +135,50 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
return ids; return ids;
}, [permissions, questions]); }, [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 groups = useMemo(() => {
const out: { key: Key; items: Session[] }[] = []; const out: { id: string; label: string; icon?: string; items: Session[] }[] = [];
for (const s of sessions) { const add = (id: string, label: string, s: Session, icon?: string) => {
const key = groupKey(s.updated_at); const g = out.find((x) => x.id === id);
const g = out.find((x) => x.key === key);
if (g) g.items.push(s); 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; return out;
}, [sessions]); }, [sessions, groupBy, t]);
const folderNames = useMemo(() => [...new Set(sessions.map((s) => s.folder).filter(Boolean) as string[])].sort(), [sessions]);
return ( return (
<aside className={`sidebar ${open ? "open" : ""}`}> <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 && sessions.length === 0 && <div className="empty">{t("sidebar.noSessions")}</div>}
{!hits && groups.map((g) => ( {!hits && sessions.length > 0 && (
<div key={g.key}> <div className="session-view">
<div className="session-group">{t(g.key)}</div> <button className="ghost small" onClick={switchGroupBy} title={t("sidebar.groupByHint")}>
{g.items.map((s) => ( <Icon name={groupBy === "time" ? "clock" : "folders"} className="tiny" />
<button {t(groupBy === "time" ? "sidebar.byTime" : "sidebar.byProject")}
key={s.id} </button>
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>
))}
</div> </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> </nav>
{update && ( {update && (
<a className="update-chip" href={update.url} target="_blank" rel="noreferrer" title={update.notes}> <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.webhookRenew": "Neues Secret",
"jobs.webhookShown": "Secret jetzt kopieren – es wird nur einmal angezeigt:", "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).", "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.title": "Neue Session",
"new.directory": "Projektverzeichnis", "new.directory": "Projektverzeichnis",
"new.up": "Hoch", "new.up": "Hoch",
+45
View File
@@ -417,6 +417,51 @@ export const en = {
"jobs.webhookRenew": "New secret", "jobs.webhookRenew": "New secret",
"jobs.webhookShown": "Copy the secret now – it is shown only once:", "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).", "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.title": "New session",
"new.directory": "Project directory", "new.directory": "Project directory",
"new.up": "Up", "new.up": "Up",
+2
View File
@@ -5,8 +5,10 @@ import "./install";
import "./styles.css"; import "./styles.css";
import { App } from "./App"; import { App } from "./App";
import { applyTheme } from "./theme"; import { applyTheme } from "./theme";
import { captureShare } from "./share";
applyTheme(); applyTheme();
captureShare();
registerSW({ immediate: true }); registerSW({ immediate: true });
createRoot(document.getElementById("root")!).render( 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; padding: 4px 8px;
} }
.session-group { .session-group {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
border: none;
background: transparent;
text-align: left;
font-size: 11px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: var(--muted); color: var(--muted);
padding: 10px 8px 4px; 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 { .session-item {
display: block; display: block;
width: 100%; width: 100%;
@@ -3096,3 +3181,182 @@ button.danger-solid {
font-size: 13px; font-size: 13px;
color: var(--fg2); 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; worktree?: { repo: string; path: string; branch: string; base: string; sub?: string } | null;
/** Listed because it was shared with the current user. */ /** Listed because it was shared with the current user. */
shared?: boolean; shared?: boolean;
pinned?: boolean;
archived?: boolean;
folder?: string | null;
} }
export interface QuestionOption { 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", "Review loops per mode, project memory and personal memory",
"Web terminal, git worktrees, accept changes hunk by hunk, voice input", "Web terminal, git worktrees, accept changes hunk by hunk, voice input",
"15 colour themes with light/dark/auto, push notifications on your phone", "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: [ de: [
"Terminal-Client `codecraft`: lokal arbeiten wie mit Claude Code – Einstellungen → Konto → Terminal-Client", "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", "Review-Schleifen pro Modus, Projekt-Gedächtnis und persönliches Gedächtnis",
"Web-Terminal, Git-Worktrees, Änderungen stückweise übernehmen, Spracheingabe", "Web-Terminal, Git-Worktrees, Änderungen stückweise übernehmen, Spracheingabe",
"15 Farbthemes mit Hell/Dunkel/Auto, Push-Benachrichtigungen aufs Handy", "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)",
], ],
}, },
}; };
+2
View File
@@ -29,6 +29,8 @@ export default defineConfig({
scope: "/", scope: "/",
orientation: "any", orientation: "any",
categories: ["developer", "productivity"], categories: ["developer", "productivity"],
// Android "Share" sheet: text/links become a new chat or go into a recent one.
share_target: { action: "/", method: "GET", params: { title: "share_title", text: "share_text", url: "share_url" } },
icons: [ icons: [
{ src: "icon-192.png", sizes: "192x192", type: "image/png" }, { src: "icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "icon-512.png", sizes: "512x512", type: "image/png" }, { src: "icon-512.png", sizes: "512x512", type: "image/png" },