- Bubbles: own messages right-aligned in their own colour, agent answers left. - Chat icons (22, validated on the server), shown in list, top bar, project page. - Colour tags: eight fixed hues (new: pink) with theme-matched lightness and the highest chroma sRGB allows; stronger background tint. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
132 lines
5.9 KiB
TypeScript
132 lines
5.9 KiB
TypeScript
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.icon && <Icon name={s.icon} className="tiny chat-icon" />}
|
|
{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>
|
|
</>
|
|
);
|
|
}
|