Claude Code engine, image viewer, colour tags, checks per turn and more
- Claude Code as an engine: chats on a "claude-code/<alias>" model run through the claude CLI (stream-json); tool approvals go through our permission system. Sign in from the web UI (URL + pasted code), CLI version and update from Settings; the Docker image contains the CLI. - Small models (8k context) for side tasks: input is cut to the model's context window; one-line chat status, "What does this do?" on approvals, "Plan as automation" (job draft from a chat). - Mobile: top bar no longer widens the page, image viewer (zoom, swipe, mark up, use in message), jump to previous own message, message menu, time under every message. - Approvals: push actions (allow/reject), "always allow in this project" (never for dangerous commands) with revoke. - Per turn: changed files with diffs and rollback; project check command with a green/red badge and "let the agent fix it". - Colour tags for chats in the colours of the active theme. - Project defaults for new chats, project page (timeline), offline queue. Tested in a throwaway container with a mock model (8k small model): 26 API checks passed (permissions, diffs, checks, defaults, job draft, side-task budget, Claude Code status/login URL, unsigned-in chat). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
084452abf6
commit
62fe5bf467
42 files changed
+3734
-55
No files matched your search
@@ -0,0 +1,128 @@
|
||||
import { useMemo } from "react";
|
||||
import { useFetch } from "../hooks";
|
||||
import { fmtCost, fmtDate, fmtRelative, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import type { Session } from "../types";
|
||||
import { Icon } from "./Icon";
|
||||
import { basename } from "./FileFigure";
|
||||
|
||||
interface Overview {
|
||||
sessions: Record<string, { files: number; check: string | null; checks_failed: number; cost: number }>;
|
||||
check: { command: string; enabled: boolean } | null;
|
||||
defaults: { model?: string; agent?: string; permission_mode?: string; color?: string; instructions?: string; folder?: string } | null;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
const day = (ms: number) => new Date(ms).toDateString();
|
||||
|
||||
/** One project folder: its chats as a timeline (newest first) with what each one did. */
|
||||
export function ProjectPage({ cwd, onMenu }: { cwd: string; onMenu: () => void }) {
|
||||
const t = useT();
|
||||
const all = useStore((s) => s.sessions);
|
||||
const { data } = useFetch<Overview>(`/projects/overview?cwd=${encodeURIComponent(cwd)}`);
|
||||
const sessions = useMemo(() => all.filter((s) => s.cwd === cwd && !s.archived).sort((a, b) => b.updated_at - a.updated_at), [all, cwd]);
|
||||
const archived = all.filter((s) => s.cwd === cwd && s.archived).length;
|
||||
|
||||
// Day groups: "Today", "Yesterday", date.
|
||||
const groups = useMemo(() => {
|
||||
const out: { label: string; items: Session[] }[] = [];
|
||||
const today = day(Date.now());
|
||||
const yesterday = day(Date.now() - 86_400_000);
|
||||
for (const s of sessions) {
|
||||
const d = day(s.updated_at);
|
||||
const label = d === today ? t("project.today") : d === yesterday ? t("project.yesterday") : fmtDate(s.updated_at, false);
|
||||
const last = out[out.length - 1];
|
||||
if (last?.label === label) last.items.push(s);
|
||||
else out.push({ label, items: [s] });
|
||||
}
|
||||
return out;
|
||||
}, [sessions, t]);
|
||||
|
||||
const totalCost = sessions.reduce((n, s) => n + (data?.sessions[s.id]?.cost ?? 0), 0);
|
||||
const totalFiles = sessions.reduce((n, s) => n + (data?.sessions[s.id]?.files ?? 0), 0);
|
||||
|
||||
const newChat = () => {
|
||||
try {
|
||||
localStorage.setItem("codecraft.lastDir", cwd);
|
||||
} catch {
|
||||
// Without storage the wizard starts at its usual folder.
|
||||
}
|
||||
navigate("/new");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<div className="grow">
|
||||
<h1>
|
||||
<Icon name="folder" /> {basename(cwd) || "/"}
|
||||
</h1>
|
||||
<div className="path mono">{cwd}</div>
|
||||
</div>
|
||||
<button className="small primary" onClick={newChat}>
|
||||
<Icon name="plus" /> {t("project.newChat")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="chat-scroll">
|
||||
<div className="chat project-page">
|
||||
<div className="project-summary">
|
||||
<span className="chip">{t("project.chats", { n: sessions.length })}</span>
|
||||
{totalFiles > 0 && <span className="chip">{t("project.files", { n: totalFiles })}</span>}
|
||||
{data && totalCost > 0 && <span className="chip">{fmtCost(totalCost, data.currency)}</span>}
|
||||
{data?.check?.enabled && (
|
||||
<span className="chip mono" title={t("check.title")}>
|
||||
<Icon name="check" className="tiny" /> {data.check.command}
|
||||
</span>
|
||||
)}
|
||||
{data?.defaults?.model && (
|
||||
<span className="chip" title={t("projectDefaults.hint")}>
|
||||
<Icon name="template" className="tiny" /> {data.defaults.model.split("/").pop()}
|
||||
</span>
|
||||
)}
|
||||
{archived > 0 && <span className="hint">{t("project.archived", { n: archived })}</span>}
|
||||
</div>
|
||||
{sessions.length === 0 && <div className="empty">{t("project.empty")}</div>}
|
||||
{groups.map((g) => (
|
||||
<section key={g.label} className="timeline-day">
|
||||
<h3>{g.label}</h3>
|
||||
<ol className="timeline">
|
||||
{g.items.map((s) => {
|
||||
const info = data?.sessions[s.id];
|
||||
return (
|
||||
<li key={s.id} data-color={s.color} className={s.color ? "tagged" : ""}>
|
||||
<a href={`#/s/${s.id}`} onClick={(e) => (e.preventDefault(), navigate(`/s/${s.id}`))}>
|
||||
<div className="tl-title">
|
||||
<span className={s.color ? "color-badge" : undefined}>{s.title || t("session.untitled")}</span>
|
||||
<span className="tl-when" title={fmtDate(s.updated_at)}>
|
||||
{fmtRelative(s.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
{s.summary && <div className="tl-summary">{s.summary}</div>}
|
||||
<div className="tl-meta">
|
||||
<span>{s.agent}</span>
|
||||
<span>{s.model.split("/").pop()}</span>
|
||||
{!!info?.files && <span>{t("project.files", { n: info.files })}</span>}
|
||||
{info?.check && (
|
||||
<span className={`check-${info.check}`}>
|
||||
<Icon name={info.check === "passed" ? "check" : info.check === "running" ? "clock" : "x-mark"} className="tiny" /> {t(`check.${info.check}` as Key)}
|
||||
</span>
|
||||
)}
|
||||
{!!info?.checks_failed && <span className="check-failed">{t("project.checksFailed", { n: info.checks_failed })}</span>}
|
||||
{!!info?.cost && data && <span>{fmtCost(info.cost, data.currency)}</span>}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user