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}.
|
/// 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 {
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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 { 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 />}
|
||||||
|
|||||||
@@ -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 { 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,24 +223,26 @@ 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}
|
|
||||||
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>
|
</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>
|
</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.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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
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 {
|
||||||
|
|||||||
@@ -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)",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -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" },
|
||||||
|
|||||||
Reference in new issue
Block a user