Add share target, prompt templates, command palette and session organisation
- PWA share target: shared text/links start a new chat or go into a
recent session (chooser dialog)
- Prompt templates (personal + global for admins) with {{placeholders}}
that are asked for on insert; managed under Settings → Templates
- Ctrl/Cmd+K command palette; Alt+N new session, Alt+Up/Down switch
sessions, Ctrl+, settings
- Sessions can be pinned, archived and filed into folders; the list can
be grouped by date or by project, groups are collapsible
- Fix: the "What's new" dialog was never shown to signed-in users
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b5de531d2e
commit
428e2df40a
25 files changed
+1103
-30
No files matched your search
@@ -133,6 +133,15 @@ pub struct Session {
|
||||
/// Isolated git worktree of this session: {repo, path, branch, base}.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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 {
|
||||
|
||||
@@ -129,6 +129,8 @@ pub fn router(app: AppState) -> Router {
|
||||
.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/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/skill/{name}", get(crate::cli_api::skill))
|
||||
.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,
|
||||
phase: None,
|
||||
worktree: None,
|
||||
pinned: false,
|
||||
archived: false,
|
||||
folder: None,
|
||||
};
|
||||
let s = app.save_session(s)?;
|
||||
Ok(Json(json!(s)))
|
||||
@@ -617,6 +622,8 @@ async fn fork_session(Extension(user): Extension<User>, State(app): State<AppSta
|
||||
last_usage: None,
|
||||
phase: None,
|
||||
worktree: None,
|
||||
pinned: false,
|
||||
archived: false,
|
||||
..src.clone()
|
||||
};
|
||||
let fork = app.save_session(fork)?;
|
||||
@@ -673,10 +680,16 @@ struct UpdateSession {
|
||||
agent: Option<String>,
|
||||
permission_mode: Option<String>,
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 !crate::permission::MODES.contains(&p.as_str()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||
|
||||
@@ -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)?;
|
||||
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)))
|
||||
}
|
||||
@@ -161,6 +161,9 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
|
||||
clean_room: None,
|
||||
phase: None,
|
||||
worktree: None,
|
||||
pinned: false,
|
||||
archived: false,
|
||||
folder: None,
|
||||
};
|
||||
let session = app.save_session(session)?;
|
||||
app.store.job_started(&job.id, &session.id, now)?;
|
||||
|
||||
@@ -700,6 +700,9 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
|
||||
clean_room: parent.clean_room,
|
||||
phase: None,
|
||||
worktree: None,
|
||||
pinned: false,
|
||||
archived: false,
|
||||
folder: None,
|
||||
};
|
||||
let child = app.save_session(child)?;
|
||||
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
||||
|
||||
+7
-1
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+138
-24
@@ -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,24 +223,26 @@ 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>
|
||||
{!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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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] ?? "");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)",
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -29,6 +29,8 @@ export default defineConfig({
|
||||
scope: "/",
|
||||
orientation: "any",
|
||||
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: [
|
||||
{ src: "icon-192.png", sizes: "192x192", type: "image/png" },
|
||||
{ src: "icon-512.png", sizes: "512x512", type: "image/png" },
|
||||
|
||||
Reference in new issue
Block a user