Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash, web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare), permissions with modes and rules, Anthropic + OpenAI-compatible providers, MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics, compaction, commented TOML config editable via toml_edit. React PWA: chat with live streaming, permission/question cards, modes (build/plan/craft), settings for providers/models/prices/MCP/permissions, raw config editor, statistics dashboard, i18n (en/de). Docker setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
126 lines
4.4 KiB
TypeScript
126 lines
4.4 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { post } from "../api";
|
||
import { useAgents, useFetch } from "../hooks";
|
||
import { useT } from "../i18n";
|
||
import { ModelSelect } from "./ModelSelect";
|
||
|
||
interface DirListing {
|
||
path: string;
|
||
parent: string | null;
|
||
workspace: string;
|
||
dirs: { name: string; path: string; git: boolean }[];
|
||
}
|
||
|
||
const LAST_DIR = "codecraft.lastDir";
|
||
|
||
export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreated: (id: string) => void }) {
|
||
const t = useT();
|
||
const [path, setPath] = useState<string>(localStorage.getItem(LAST_DIR) ?? "");
|
||
const { data: listing, error: dirError } = useFetch<DirListing>(`/fs/dirs${path ? `?path=${encodeURIComponent(path)}` : ""}`);
|
||
const config = useFetch<{ default_model?: string }>("/config").data;
|
||
const [model, setModel] = useState("");
|
||
const [agent, setAgent] = useState("build");
|
||
const [prompt, setPrompt] = useState("");
|
||
const [error, setError] = useState<string>();
|
||
const [creating, setCreating] = useState(false);
|
||
const agents = useAgents(listing?.path).filter((a) => a.mode === "primary");
|
||
|
||
useEffect(() => {
|
||
if (!model && config?.default_model) setModel(config.default_model);
|
||
}, [config, model]);
|
||
|
||
// A remembered directory that vanished: fall back to the workspace root.
|
||
useEffect(() => {
|
||
if (dirError && path) setPath("");
|
||
}, [dirError, path]);
|
||
|
||
const create = async () => {
|
||
if (!listing) return;
|
||
setCreating(true);
|
||
setError(undefined);
|
||
try {
|
||
localStorage.setItem(LAST_DIR, listing.path);
|
||
const s = await post<{ id: string }>("/sessions", { cwd: listing.path, model: model || undefined, agent });
|
||
if (prompt.trim()) await post(`/sessions/${s.id}/prompt`, { text: prompt.trim() });
|
||
onCreated(s.id);
|
||
} catch (e: any) {
|
||
setError(e.message);
|
||
setCreating(false);
|
||
}
|
||
};
|
||
|
||
const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : "";
|
||
|
||
return (
|
||
<>
|
||
<div className="topbar">
|
||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||
☰
|
||
</button>
|
||
<div className="grow">
|
||
<h1>{t("new.title")}</h1>
|
||
</div>
|
||
</div>
|
||
<div className="page">
|
||
<div className="page-inner" style={{ maxWidth: 760 }}>
|
||
<div className="panel">
|
||
<h3>{t("new.directory")}</h3>
|
||
<div className="row" style={{ marginBottom: 8 }}>
|
||
<code className="grow" style={{ flex: 1 }}>
|
||
{rel}
|
||
</code>
|
||
{listing?.parent && (
|
||
<button className="small" onClick={() => setPath(listing.parent!)}>
|
||
↑ {t("new.up")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="dir-list">
|
||
{listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>}
|
||
{listing?.dirs.map((d) => (
|
||
<button key={d.path} onClick={() => setPath(d.path)}>
|
||
<span>📁</span>
|
||
<span style={{ flex: 1 }}>{d.name}</span>
|
||
{d.git && <span className="chip">git</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel">
|
||
<div className="form">
|
||
<label>{t("new.model")}</label>
|
||
<ModelSelect value={model} onChange={setModel} />
|
||
<label>{t("new.mode")}</label>
|
||
<select value={agent} onChange={(e) => setAgent(e.target.value)}>
|
||
{agents.map((a) => (
|
||
<option key={a.name} value={a.name}>
|
||
{a.name} – {a.description}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel">
|
||
<h3>{t("new.firstPrompt")}</h3>
|
||
<textarea
|
||
style={{ width: "100%", minHeight: 110 }}
|
||
value={prompt}
|
||
placeholder={t("composer.placeholder")}
|
||
onChange={(e) => setPrompt(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) void create();
|
||
}}
|
||
/>
|
||
</div>
|
||
{error && <div className="notice err">{error}</div>}
|
||
<button className="primary" disabled={!listing || creating} onClick={create}>
|
||
{t("new.start", { dir: rel })}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|