Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the packages workspace (cli, core, server, web, docs, landing, skills), build scripts, tooling config, and CI workflows. Includes the data-layout revision made on this branch: the local data root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the installer keeps its binaries in ~/.penguin), and every Agent lives under <project>/agents/<agent>/ — path helpers, the three agent-enumeration scans, the system prompt, built-in Skills, tests and docs all follow the new layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* User management page (admin only): user list + create / reset password / delete.
|
||||
* Registration is closed: new users are created here, with the initial password set by the admin and
|
||||
* communicated offline; deleting a user also deletes all their Projects (including data directories),
|
||||
* with a confirmation dialog.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Navigate } from "react-router";
|
||||
import type { UserInfo } from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { USERNAME_PATTERN } from "../../lib/semantic-id";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { useDocumentTitle } from "../../lib/use-document-title";
|
||||
import { useAuth } from "../../state/auth";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { PasswordInput } from "../../components/ui/password-input";
|
||||
import { Modal } from "../../components/ui/modal";
|
||||
|
||||
export function AdminUsersPage() {
|
||||
useDocumentTitle(S.admin.users);
|
||||
const { user } = useAuth();
|
||||
const [users, setUsers] = useState<UserInfo[] | null>(null);
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [resetting, setResetting] = useState<UserInfo | null>(null);
|
||||
const [deleting, setDeleting] = useState<UserInfo | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
setUsers((await api.adminListUsers()).users);
|
||||
setListError(null);
|
||||
} catch (e) {
|
||||
setListError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.isAdmin) void reload();
|
||||
}, [user?.isAdmin, reload]);
|
||||
|
||||
// Route guard fallback: non-admins are redirected back to the chat page (the sidebar has no entry point anyway).
|
||||
if (user && !user.isAdmin) return <Navigate to="/chat" replace />;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 md:p-6">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">{S.admin.users}</h1>
|
||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>
|
||||
{S.admin.createUser}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{users === null ? (
|
||||
<p className="text-sm text-gray-400">{S.common.loading}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900/60 dark:text-gray-400">
|
||||
<th className="whitespace-nowrap px-3 py-2 font-medium">{S.admin.userId}</th>
|
||||
<th className="whitespace-nowrap px-3 py-2 font-medium">{S.admin.role}</th>
|
||||
<th className="whitespace-nowrap px-3 py-2 font-medium">{S.admin.createdAt}</th>
|
||||
<th className="whitespace-nowrap px-3 py-2 text-right font-medium">
|
||||
{S.admin.actions}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800/60">
|
||||
{users.map((u) => (
|
||||
<tr key={u.userId}>
|
||||
<td className="whitespace-nowrap px-3 py-2 font-medium">
|
||||
{u.userId}
|
||||
{u.passwordIsInitial && (
|
||||
<span className="ml-2 align-middle">
|
||||
<Badge tone="gray">{S.admin.initialPasswordFlag}</Badge>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2">
|
||||
<Badge tone="gray">{u.isAdmin ? S.admin.roleAdmin : S.admin.roleUser}</Badge>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-gray-500 dark:text-gray-400">
|
||||
{formatDateTime(u.createdAt)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-1.5 text-right">
|
||||
<Button size="sm" variant="ghost" onClick={() => setResetting(u)}>
|
||||
{S.admin.resetPassword}
|
||||
</Button>
|
||||
{!u.isAdmin && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setDeleting(u)}>
|
||||
{S.admin.deleteUser}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{listError && <p className="mt-2 text-xs text-red-600 dark:text-red-400">{listError}</p>}
|
||||
</div>
|
||||
|
||||
<CreateUserDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onDone={() => {
|
||||
setCreateOpen(false);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
<ResetPasswordDialog user={resetting} onClose={() => setResetting(null)} />
|
||||
<DeleteUserDialog
|
||||
user={deleting}
|
||||
onClose={() => setDeleting(null)}
|
||||
onDone={() => {
|
||||
setDeleting(null);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateUserDialog({
|
||||
open,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [userId, setUserId] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setUserId("");
|
||||
setPassword("");
|
||||
setError(null);
|
||||
}, [open]);
|
||||
|
||||
const submit = async () => {
|
||||
const id = userId.trim();
|
||||
if (!id || !password) {
|
||||
setError(S.common.requiredField);
|
||||
return;
|
||||
}
|
||||
if (!USERNAME_PATTERN.test(id)) {
|
||||
setError(S.auth.usernameHint);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.adminCreateUser({ userId: id, password });
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={S.admin.createUser}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} disabled={busy}>
|
||||
{S.common.cancel}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={busy} onClick={() => void submit()}>
|
||||
{S.common.create}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
label={S.admin.userId}
|
||||
size="sm"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
hint={S.auth.usernameHint}
|
||||
autoFocus
|
||||
/>
|
||||
<PasswordInput
|
||||
label={S.admin.initialPassword}
|
||||
size="sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
hint={S.auth.passwordHint}
|
||||
/>
|
||||
{USERNAME_PATTERN.test(userId.trim()) && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{S.admin.defaultProjectNote(`${userId.trim()}-default_project`)}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetPasswordDialog({ user, onClose }: { user: UserInfo | null; onClose: () => void }) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setPassword("");
|
||||
setError(null);
|
||||
}, [user]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!user) return;
|
||||
if (!password) {
|
||||
setError(S.common.requiredField);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.adminResetPassword(user.userId, { password });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={user !== null}
|
||||
title={user ? S.admin.resetPasswordTitle(user.userId) : ""}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} disabled={busy}>
|
||||
{S.common.cancel}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={busy} onClick={() => void submit()}>
|
||||
{S.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<PasswordInput
|
||||
label={S.admin.initialPassword}
|
||||
size="sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
hint={S.auth.passwordHint}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">{S.admin.resetPasswordNote}</p>
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteUserDialog({
|
||||
user,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
user: UserInfo | null;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setConfirmed(false);
|
||||
setError(null);
|
||||
}, [user]);
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!user) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.adminDeleteUser(user.userId);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={user !== null}
|
||||
title={user ? S.admin.deleteUserTitle(user.userId) : ""}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} disabled={busy}>
|
||||
{S.common.cancel}
|
||||
</Button>
|
||||
{confirmed ? (
|
||||
<Button variant="danger" disabled={busy} onClick={() => void doDelete()}>
|
||||
{S.common.confirm}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="danger" onClick={() => setConfirmed(true)}>
|
||||
{S.common.delete}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{user ? S.admin.deleteUserConfirm(user.userId) : ""}
|
||||
</p>
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Session activity sparkline (Agents list card, GitHub-repo-Pulse-graph style): a
|
||||
* polyline + faint fill of daily active Session counts over the last N days,
|
||||
* normalized to the max value within the window — shows only relative ups and downs,
|
||||
* no scale/ticks. All-zero / empty data renders a flat baseline at the bottom. Pure
|
||||
* SVG, no dependencies; color follows the light/dark theme.
|
||||
*/
|
||||
|
||||
const W = 100;
|
||||
const H = 30;
|
||||
const PAD = 2;
|
||||
|
||||
export function ActivitySparkline({
|
||||
data,
|
||||
label,
|
||||
className = "",
|
||||
}: {
|
||||
data: number[];
|
||||
label: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const max = Math.max(1, ...data);
|
||||
const step = data.length > 1 ? (W - 2 * PAD) / (data.length - 1) : 0;
|
||||
const pts = data.map((v, i) => [PAD + i * step, H - PAD - (v / max) * (H - 2 * PAD)] as const);
|
||||
const baseline = `${PAD},${H - PAD} ${W - PAD},${H - PAD}`;
|
||||
const line = pts.length > 1 ? pts.map(([x, y]) => `${x},${y}`).join(" ") : baseline;
|
||||
const area = pts.length > 1 ? `${line} ${W - PAD},${H - PAD} ${PAD},${H - PAD}` : null;
|
||||
return (
|
||||
<svg
|
||||
width={W}
|
||||
height={H}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className={`text-emerald-600 dark:text-emerald-500 ${className}`}
|
||||
>
|
||||
<title>{label}</title>
|
||||
{area !== null && <polygon points={area} fill="currentColor" fillOpacity="0.12" />}
|
||||
<polyline
|
||||
points={line}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
/**
|
||||
* Agent settings page: six tabs —
|
||||
* Overview (name/description/State path/active count/State version + snapshot
|
||||
* export-import), Prompt (AGENTS.md and system_prompt editors + placeholder
|
||||
* reference), Runtime (max_turns, model.*, compaction.*), Tools (editable built-in
|
||||
* tools table, MCP Server read-only JSON), Vault (vault-tab.tsx), Schedule
|
||||
* (schedules-tab.tsx).
|
||||
* Save = PUT config (sends only the changed keys; YAML comments are preserved
|
||||
* server-side).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ChangeEvent } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import type {
|
||||
AgentConfigResponse,
|
||||
AgentConfigUpdateRequest,
|
||||
AgentCompactionConfigDto,
|
||||
AgentModelConfigDto,
|
||||
} from "@prismshadow/penguin-server/api";
|
||||
import type { ToolDefinitionConfig, ToolPermission } from "@prismshadow/penguin-core/interfaces";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { useDocumentTitle } from "../../lib/use-document-title";
|
||||
import { useProject } from "../../state/project";
|
||||
import { Tabs } from "../../components/ui/tabs";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input, Textarea } from "../../components/ui/input";
|
||||
import { OptionMenu, type OptionMenuChoice } from "../../components/ui/option-menu";
|
||||
import { Modal } from "../../components/ui/modal";
|
||||
import { Skeleton } from "../../components/ui/skeleton";
|
||||
import { VaultTab } from "./vault-tab";
|
||||
import { SchedulesTab } from "./schedules-tab";
|
||||
|
||||
type TabKey = "overview" | "prompt" | "runtime" | "tools" | "vault" | "schedules";
|
||||
|
||||
/** "" represents not overridden (falls back to the current config), corresponding to the defaultValue placeholder row. */
|
||||
function withDefaultOption(
|
||||
entries: ReadonlyArray<readonly [string, string]>,
|
||||
): ReadonlyArray<OptionMenuChoice<string>> {
|
||||
return entries.map(([value, description]) => ({
|
||||
value,
|
||||
triggerLabel: value || S.agent.defaultValue,
|
||||
label: value || S.agent.defaultValue,
|
||||
description,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Numeric input's string state → number (empty/invalid = undefined, meaning no change). */
|
||||
function parseNum(s: string): number | undefined {
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function numToStr(n: number | undefined): string {
|
||||
return n === undefined ? "" : String(n);
|
||||
}
|
||||
|
||||
export function AgentSettingsPage() {
|
||||
// Read inside the component: after a language switch remount, this picks up the current dictionary.
|
||||
const TABS = [
|
||||
{ key: "overview", label: S.agent.tabOverview },
|
||||
{ key: "prompt", label: S.agent.tabPrompt },
|
||||
{ key: "runtime", label: S.agent.tabRuntime },
|
||||
{ key: "tools", label: S.agent.tabTools },
|
||||
{ key: "vault", label: S.agent.tabVault },
|
||||
{ key: "schedules", label: S.agent.tabSchedules },
|
||||
] as const;
|
||||
const navigate = useNavigate();
|
||||
const params = useParams<{ agentId: string }>();
|
||||
const agentId = params.agentId ?? "";
|
||||
useDocumentTitle(S.agent.settings);
|
||||
const { currentProject, reloadAgents } = useProject();
|
||||
const projectId = currentProject?.projectId ?? null;
|
||||
|
||||
const [data, setData] = useState<AgentConfigResponse | null>(null);
|
||||
const [tab, setTab] = useState<TabKey>("overview");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!projectId || !agentId) return;
|
||||
setData(null);
|
||||
setError(null);
|
||||
api
|
||||
.getAgentConfig(projectId, agentId)
|
||||
.then(setData)
|
||||
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError));
|
||||
}, [projectId, agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
/** Snapshot import succeeded: show the new version and reload the whole config (import overwrites the entire Agent State, so every tab's data needs a refresh). */
|
||||
const onImported = useCallback(
|
||||
(version: number) => {
|
||||
setNotice(S.agent.importDone(version));
|
||||
load();
|
||||
void reloadAgents();
|
||||
},
|
||||
[load, reloadAgents],
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
async (update: AgentConfigUpdateRequest) => {
|
||||
if (!projectId || !agentId) return;
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const res = await api.putAgentConfig(projectId, agentId, update);
|
||||
setData(res);
|
||||
setNotice(S.common.saved);
|
||||
// Name/description changes affect the breadcrumb and list display.
|
||||
if (update.config?.name !== undefined || update.config?.description !== undefined) {
|
||||
void reloadAgents();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
}
|
||||
},
|
||||
[projectId, agentId, reloadAgents],
|
||||
);
|
||||
|
||||
if (!projectId) return null;
|
||||
if (error && !data) {
|
||||
return <p className="p-6 text-sm text-red-600 dark:text-red-400">{error}</p>;
|
||||
}
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="space-y-3 p-6">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="no-scrollbar h-full overflow-y-auto p-4 md:p-6">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate("/agents")}
|
||||
className="-ml-2 mb-3 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
<path d="M9 12h12" />
|
||||
</svg>
|
||||
{S.agent.backToList}
|
||||
</Button>
|
||||
<h1 className="mb-1 text-xl font-semibold">{data.config.name ?? agentId}</h1>
|
||||
<p className="mb-4 font-mono text-xs text-gray-400">{agentId}</p>
|
||||
<Tabs items={TABS} active={tab} onChange={setTab} />
|
||||
<div className="py-4">
|
||||
{tab === "overview" && (
|
||||
<OverviewTab data={data} agentId={agentId} onSave={save} onImported={onImported} />
|
||||
)}
|
||||
{tab === "prompt" && <PromptTab data={data} onSave={save} />}
|
||||
{tab === "runtime" && <RuntimeTab data={data} onSave={save} />}
|
||||
{tab === "tools" && <ToolsTab data={data} onSave={save} />}
|
||||
{tab === "vault" && <VaultTab agentId={agentId} />}
|
||||
{tab === "schedules" && <SchedulesTab agentId={agentId} />}
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
{notice && <p className="text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SaveFn = (update: AgentConfigUpdateRequest) => Promise<void>;
|
||||
|
||||
/** <a download>/<label> version of the button look (matches Button secondary sm; the Button component only renders <button>). */
|
||||
const TRANSFER_BUTTON_CLASS =
|
||||
"inline-flex cursor-pointer items-center justify-center gap-1 rounded-md border border-gray-300 " +
|
||||
"bg-white px-2.5 py-1 text-xs font-medium text-gray-800 transition-colors duration-150 " +
|
||||
"hover:bg-gray-50 focus-within:ring-2 focus-within:ring-gray-400/30 " +
|
||||
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800";
|
||||
|
||||
function OverviewTab({
|
||||
data,
|
||||
agentId,
|
||||
onSave,
|
||||
onImported,
|
||||
}: {
|
||||
data: AgentConfigResponse;
|
||||
agentId: string;
|
||||
onSave: SaveFn;
|
||||
onImported: (version: number) => void;
|
||||
}) {
|
||||
const { currentProject } = useProject();
|
||||
const projectId = currentProject?.projectId ?? null;
|
||||
const isOwner = currentProject?.role === "owner";
|
||||
const [name, setName] = useState(data.config.name ?? "");
|
||||
const [description, setDescription] = useState(data.config.description ?? "");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
// base64 of the snapshot package pending confirmation for a version conflict (409 version_conflict); non-null shows the confirm modal.
|
||||
const [conflict, setConflict] = useState<string | null>(null);
|
||||
|
||||
const submit = () => {
|
||||
const config: NonNullable<AgentConfigUpdateRequest["config"]> = {};
|
||||
if (name.trim() !== (data.config.name ?? "")) config.name = name.trim();
|
||||
if (description.trim() !== (data.config.description ?? "")) {
|
||||
config.description = description.trim();
|
||||
}
|
||||
if (Object.keys(config).length === 0) return;
|
||||
void onSave({ config });
|
||||
};
|
||||
|
||||
const runImport = async (dataBase64: string, confirm: boolean) => {
|
||||
if (!projectId) return;
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
try {
|
||||
const res = await api.importAgent(projectId, agentId, {
|
||||
dataBase64,
|
||||
...(confirm ? { confirm: true } : {}),
|
||||
});
|
||||
setConflict(null);
|
||||
onImported(res.version);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409 && e.code === "version_conflict") {
|
||||
setConflict(dataBase64); // resend with confirm: true after confirming
|
||||
} else {
|
||||
setConflict(null);
|
||||
setImportError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPickFile = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setImportError(null);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const url = reader.result as string;
|
||||
void runImport(url.slice(url.indexOf(",") + 1), false); // strip the data:...;base64, prefix
|
||||
};
|
||||
reader.onerror = () => setImportError(S.common.unknownError);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.agent.name}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label={S.agent.description}
|
||||
size="sm"
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.stateDir}</p>
|
||||
<p className="break-all font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
{data.stateDir}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.activeSessions}</p>
|
||||
<p className="text-sm">{data.activeSessionCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.stateVersion}</p>
|
||||
<p className="font-mono text-sm">v{data.config.version}</p>
|
||||
</div>
|
||||
|
||||
{/* Snapshot export / import: export is available to any member; import overwrites the entire Agent State, visible only to owners. */}
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.transferTitle}</p>
|
||||
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400">{S.agent.transferDesc}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{projectId && (
|
||||
<a
|
||||
href={api.agentExportUrl(projectId, agentId)}
|
||||
download
|
||||
className={TRANSFER_BUTTON_CLASS}
|
||||
>
|
||||
{S.agent.exportSnapshot}
|
||||
</a>
|
||||
)}
|
||||
{isOwner && (
|
||||
<label
|
||||
className={`${TRANSFER_BUTTON_CLASS} ${importing ? "pointer-events-none opacity-60" : ""}`}
|
||||
>
|
||||
{/* sr-only rather than hidden: keeps it keyboard-Tab-focusable (same as the workspace-browser upload). */}
|
||||
<input
|
||||
type="file"
|
||||
accept=".tar.gz,.tgz"
|
||||
className="sr-only"
|
||||
disabled={importing}
|
||||
onChange={onPickFile}
|
||||
/>
|
||||
{importing ? S.agent.importing : S.agent.importSnapshot}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{importError && (
|
||||
<p className="mt-1.5 text-xs text-red-600 dark:text-red-400">{importError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="primary" onClick={submit}>
|
||||
{S.common.save}
|
||||
</Button>
|
||||
|
||||
{/* Version conflict confirmation: resend the same package with confirm: true after confirming. */}
|
||||
<Modal
|
||||
open={conflict !== null}
|
||||
title={S.agent.importConflictTitle}
|
||||
onClose={() => setConflict(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setConflict(null)}>{S.common.cancel}</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={importing}
|
||||
onClick={() => {
|
||||
if (conflict !== null) void runImport(conflict, true);
|
||||
}}
|
||||
>
|
||||
{S.common.confirm}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{S.agent.importConflictBody}</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn }) {
|
||||
const [agentsMd, setAgentsMd] = useState(data.agentsMd);
|
||||
const [systemPrompt, setSystemPrompt] = useState(data.config.systemPrompt);
|
||||
const promptRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const submit = () => {
|
||||
const update: AgentConfigUpdateRequest = {};
|
||||
if (agentsMd !== data.agentsMd) update.agentsMd = agentsMd;
|
||||
if (systemPrompt !== data.config.systemPrompt) update.config = { systemPrompt };
|
||||
if (update.agentsMd === undefined && update.config === undefined) return;
|
||||
void onSave(update);
|
||||
};
|
||||
|
||||
/**
|
||||
* Quickly insert a placeholder at the system_prompt cursor position (appends to the
|
||||
* end when unfocused). Prefers execCommand insertText — it writes to the browser's
|
||||
* undo stack (undoable with Ctrl/⌘+Z) and fires an input event, which the
|
||||
* controlled onChange syncs into state; falls back to directly mutating state when
|
||||
* unsupported (no undo).
|
||||
*/
|
||||
const insertPlaceholder = (ph: string) => {
|
||||
const el = promptRef.current;
|
||||
if (el) {
|
||||
el.focus();
|
||||
// execCommand is deprecated but still the only available way to preserve the textarea's native undo stack.
|
||||
const inserted = document.execCommand?.("insertText", false, ph);
|
||||
if (inserted) return; // onChange will update state from e.target.value
|
||||
}
|
||||
const start = el ? el.selectionStart : systemPrompt.length;
|
||||
const end = el ? el.selectionEnd : systemPrompt.length;
|
||||
setSystemPrompt(systemPrompt.slice(0, start) + ph + systemPrompt.slice(end));
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const caret = start + ph.length;
|
||||
el.setSelectionRange(caret, caret);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Textarea
|
||||
label={S.agent.agentsMd}
|
||||
mono
|
||||
size="sm"
|
||||
rows={14}
|
||||
value={agentsMd}
|
||||
onChange={(e) => setAgentsMd(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
ref={promptRef}
|
||||
label={S.agent.systemPrompt}
|
||||
mono
|
||||
size="sm"
|
||||
rows={12}
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
/>
|
||||
<div className="rounded-md border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-900">
|
||||
<p className="mb-2 text-xs font-semibold text-gray-500">{S.agent.placeholdersTitle}</p>
|
||||
<ul className="space-y-1">
|
||||
{S.agent.placeholders.map(([ph, desc]) => (
|
||||
<li key={ph} className="flex items-center gap-3 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertPlaceholder(ph)}
|
||||
title={S.agent.insertPlaceholder}
|
||||
className="shrink-0 rounded border border-gray-200 bg-white px-1.5 py-0.5 font-mono font-semibold text-gray-800 transition-colors duration-150 hover:border-gray-400 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:border-gray-500 dark:hover:bg-gray-700"
|
||||
>
|
||||
{ph}
|
||||
</button>
|
||||
<span className="text-gray-500 dark:text-gray-400">{desc}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Button size="sm" variant="primary" onClick={submit}>
|
||||
{S.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn }) {
|
||||
const cfg = data.config;
|
||||
const [maxTurns, setMaxTurns] = useState(numToStr(cfg.maxTurns));
|
||||
const [maxTokens, setMaxTokens] = useState(numToStr(cfg.model?.maxTokens));
|
||||
const [thinkingLevel, setThinkingLevel] = useState(cfg.model?.thinkingLevel ?? "");
|
||||
const [timeoutMs, setTimeoutMs] = useState(numToStr(cfg.model?.timeoutMs));
|
||||
const [maxContextLength, setMaxContextLength] = useState(
|
||||
numToStr(cfg.compaction?.maxContextLength),
|
||||
);
|
||||
const [maxSessionTurns, setMaxSessionTurns] = useState(numToStr(cfg.compaction?.maxSessionTurns));
|
||||
const [mode, setMode] = useState(cfg.compaction?.mode ?? "");
|
||||
const [prompt, setPrompt] = useState(cfg.compaction?.prompt ?? "");
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
const submit = () => {
|
||||
setLocalError(null);
|
||||
const config: NonNullable<AgentConfigUpdateRequest["config"]> = {};
|
||||
|
||||
const mt = parseNum(maxTurns);
|
||||
if (mt !== undefined && mt !== cfg.maxTurns) {
|
||||
if (mt <= 0 && mt !== -1) {
|
||||
setLocalError(S.agent.maxTurnsInvalid);
|
||||
return;
|
||||
}
|
||||
config.maxTurns = mt;
|
||||
}
|
||||
|
||||
const model: AgentModelConfigDto = {};
|
||||
const tok = parseNum(maxTokens);
|
||||
if (tok !== undefined && tok !== cfg.model?.maxTokens) model.maxTokens = tok;
|
||||
if (thinkingLevel && thinkingLevel !== (cfg.model?.thinkingLevel ?? "")) {
|
||||
model.thinkingLevel = thinkingLevel as AgentModelConfigDto["thinkingLevel"];
|
||||
}
|
||||
const tmo = parseNum(timeoutMs);
|
||||
if (tmo !== undefined && tmo !== cfg.model?.timeoutMs) {
|
||||
if (tmo <= 0 && tmo !== -1) {
|
||||
setLocalError(S.agent.timeoutInvalid);
|
||||
return;
|
||||
}
|
||||
model.timeoutMs = tmo;
|
||||
}
|
||||
if (Object.keys(model).length > 0) config.model = model;
|
||||
|
||||
const compaction: AgentCompactionConfigDto = {};
|
||||
const mcl = parseNum(maxContextLength);
|
||||
if (mcl !== undefined && mcl !== cfg.compaction?.maxContextLength) {
|
||||
compaction.maxContextLength = mcl;
|
||||
}
|
||||
const mst = parseNum(maxSessionTurns);
|
||||
if (mst !== undefined && mst !== cfg.compaction?.maxSessionTurns) {
|
||||
compaction.maxSessionTurns = mst;
|
||||
}
|
||||
if (mode && mode !== (cfg.compaction?.mode ?? "")) {
|
||||
compaction.mode = mode as AgentCompactionConfigDto["mode"];
|
||||
}
|
||||
if (prompt !== (cfg.compaction?.prompt ?? "")) compaction.prompt = prompt;
|
||||
if (Object.keys(compaction).length > 0) config.compaction = compaction;
|
||||
|
||||
if (Object.keys(config).length === 0) return;
|
||||
void onSave({ config });
|
||||
};
|
||||
|
||||
// S is reassigned on language switch (live binding), so read it during render rather than hoisting to a module-level constant.
|
||||
const thinkingLevelOptions = withDefaultOption(S.agent.thinkingLevelOptions);
|
||||
const compactionModeOptions = withDefaultOption(S.agent.compactionModeOptions);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="p-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
label={S.agent.maxTurns}
|
||||
size="sm"
|
||||
value={maxTurns}
|
||||
onChange={(e) => setMaxTurns(e.target.value)}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
/>
|
||||
<Input
|
||||
label={S.agent.maxTokens}
|
||||
size="sm"
|
||||
value={maxTokens}
|
||||
onChange={(e) => setMaxTokens(e.target.value)}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
/>
|
||||
<OptionMenu
|
||||
label={S.agent.thinkingLevel}
|
||||
fullWidth
|
||||
size="sm"
|
||||
value={thinkingLevel}
|
||||
onChange={setThinkingLevel}
|
||||
options={thinkingLevelOptions}
|
||||
/>
|
||||
<Input
|
||||
label={S.agent.timeoutMs}
|
||||
hint={S.agent.timeoutMsHint}
|
||||
size="sm"
|
||||
value={timeoutMs}
|
||||
onChange={(e) => setTimeoutMs(e.target.value)}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-b border-gray-200 bg-gray-50/80 px-3 py-2 text-xs font-semibold text-gray-500 dark:border-gray-800 dark:bg-gray-900">
|
||||
{S.agent.compaction}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<Input
|
||||
label={S.agent.maxContextLength}
|
||||
hint={S.agent.maxContextLengthHint}
|
||||
size="sm"
|
||||
value={maxContextLength}
|
||||
onChange={(e) => setMaxContextLength(e.target.value)}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
/>
|
||||
<Input
|
||||
label={S.agent.maxSessionTurns}
|
||||
hint={S.agent.maxSessionTurnsHint}
|
||||
size="sm"
|
||||
value={maxSessionTurns}
|
||||
onChange={(e) => setMaxSessionTurns(e.target.value)}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
/>
|
||||
<OptionMenu
|
||||
label={S.agent.compactionMode}
|
||||
fullWidth
|
||||
size="sm"
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={compactionModeOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-b border-gray-200 bg-gray-50/80 px-3 py-2 text-xs font-semibold text-gray-500 dark:border-gray-800 dark:bg-gray-900">
|
||||
{S.agent.compactionPrompt}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<Textarea
|
||||
aria-label={S.agent.compactionPrompt}
|
||||
mono
|
||||
size="sm"
|
||||
rows={4}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localError && <p className="text-xs text-red-600 dark:text-red-400">{localError}</p>}
|
||||
<Button size="sm" variant="primary" onClick={submit}>
|
||||
{S.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Local edit state for a tool row: numeric columns use string state (can be cleared then re-entered; empty string = clear the override and revert to default). */
|
||||
interface ToolRowState {
|
||||
base: ToolDefinitionConfig;
|
||||
timeoutMs: string;
|
||||
maxOutputLength: string;
|
||||
}
|
||||
|
||||
function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn }) {
|
||||
// S is reassigned on language switch (live binding), so read it during render rather than hoisting to a module-level constant.
|
||||
const permissionOptions: ReadonlyArray<OptionMenuChoice<ToolPermission>> = [
|
||||
{
|
||||
value: "r",
|
||||
triggerLabel: "r",
|
||||
label: S.agent.permissionReadLabel,
|
||||
description: S.agent.permissionReadDescription,
|
||||
},
|
||||
{
|
||||
value: "rw",
|
||||
triggerLabel: "rw",
|
||||
label: S.agent.permissionReadWriteLabel,
|
||||
description: S.agent.permissionReadWriteDescription,
|
||||
},
|
||||
];
|
||||
const [rows, setRows] = useState<ToolRowState[]>(() =>
|
||||
data.config.toolsBuiltin.map((t) => ({
|
||||
base: { ...t },
|
||||
timeoutMs: numToStr(t.timeoutMs),
|
||||
maxOutputLength: numToStr(t.maxOutputLength),
|
||||
})),
|
||||
);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
const update = (index: number, patch: Partial<ToolRowState>) => {
|
||||
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
setLocalError(null);
|
||||
// toolsBuiltin is submitted as a full table: an empty string omits that key (revert to default); non-empty values are validated per the server's rules.
|
||||
const tools: ToolDefinitionConfig[] = [];
|
||||
for (const row of rows) {
|
||||
const tool: ToolDefinitionConfig = { ...row.base };
|
||||
delete tool.timeoutMs;
|
||||
delete tool.maxOutputLength;
|
||||
const timeout = row.timeoutMs.trim();
|
||||
if (timeout) {
|
||||
const n = Number(timeout);
|
||||
if (!Number.isInteger(n) || (n <= 0 && n !== -1)) {
|
||||
setLocalError(S.agent.toolFieldInvalid(row.base.name, "timeoutMs"));
|
||||
return;
|
||||
}
|
||||
tool.timeoutMs = n;
|
||||
}
|
||||
const maxOutput = row.maxOutputLength.trim();
|
||||
if (maxOutput) {
|
||||
const n = Number(maxOutput);
|
||||
if (!Number.isInteger(n) || (n <= 0 && n !== -1)) {
|
||||
setLocalError(S.agent.toolFieldInvalid(row.base.name, "maxOutputLength"));
|
||||
return;
|
||||
}
|
||||
tool.maxOutputLength = n;
|
||||
}
|
||||
tools.push(tool);
|
||||
}
|
||||
void onSave({ config: { toolsBuiltin: tools } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto overflow-y-clip rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<table className="w-full min-w-[520px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50/80 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900">
|
||||
<th className="px-3 py-2">{S.agent.toolName}</th>
|
||||
<th className="px-3 py-2">{S.agent.toolPermission}</th>
|
||||
<th className="px-3 py-2">{S.agent.toolTimeout}</th>
|
||||
<th className="px-3 py-2">{S.agent.toolMaxOutput}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={row.base.name} className="border-b border-gray-100 dark:border-gray-800/60">
|
||||
<td className="px-3 py-2 font-mono text-xs">{row.base.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<OptionMenu
|
||||
mono
|
||||
size="sm"
|
||||
aria-label={S.agent.toolPermission}
|
||||
placeholder={S.agent.defaultValue}
|
||||
options={permissionOptions}
|
||||
value={row.base.permission}
|
||||
onChange={(v) => update(i, { base: { ...row.base, permission: v } })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
size="sm"
|
||||
value={row.timeoutMs}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
onChange={(e) => update(i, { timeoutMs: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
size="sm"
|
||||
value={row.maxOutputLength}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
onChange={(e) => update(i, { maxOutputLength: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{localError && <p className="text-xs text-red-600 dark:text-red-400">{localError}</p>}
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.mcpServers}</p>
|
||||
<pre className="max-h-64 overflow-auto rounded-md border border-gray-200 bg-gray-50 p-3 text-xs dark:border-gray-800 dark:bg-gray-900">
|
||||
{JSON.stringify(data.config.mcpServers, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="primary" onClick={submit}>
|
||||
{S.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Agents list page: entry point for creating,
|
||||
* deleting, and editing Agents. Laid out as GitHub-repo-list-style single-column compact rows:
|
||||
* one horizontal band of "info | 30-day activity sparkline | button group" per row.
|
||||
* Info column has three lines: title line (small avatar + bold name + agentId); single-line
|
||||
* truncated description; and a stats line — icon + number only (Session count / tool count) plus
|
||||
* relative time (today/yesterday/n days ago), with meaning folded into the hover title.
|
||||
* Buttons sit to the right of the sparkline: "New Chat" (draft state, same as sidebar group
|
||||
* header) and "Settings" (goes to settings page) show text labels; "Usage" / "Traces" (deep link
|
||||
* via ?agentId= to the usage center / trace observability; traces use an eye line icon =
|
||||
* observability) and "Delete" (with confirmation; built-in Agents show a non-interactive light
|
||||
* gray placeholder with an undeletable tooltip) are square icon buttons (tooltip shows the full
|
||||
* name); "Create Agent" only fills in name + description.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { SEMANTIC_ID_PATTERN } from "../../lib/semantic-id";
|
||||
import { formatDateTime, formatRelativeDays } from "../../lib/format";
|
||||
import { useDocumentTitle } from "../../lib/use-document-title";
|
||||
import { useLocale } from "../../state/locale";
|
||||
import { agentDisplayName, useProject } from "../../state/project";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input, Textarea } from "../../components/ui/input";
|
||||
import { Modal } from "../../components/ui/modal";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Skeleton, SkeletonCard } from "../../components/ui/skeleton";
|
||||
import { EmptyState } from "../../components/ui/empty-state";
|
||||
import { AgentAvatar } from "../../components/ui/agent-avatar";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import { STAT_ICONS } from "../../lib/stat-icons";
|
||||
import { DRAFT_SESSION_ID } from "../chat/chat-page";
|
||||
import { ActivitySparkline } from "./activity-sparkline";
|
||||
|
||||
/** Built-in Agent shipped with every Project (default_agent only; the server also rejects deletion, so no delete entry point is shown here). */
|
||||
const BUILTIN_AGENT_IDS = new Set(["default_agent"]);
|
||||
|
||||
/** Card button icons (24x24 line path, rendered via GlyphIcon). */
|
||||
const CARD_ICONS = {
|
||||
/** New chat (plus sign) */
|
||||
newChat: "M12 5v14M5 12h14",
|
||||
/** Settings (gear, same as sidebar user menu) */
|
||||
settings:
|
||||
"M10.3 4.3a2 2 0 0 1 3.4 0l.5.8a2 2 0 0 0 1.8 1l1-.1a2 2 0 0 1 1.7 3l-.5.8a2 2 0 0 0 0 2l.5.8a2 2 0 0 1-1.7 3l-1-.1a2 2 0 0 0-1.8 1l-.5.8a2 2 0 0 1-3.4 0l-.5-.8a2 2 0 0 0-1.8-1l-1 .1a2 2 0 0 1-1.7-3l.5-.8a2 2 0 0 0 0-2l-.5-.8a2 2 0 0 1 1.7-3l1 .1a2 2 0 0 0 1.8-1zM12 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4z",
|
||||
/** Delete (trash can) */
|
||||
trash:
|
||||
"M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m3 0l-1 13a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 7m4 4v6m4-6v6",
|
||||
/** Total session count (chat bubble) */
|
||||
sessions: "M8 10h8M8 14h5M21 12a9 9 0 1 1-4-7.5",
|
||||
/** Vault key count (key: bow + teeth) */
|
||||
vaultKeys: "M15.5 7.5l3 3L22 7l-3-3M21 2l-9.6 9.6M13 15.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z",
|
||||
/** Schedule count (alarm clock: dial + hands + twin bells, distinct from the plain clock face used for "last modified") */
|
||||
schedules: "M12 21a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm0-10v3l2 1.5M5 3L2.5 5.5M19 3l2.5 2.5",
|
||||
/** Usage (bar chart, same as sidebar "Usage Center") */
|
||||
usage: "M4 20V10m6 10V4m6 16v-7m4 7H2",
|
||||
/** Traces (eye line icon: observability; follows text color, no fill) */
|
||||
traces: "M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z",
|
||||
} as const;
|
||||
|
||||
export function AgentsPage() {
|
||||
const navigate = useNavigate();
|
||||
useDocumentTitle(S.nav.agents);
|
||||
const { locale } = useLocale();
|
||||
const { currentProject, agents, agentsLoading, reloadAgents, setCurrentAgentId } = useProject();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [agentId, setAgentId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
/** Open the create dialog: don't keep the previous draft, always start from an empty form. */
|
||||
const openCreate = () => {
|
||||
setAgentId("");
|
||||
setName("");
|
||||
setDescription("");
|
||||
setError(null);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
/** Agent pending delete confirmation (null = none). */
|
||||
const [deleting, setDeleting] = useState<{ agentId: string; name: string } | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const projectId = currentProject?.projectId;
|
||||
|
||||
const create = async () => {
|
||||
if (!projectId) return;
|
||||
const id = agentId.trim();
|
||||
if (!id) {
|
||||
setError(S.common.requiredField);
|
||||
return;
|
||||
}
|
||||
if (!SEMANTIC_ID_PATTERN.test(id)) {
|
||||
setError(S.agent.idHint);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Name defaults to the id (leave blank to let the server fill it in from the id).
|
||||
const body: { agentId: string; name?: string; description?: string } = { agentId: id };
|
||||
if (name.trim()) body.name = name.trim();
|
||||
if (description.trim()) body.description = description.trim();
|
||||
const res = await api.createAgent(projectId, body);
|
||||
setCreateOpen(false);
|
||||
await reloadAgents();
|
||||
setCurrentAgentId(res.agent.agentId);
|
||||
navigate(`/agents/${res.agent.agentId}`);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* "New Chat": enters draft state (same as sidebar group header) — the Session is only
|
||||
* actually created when the first message is sent. agentId travels via route state: when the
|
||||
* draft view restores from cache it prefers the cached agentId, but the route state explicitly
|
||||
* overrides it, ensuring that clicking "New Chat" on a given card always lands on that Agent
|
||||
* rather than the previous one from the cache.
|
||||
*/
|
||||
const newChat = (agentId: string) => {
|
||||
setCurrentAgentId(agentId);
|
||||
navigate(`/chat/${DRAFT_SESSION_ID}`, { state: { agentId } });
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!projectId || !deleting) return;
|
||||
setBusy(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await api.deleteAgent(projectId, deleting.agentId);
|
||||
setDeleting(null);
|
||||
await reloadAgents();
|
||||
} catch (e) {
|
||||
setDeleteError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 md:p-6">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-semibold">{S.agent.listTitle}</h1>
|
||||
<Button variant="primary" onClick={openCreate}>
|
||||
{S.agent.create}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{agentsLoading ? (
|
||||
/* Same single-column row styling as the real list (space-y-3 + px-5 py-4), with a
|
||||
three-line info column plus sparkline/button-group placeholders, so no layout shift
|
||||
occurs once the skeleton disappears */
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<SkeletonCard
|
||||
key={i}
|
||||
className="flex flex-wrap items-center gap-x-6 gap-y-2 px-5 py-4"
|
||||
>
|
||||
<div className="min-w-[14rem] flex-1">
|
||||
<Skeleton className="h-[18px] w-40" />
|
||||
<Skeleton className="mt-1.5 h-4 w-2/3" />
|
||||
<Skeleton className="mt-1.5 h-4 w-48" />
|
||||
</div>
|
||||
<Skeleton className="hidden h-9 w-40 md:block" />
|
||||
<Skeleton className="h-8 w-52" />
|
||||
</SkeletonCard>
|
||||
))}
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<EmptyState title={S.common.none} />
|
||||
) : (
|
||||
/* GitHub-repo-list-style single column: separate cards with row spacing; each row is
|
||||
one horizontal band of "info | sparkline | button group", with the info column
|
||||
compressed to two lines of text (name line + combined description/stats line) to
|
||||
minimize row height */
|
||||
<div className="space-y-3">
|
||||
{agents.map((a) => {
|
||||
const builtin = BUILTIN_AGENT_IDS.has(a.agentId);
|
||||
return (
|
||||
<div
|
||||
key={a.agentId}
|
||||
className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-md border border-gray-200 bg-white px-5 py-4 dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
{/* Info column: once it can't fit within 14rem, everything after it
|
||||
(sparkline/buttons) wraps as a whole. The avatar counts as the first line
|
||||
(same line as the name); description/stats share the same left edge as the
|
||||
avatar (the column's left edge) */}
|
||||
<div className="min-w-[14rem] flex-1">
|
||||
{/* Title line: small avatar + name + agentId + active badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentAvatar id={a.agentId} size={18} className="shrink-0 rounded" />
|
||||
{/* min-w-0: flex children don't shrink below their content by default; needed here to truncate overly long names */}
|
||||
<span className="min-w-0 truncate text-base font-bold">
|
||||
{agentDisplayName(a)}
|
||||
</span>
|
||||
<span className="hidden shrink-0 font-mono text-xs text-gray-400 md:inline dark:text-gray-500">
|
||||
{a.agentId}
|
||||
</span>
|
||||
<Badge tone="gray">v{a.version}</Badge>
|
||||
{a.activeSessionCount > 0 && (
|
||||
<Badge tone="brand">
|
||||
{S.agent.activeSessions} {a.activeSessionCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Description truncated to one line (an empty description still takes up a line, keeping card heights equal) */}
|
||||
<p className="mt-1.5 min-h-4 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{a.description ?? ""}
|
||||
</p>
|
||||
{/* Stats on their own line: same color/font size as the description; each
|
||||
reserves a minimum width so they align vertically across cards; meaning
|
||||
folded into the hover title */}
|
||||
<div className="mt-1.5 flex items-center gap-x-2.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span
|
||||
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
|
||||
title={S.agent.sessionCount(a.sessionCount)}
|
||||
>
|
||||
<GlyphIcon d={CARD_ICONS.sessions} size={12} />
|
||||
{a.sessionCount}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
|
||||
title={S.agent.toolCount(a.toolCount)}
|
||||
>
|
||||
<GlyphIcon d={STAT_ICONS.toolCalls} size={12} />
|
||||
{a.toolCount}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
|
||||
title={S.agent.vaultKeyCount(a.vaultKeyCount)}
|
||||
>
|
||||
<GlyphIcon d={CARD_ICONS.vaultKeys} size={12} />
|
||||
{a.vaultKeyCount}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
|
||||
title={S.agent.scheduleCount(a.scheduleCount)}
|
||||
>
|
||||
<GlyphIcon d={CARD_ICONS.schedules} size={12} />
|
||||
{a.scheduleCount}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1"
|
||||
title={`${S.agent.updatedAt} ${a.updatedAt ? formatDateTime(a.updatedAt) : "—"}`}
|
||||
>
|
||||
<GlyphIcon d={STAT_ICONS.elapsed} size={12} />
|
||||
{a.updatedAt ? formatRelativeDays(a.updatedAt, locale) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session activity sparkline (hidden on narrow screens first, giving the horizontal space back to content and buttons) */}
|
||||
<ActivitySparkline
|
||||
data={a.sessionActivity}
|
||||
label={S.agent.activity(a.sessionActivity.length || 30)}
|
||||
className="hidden shrink-0 md:block"
|
||||
/>
|
||||
|
||||
{/* Button group to the right of the sparkline: "New Chat" shows text, the rest are square icon buttons (tooltip shows the full name) */}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button size="sm" variant="primary" onClick={() => newChat(a.agentId)}>
|
||||
<GlyphIcon d={CARD_ICONS.newChat} />
|
||||
{S.chat.newSessionMenu}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCurrentAgentId(a.agentId);
|
||||
navigate(`/agents/${a.agentId}`);
|
||||
}}
|
||||
>
|
||||
<GlyphIcon
|
||||
d={CARD_ICONS.settings}
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
{S.common.settings}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
title={S.nav.usage}
|
||||
aria-label={S.nav.usage}
|
||||
onClick={() => navigate(`/usage?agentId=${encodeURIComponent(a.agentId)}`)}
|
||||
>
|
||||
<GlyphIcon
|
||||
d={CARD_ICONS.usage}
|
||||
size={15}
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
title={S.nav.traces}
|
||||
aria-label={S.nav.traces}
|
||||
onClick={() => navigate(`/traces?agentId=${encodeURIComponent(a.agentId)}`)}
|
||||
>
|
||||
<GlyphIcon
|
||||
d={CARD_ICONS.traces}
|
||||
size={15}
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
</Button>
|
||||
{/* Built-in Agents can't be deleted: shown as a non-button light gray
|
||||
placeholder (no border/background, no hover response, disabled cursor,
|
||||
explained via tooltip); the transparent border keeps the same box size as
|
||||
an icon button so column widths stay consistent across cards */}
|
||||
{builtin ? (
|
||||
<span
|
||||
role="img"
|
||||
title={S.agent.builtinUndeletable}
|
||||
aria-label={S.agent.builtinUndeletable}
|
||||
className="inline-flex cursor-not-allowed items-center justify-center rounded-md border border-transparent p-1.5 text-gray-300 dark:text-gray-600"
|
||||
>
|
||||
<GlyphIcon d={CARD_ICONS.trash} size={15} />
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="danger"
|
||||
title={S.agent.deleteAgent}
|
||||
aria-label={S.agent.deleteAgent}
|
||||
onClick={() =>
|
||||
setDeleting({ agentId: a.agentId, name: agentDisplayName(a) })
|
||||
}
|
||||
>
|
||||
<GlyphIcon d={CARD_ICONS.trash} size={15} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
title={S.agent.createTitle}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setCreateOpen(false)}>{S.common.cancel}</Button>
|
||||
<Button variant="primary" disabled={busy} onClick={() => void create()}>
|
||||
{S.common.create}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
label={S.agent.id}
|
||||
size="sm"
|
||||
value={agentId}
|
||||
onChange={(e) => setAgentId(e.target.value)}
|
||||
hint={S.agent.idHint}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label={S.agent.name}
|
||||
size="sm"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
hint={S.agent.nameHint}
|
||||
/>
|
||||
<Textarea
|
||||
label={S.agent.description}
|
||||
size="sm"
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Modal
|
||||
open={deleting !== null}
|
||||
title={S.agent.deleteAgent}
|
||||
onClose={() => {
|
||||
setDeleting(null);
|
||||
setDeleteError(null);
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDeleting(null);
|
||||
setDeleteError(null);
|
||||
}}
|
||||
>
|
||||
{S.common.cancel}
|
||||
</Button>
|
||||
<Button variant="danger" disabled={busy} onClick={() => void doDelete()}>
|
||||
{S.common.confirm}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{deleting ? S.agent.deleteConfirm(deleting.name) : ""}
|
||||
</p>
|
||||
{deleteError && (
|
||||
<p className="mt-2 text-xs text-red-600 dark:text-red-400">{deleteError}</p>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* Agent settings page "Schedule" tab: a table view over
|
||||
* agent_state/schedule/*.toml (status badge derived from run state; "next / last
|
||||
* fired" shown as two stacked rows) plus a shared create/edit modal form.
|
||||
* Readable by any member; toggle/edit/delete are owner-only — PUT has whole-file
|
||||
* replace semantics, so toggling also resends every field and only flips `enabled`.
|
||||
* startAt/endAt use datetime-local inputs (local timezone), converted to ISO 8601 on
|
||||
* submit; the "new Session each run" mode can also pick a Model (a provider + modelId
|
||||
* pair; defaults to the Project default; mutual exclusivity with sessionId is
|
||||
* validated server-side).
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type {
|
||||
ModelInfo,
|
||||
ScheduleItem,
|
||||
SchedulesResponse,
|
||||
ScheduleStatus,
|
||||
ScheduleUpsertRequest,
|
||||
} from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { useProject } from "../../state/project";
|
||||
import { providerInfo } from "@prismshadow/penguin-core/model-catalog";
|
||||
import { Badge, type BadgeTone } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input, Textarea } from "../../components/ui/input";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import { Modal } from "../../components/ui/modal";
|
||||
import { SkeletonList } from "../../components/ui/skeleton";
|
||||
|
||||
/** Display status → badge tone. */
|
||||
const STATUS_TONE: Record<ScheduleStatus, BadgeTone> = {
|
||||
active: "green",
|
||||
disabled: "gray",
|
||||
expired: "amber",
|
||||
done: "brand",
|
||||
missed: "amber",
|
||||
invalid: "red",
|
||||
};
|
||||
|
||||
/** ISO → datetime-local input value (local timezone, minute precision); returns "" when missing/invalid. */
|
||||
function toLocalInput(iso: string | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`);
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model selection value in the form: a paired reference. provider may be
|
||||
* omitted — when a hand-written TOML only specifies model_id, it's resolved by
|
||||
* unique match at parse time; editing never fills in a provider on its own.
|
||||
*/
|
||||
interface ScheduleModelRef {
|
||||
provider?: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model reference ↔ native select option value: encoded as a JSON array
|
||||
* ([provider ?? null, modelId]), used only as a transient DOM-value serialization —
|
||||
* it never enters storage or requests; the persisted/submitted data still keeps
|
||||
* provider and modelId as two separate fields (no concatenation anywhere in the
|
||||
* pipeline). "" means the Project default.
|
||||
*/
|
||||
const modelOptionValue = (ref: ScheduleModelRef): string =>
|
||||
JSON.stringify([ref.provider ?? null, ref.modelId]);
|
||||
|
||||
const parseModelOption = (v: string): ScheduleModelRef | null => {
|
||||
if (!v) return null;
|
||||
const [provider, modelId] = JSON.parse(v) as [string | null, string];
|
||||
return provider === null ? { modelId } : { provider, modelId };
|
||||
};
|
||||
|
||||
/** Display label for a model option: upstream id + provider name (shown side by side, not a composite id). */
|
||||
const modelOptionLabel = (ref: ScheduleModelRef): string =>
|
||||
ref.provider === undefined
|
||||
? ref.modelId
|
||||
: `${ref.modelId} · ${providerInfo(ref.provider)?.label ?? ref.provider}`;
|
||||
|
||||
/** Modal form state (shared by create/edit): non-null editing means editing that task (name locked). */
|
||||
interface FormState {
|
||||
editing: string | null;
|
||||
name: string;
|
||||
prompt: string;
|
||||
enabled: boolean;
|
||||
/** datetime-local input value (converted to ISO on submit). */
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
period: string;
|
||||
target: "new" | "session";
|
||||
sessionId: string;
|
||||
workspace: string;
|
||||
/** Model for the new-Session mode (null = Project default, modelId/provider omitted). */
|
||||
model: ScheduleModelRef | null;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
editing: null,
|
||||
name: "",
|
||||
prompt: "",
|
||||
enabled: true,
|
||||
startAt: "",
|
||||
endAt: "",
|
||||
period: "",
|
||||
target: "new",
|
||||
sessionId: "",
|
||||
workspace: "",
|
||||
model: null,
|
||||
};
|
||||
|
||||
export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
const { currentProject } = useProject();
|
||||
const projectId = currentProject?.projectId ?? null;
|
||||
const isOwner = currentProject?.role === "owner";
|
||||
|
||||
const [data, setData] = useState<SchedulesResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Modal form: non-null means open (EMPTY_FORM for create / prefilled row for edit).
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
// Name of the task pending deletion confirmation (non-null shows the confirm modal).
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
// Model dropdown data (needed only for owners); load failure doesn't block the form — falling back to "Project default" is fine.
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!projectId || !agentId) return;
|
||||
setData(null);
|
||||
setError(null);
|
||||
try {
|
||||
setData(await api.listSchedules(projectId, agentId));
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
}
|
||||
}, [projectId, agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !isOwner) return;
|
||||
api
|
||||
.getModels(projectId)
|
||||
.then((res) => setModels(res.models))
|
||||
.catch(() => setModels([]));
|
||||
}, [projectId, isOwner]);
|
||||
|
||||
const set = (patch: Partial<FormState>) =>
|
||||
setForm((prev) => (prev === null ? prev : { ...prev, ...patch }));
|
||||
|
||||
const openForm = (next: FormState) => {
|
||||
setFormError(null);
|
||||
setForm(next);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!projectId || form === null) return;
|
||||
setFormError(null);
|
||||
setNotice(null);
|
||||
const name = form.editing ?? form.name.trim();
|
||||
// sessionId is required in bind-to-Session mode — leaving it blank would silently downgrade to "new Session", changing the user's intended choice.
|
||||
if (
|
||||
!name ||
|
||||
!form.prompt.trim() ||
|
||||
!form.startAt ||
|
||||
(form.target === "session" && !form.sessionId.trim())
|
||||
) {
|
||||
setFormError(S.common.requiredField);
|
||||
return;
|
||||
}
|
||||
// Empty-string keys are always omitted; target is one of two choices — sessionId is
|
||||
// sent only when binding to a Session, and workspace plus the model reference
|
||||
// (modelId + provider pair) only when creating a new Session.
|
||||
const body: ScheduleUpsertRequest = {
|
||||
prompt: form.prompt,
|
||||
enabled: form.enabled,
|
||||
startAt: new Date(form.startAt).toISOString(),
|
||||
...(form.period.trim() ? { period: form.period.trim() } : {}),
|
||||
...(form.endAt ? { endAt: new Date(form.endAt).toISOString() } : {}),
|
||||
...(form.target === "session" && form.sessionId.trim()
|
||||
? { sessionId: form.sessionId.trim() }
|
||||
: {}),
|
||||
...(form.target === "new" && form.workspace.trim()
|
||||
? { workspace: form.workspace.trim() }
|
||||
: {}),
|
||||
...(form.target === "new" && form.model
|
||||
? {
|
||||
modelId: form.model.modelId,
|
||||
...(form.model.provider !== undefined ? { provider: form.model.provider } : {}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
setBusy(true);
|
||||
try {
|
||||
if (form.editing !== null) await api.updateSchedule(projectId, agentId, form.editing, body);
|
||||
else await api.createSchedule(projectId, agentId, { name, ...body });
|
||||
setForm(null);
|
||||
setNotice(S.common.saved);
|
||||
await load();
|
||||
} catch (e) {
|
||||
// Errors like 400 (validated with the same rules as hand-written files) land under the modal form.
|
||||
setFormError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Toggle: whole-file-replace semantics — resend original fields, only flip enabled. */
|
||||
const toggle = async (item: ScheduleItem) => {
|
||||
if (!projectId) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await api.updateSchedule(projectId, agentId, item.name, {
|
||||
prompt: item.prompt,
|
||||
enabled: !item.enabled,
|
||||
startAt: item.startAt,
|
||||
...(item.period !== undefined ? { period: item.period } : {}),
|
||||
...(item.endAt !== undefined ? { endAt: item.endAt } : {}),
|
||||
...(item.sessionId !== undefined ? { sessionId: item.sessionId } : {}),
|
||||
...(item.workspace !== undefined ? { workspace: item.workspace } : {}),
|
||||
// Model reference is resent as a pair (a hand-written file with provider omitted stays omitted).
|
||||
...(item.modelId !== undefined ? { modelId: item.modelId } : {}),
|
||||
...(item.provider !== undefined ? { provider: item.provider } : {}),
|
||||
});
|
||||
setNotice(S.common.saved);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Edit: prefill this row into the modal form (submits via PUT; model reference prefilled as a pair, provider not auto-filled when absent). */
|
||||
const startEdit = (item: ScheduleItem) => {
|
||||
openForm({
|
||||
editing: item.name,
|
||||
name: item.name,
|
||||
prompt: item.prompt,
|
||||
enabled: item.enabled,
|
||||
startAt: toLocalInput(item.startAt),
|
||||
endAt: toLocalInput(item.endAt),
|
||||
period: item.period ?? "",
|
||||
target: item.sessionId ? "session" : "new",
|
||||
sessionId: item.sessionId ?? "",
|
||||
workspace: item.workspace ?? "",
|
||||
model:
|
||||
item.modelId !== undefined
|
||||
? {
|
||||
modelId: item.modelId,
|
||||
...(item.provider !== undefined ? { provider: item.provider } : {}),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
};
|
||||
|
||||
/** Confirm modal's "Confirm": closes the modal after deletion; if the deleted task is currently being edited, close the form too. */
|
||||
const confirmRemove = async () => {
|
||||
if (!projectId || deleting === null) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await api.deleteSchedule(projectId, agentId, deleting);
|
||||
if (form?.editing === deleting) setForm(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!projectId) return null;
|
||||
|
||||
const schedules = data?.schedules ?? [];
|
||||
const invalidFiles = data?.invalidFiles ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{S.schedule.desc}</p>
|
||||
{!isOwner && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{S.schedule.readOnlyHint}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data === null ? (
|
||||
<SkeletonList rows={4} />
|
||||
) : schedules.length === 0 ? (
|
||||
// Plain-text empty state (settings area doesn't use the penguin-icon EmptyState, keeps the same gray level as the table area).
|
||||
<p className="py-2 text-xs text-gray-400 dark:text-gray-500">{S.schedule.empty}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto overflow-y-clip rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<table className="w-full min-w-[640px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50/80 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900">
|
||||
<th className="px-3 py-2.5">{S.schedule.colName}</th>
|
||||
<th className="px-3 py-2.5">{S.schedule.colStatus}</th>
|
||||
<th className="px-3 py-2.5">{S.schedule.colPeriod}</th>
|
||||
<th className="px-3 py-2.5">{S.schedule.colTarget}</th>
|
||||
<th className="px-3 py-2.5">{S.schedule.colFireTimes}</th>
|
||||
<th className="px-3 py-2.5">{S.schedule.colQueued}</th>
|
||||
{isOwner && <th className="px-3 py-2.5" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{schedules.map((item) => (
|
||||
<tr
|
||||
key={item.name}
|
||||
className="border-b border-gray-100 transition-colors duration-150 last:border-b-0 hover:bg-gray-50 dark:border-gray-800/60 dark:hover:bg-gray-800/40"
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-xs">{item.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
{/* invalid reason is folded into the hover title. */}
|
||||
<span title={item.invalidReason}>
|
||||
<Badge tone={STATUS_TONE[item.status]}>
|
||||
{S.schedule.statusNames[item.status] ?? item.status}
|
||||
</Badge>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{item.period !== undefined ? (
|
||||
<span className="font-mono">{item.period}</span>
|
||||
) : (
|
||||
S.schedule.once
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className="max-w-36 truncate px-3 py-2 text-xs text-gray-500 dark:text-gray-400"
|
||||
title={item.sessionId}
|
||||
>
|
||||
{item.sessionId !== undefined ? (
|
||||
<span className="font-mono">{item.sessionId}</span>
|
||||
) : (
|
||||
S.schedule.newSession
|
||||
)}
|
||||
</td>
|
||||
{/* Top row: next fire time; bottom row: last fired time (both show — when absent). */}
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<span className="block text-gray-600 dark:text-gray-300">
|
||||
{item.nextFireAt ? formatDateTime(item.nextFireAt) : "—"}
|
||||
</span>
|
||||
<span className="block text-gray-400 dark:text-gray-500">
|
||||
{item.lastFiredAt ? formatDateTime(item.lastFiredAt) : "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{item.queued && <Badge tone="brand">{S.schedule.queued}</Badge>}
|
||||
</td>
|
||||
{isOwner && (
|
||||
<td className="whitespace-nowrap px-3 py-2 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => void toggle(item)}
|
||||
>
|
||||
{item.enabled ? S.schedule.disable : S.schedule.enable}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => startEdit(item)}
|
||||
>
|
||||
{S.common.edit}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => setDeleting(item.name)}
|
||||
>
|
||||
{S.common.delete}
|
||||
</Button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invalidFiles.length > 0 && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400">
|
||||
<p className="font-medium">{S.schedule.invalidFiles}</p>
|
||||
<ul className="mt-0.5 space-y-0.5 font-mono">
|
||||
{invalidFiles.map((f) => (
|
||||
<li key={f.name}>
|
||||
{f.name}: {f.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create entry point (owner): the form lives in a modal; the inline "Edit" button reuses the same modal. */}
|
||||
{isOwner && data !== null && (
|
||||
<Button size="sm" variant="primary" disabled={busy} onClick={() => openForm(EMPTY_FORM)}>
|
||||
{S.schedule.addTitle}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Shared create/edit modal form. */}
|
||||
<Modal
|
||||
open={form !== null}
|
||||
title={form?.editing != null ? S.schedule.editTitle(form.editing) : S.schedule.addTitle}
|
||||
onClose={() => setForm(null)}
|
||||
widthClass="sm:max-w-lg"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setForm(null)}>{S.common.cancel}</Button>
|
||||
<Button variant="primary" disabled={busy} onClick={() => void submit()}>
|
||||
{form?.editing != null ? S.common.save : S.common.create}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{form !== null && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.schedule.name}
|
||||
hint={S.schedule.nameHint}
|
||||
value={form.name}
|
||||
disabled={form.editing !== null}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
className="font-mono"
|
||||
placeholder="daily_report"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.schedule.period}
|
||||
value={form.period}
|
||||
onChange={(e) => set({ period: e.target.value })}
|
||||
className="font-mono"
|
||||
placeholder={S.schedule.periodPlaceholder}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.schedule.startAt}
|
||||
type="datetime-local"
|
||||
value={form.startAt}
|
||||
onChange={(e) => set({ startAt: e.target.value })}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.schedule.endAt}
|
||||
type="datetime-local"
|
||||
value={form.endAt}
|
||||
onChange={(e) => set({ endAt: e.target.value })}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
label={S.schedule.target}
|
||||
value={form.target}
|
||||
onChange={(e) => set({ target: e.target.value as FormState["target"] })}
|
||||
>
|
||||
<option value="new">{S.schedule.targetNew}</option>
|
||||
<option value="session">{S.schedule.targetSession}</option>
|
||||
</Select>
|
||||
{form.target === "session" ? (
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.schedule.sessionId}
|
||||
value={form.sessionId}
|
||||
onChange={(e) => set({ sessionId: e.target.value })}
|
||||
className="font-mono"
|
||||
autoComplete="off"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
size="sm"
|
||||
label={S.schedule.model}
|
||||
value={form.model ? modelOptionValue(form.model) : ""}
|
||||
onChange={(e) => set({ model: parseModelOption(e.target.value) })}
|
||||
>
|
||||
<option value="">{S.schedule.modelDefault}</option>
|
||||
{/* When the prefilled reference is no longer in the model config (including a
|
||||
hand-written file missing only provider), add an extra option so it isn't
|
||||
displayed as "Project default". */}
|
||||
{form.model &&
|
||||
!models.some(
|
||||
(m) =>
|
||||
m.modelId === form.model!.modelId && m.provider === form.model!.provider,
|
||||
) && (
|
||||
<option value={modelOptionValue(form.model)}>
|
||||
{modelOptionLabel(form.model)}
|
||||
</option>
|
||||
)}
|
||||
{models.map((m) => (
|
||||
<option
|
||||
key={`${m.provider}:${m.modelId}`}
|
||||
value={modelOptionValue({ provider: m.provider, modelId: m.modelId })}
|
||||
>
|
||||
{modelOptionLabel({ provider: m.provider, modelId: m.modelId })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.schedule.workspace}
|
||||
value={form.workspace}
|
||||
onChange={(e) => set({ workspace: e.target.value })}
|
||||
className="font-mono"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
label={S.schedule.prompt}
|
||||
size="sm"
|
||||
rows={4}
|
||||
value={form.prompt}
|
||||
onChange={(e) => set({ prompt: e.target.value })}
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => set({ enabled: e.target.checked })}
|
||||
/>
|
||||
{S.schedule.enabled}
|
||||
</label>
|
||||
{formError && <p className="text-xs text-red-600 dark:text-red-400">{formError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Delete confirmation (same pattern as Vault / Agent deletion: Modal + cancel/danger-confirm). */}
|
||||
<Modal
|
||||
open={deleting !== null}
|
||||
title={S.schedule.deleteTitle}
|
||||
onClose={() => setDeleting(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setDeleting(null)}>{S.common.cancel}</Button>
|
||||
<Button variant="danger" disabled={busy} onClick={() => void confirmRemove()}>
|
||||
{S.common.confirm}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{deleting !== null ? S.schedule.deleteConfirm(deleting) : ""}
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
{notice && <p className="text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Agent settings page "Vault" tab: an Agent-level key-value vault
|
||||
* (agent_state/.vault.toml) — a table (key, masked value, delete) plus an "Add" modal
|
||||
* (key + value, value uses a password field). Saving goes through PUT with
|
||||
* whole-table replace semantics: keys absent from the body are deleted, and
|
||||
* resending only the key name means keep the original value (plaintext never comes
|
||||
* back to the frontend); only owners can edit, members are read-only.
|
||||
* The key name is injected into the Agent's system prompt to inform the model; the
|
||||
* value is injected only into the exec_command subprocess environment, never into
|
||||
* the model context.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { VaultEntryInfo, VaultUpdateRequest } from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { useProject } from "../../state/project";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { PasswordInput } from "../../components/ui/password-input";
|
||||
import { Modal } from "../../components/ui/modal";
|
||||
import { SkeletonList } from "../../components/ui/skeleton";
|
||||
|
||||
/** Vault key naming rule (consistent with core/server): shell environment variable name. */
|
||||
const VAULT_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
export function VaultTab({ agentId }: { agentId: string }) {
|
||||
const { currentProject } = useProject();
|
||||
const projectId = currentProject?.projectId ?? null;
|
||||
const isOwner = currentProject?.role === "owner";
|
||||
|
||||
const [entries, setEntries] = useState<VaultEntryInfo[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Add modal: both form state and errors travel with the modal (tab-level error would be hidden behind the modal).
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [keyInput, setKeyInput] = useState("");
|
||||
const [valueInput, setValueInput] = useState("");
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
// Key pending deletion confirmation (non-null shows the confirm modal).
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!projectId || !agentId) return;
|
||||
setEntries(null);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.getVault(projectId, agentId);
|
||||
setEntries(res.entries);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
}
|
||||
}, [projectId, agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
/** Persist a change immediately (add / delete): returns null on success, an error message on failure — the caller decides whether it lands inside the modal or at the tab level. */
|
||||
const persist = async (body: VaultUpdateRequest): Promise<string | null> => {
|
||||
if (!projectId || !agentId) return S.common.unknownError;
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const res = await api.putVault(projectId, agentId, body);
|
||||
setEntries(res.entries);
|
||||
setNotice(S.common.saved);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e instanceof ApiError ? e.message : S.common.unknownError;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Keep existing keys (resending only the key name = keep the original value), excluding excludeKey. */
|
||||
const keepEntries = (excludeKey?: string) =>
|
||||
(entries ?? [])
|
||||
.filter((e) => e.key !== excludeKey)
|
||||
.map((e): VaultUpdateRequest["entries"][number] => ({ key: e.key }));
|
||||
|
||||
/** Open the add modal (reset form and error state). */
|
||||
const openAdd = () => {
|
||||
setKeyInput("");
|
||||
setValueInput("");
|
||||
setAddError(null);
|
||||
setAdding(true);
|
||||
};
|
||||
|
||||
const addEntry = async () => {
|
||||
const key = keyInput.trim();
|
||||
if (!key) {
|
||||
setAddError(S.common.requiredField);
|
||||
return;
|
||||
}
|
||||
if (!VAULT_KEY_PATTERN.test(key)) {
|
||||
setAddError(S.vault.keyInvalid);
|
||||
return;
|
||||
}
|
||||
if (!valueInput) {
|
||||
setAddError(S.vault.valueRequired);
|
||||
return;
|
||||
}
|
||||
setAddError(null);
|
||||
// Upsert by same key name: don't resend the existing entry too, to avoid a 400 from PUT's duplicate-key validation.
|
||||
const err = await persist({ entries: [...keepEntries(key), { key, value: valueInput }] });
|
||||
if (err !== null) {
|
||||
setAddError(err);
|
||||
return;
|
||||
}
|
||||
setAdding(false);
|
||||
};
|
||||
|
||||
/** Confirm modal's "Confirm": closes the modal after deletion (on failure, the error lands on the tab's error line). */
|
||||
const confirmRemove = async () => {
|
||||
if (deleting === null) return;
|
||||
setError(null);
|
||||
const err = await persist({ entries: keepEntries(deleting) });
|
||||
if (err !== null) setError(err);
|
||||
setDeleting(null);
|
||||
};
|
||||
|
||||
if (!projectId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{S.vault.desc}</p>
|
||||
{!isOwner && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{S.vault.readOnlyHint}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{entries === null ? (
|
||||
<SkeletonList rows={4} />
|
||||
) : entries.length === 0 ? (
|
||||
// Plain-text empty state (settings area doesn't use the penguin-icon EmptyState, keeps the same gray level as the table area).
|
||||
<p className="py-2 text-xs text-gray-400 dark:text-gray-500">{S.vault.empty}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto overflow-y-clip rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<table className="w-full min-w-[420px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50/80 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900">
|
||||
<th className="px-3 py-2.5">{S.vault.key}</th>
|
||||
<th className="px-3 py-2.5">{S.vault.valueMasked}</th>
|
||||
<th className="px-3 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.key}
|
||||
className="border-b border-gray-100 transition-colors duration-150 last:border-b-0 hover:bg-gray-50 dark:border-gray-800/60 dark:hover:bg-gray-800/40"
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-xs">{entry.key}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
{entry.valueMasked}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{isOwner && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => setDeleting(entry.key)}
|
||||
>
|
||||
{S.vault.remove}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add entry point (owner): the form lives in a modal; submitting the same key name overwrites the original value. */}
|
||||
{isOwner && entries !== null && (
|
||||
<Button size="sm" variant="primary" disabled={busy} onClick={openAdd}>
|
||||
{S.vault.add}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={adding}
|
||||
title={S.vault.addTitle}
|
||||
onClose={() => setAdding(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setAdding(false)}>{S.common.cancel}</Button>
|
||||
<Button variant="primary" disabled={busy} onClick={() => void addEntry()}>
|
||||
{S.vault.add}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.vault.key}
|
||||
hint={S.vault.keyHint}
|
||||
value={keyInput}
|
||||
onChange={(e) => setKeyInput(e.target.value)}
|
||||
className="font-mono"
|
||||
placeholder="OPENAI_API_KEY"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<PasswordInput
|
||||
size="sm"
|
||||
label={S.vault.value}
|
||||
value={valueInput}
|
||||
onChange={(e) => setValueInput(e.target.value)}
|
||||
className="font-mono"
|
||||
autoComplete="off"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !busy) void addEntry();
|
||||
}}
|
||||
/>
|
||||
{addError && <p className="text-xs text-red-600 dark:text-red-400">{addError}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete confirmation (same pattern as Agent / Session deletion: Modal + cancel/danger-confirm). */}
|
||||
<Modal
|
||||
open={deleting !== null}
|
||||
title={S.vault.deleteTitle}
|
||||
onClose={() => setDeleting(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setDeleting(null)}>{S.common.cancel}</Button>
|
||||
<Button variant="danger" disabled={busy} onClick={() => void confirmRemove()}>
|
||||
{S.common.confirm}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{deleting !== null ? S.vault.deleteConfirm(deleting) : ""}
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
{notice && <p className="text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Metric switching and per-model series grouping for the Benchmark center chart (pure functions,
|
||||
* easy to unit test): the chart can switch between the score / cost / duration metrics (sharing
|
||||
* the same time axis), and is grouped into series by each evaluation's (provider, modelId) — one
|
||||
* color per series, legend by model. score is always present;
|
||||
* cost / durationMs are optional — missing values are **skipped points**: neither drawn nor
|
||||
* connected, so the line breaks at the gap (lineSegments splits value-bearing indices into
|
||||
* contiguous segments).
|
||||
*/
|
||||
|
||||
export type BenchmarkMetric = "score" | "cost" | "duration";
|
||||
|
||||
export const BENCHMARK_METRICS: readonly BenchmarkMetric[] = ["score", "cost", "duration"];
|
||||
|
||||
/** Minimal evaluation shape needed to read a metric (BenchmarkEvaluation is a superset). */
|
||||
export interface MetricSourceLike {
|
||||
score: number;
|
||||
cost?: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
/** Each evaluation's value under the selected metric; missing (cost / durationMs not recorded) is null (skipped point). */
|
||||
export function metricValues(
|
||||
evaluations: readonly MetricSourceLike[],
|
||||
metric: BenchmarkMetric,
|
||||
): (number | null)[] {
|
||||
return evaluations.map((e) => {
|
||||
const v = metric === "score" ? e.score : metric === "cost" ? e.cost : e.durationMs;
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
});
|
||||
}
|
||||
|
||||
/** A single data point on the chart: original index (x-axis position) + value. */
|
||||
export interface MetricPoint {
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a value sequence with gaps into **contiguous value-bearing** segments (each segment has
|
||||
* at least 1 point): points within a segment are connected, segments are broken apart; a
|
||||
* single-point segment draws only a point, no line.
|
||||
*/
|
||||
export function lineSegments(values: readonly (number | null)[]): MetricPoint[][] {
|
||||
const segments: MetricPoint[][] = [];
|
||||
let current: MetricPoint[] = [];
|
||||
values.forEach((value, index) => {
|
||||
if (value === null) {
|
||||
if (current.length > 0) segments.push(current);
|
||||
current = [];
|
||||
return;
|
||||
}
|
||||
current.push({ index, value });
|
||||
});
|
||||
if (current.length > 0) segments.push(current);
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Y-axis upper bound: the max of value-bearing points (falls back to a tiny positive number when all values are missing / zero, to avoid dividing by zero in the coordinate system). */
|
||||
export function metricMax(values: readonly (number | null)[]): number {
|
||||
return Math.max(1e-9, ...values.filter((v): v is number => v !== null));
|
||||
}
|
||||
|
||||
/** Minimal evaluation shape needed for series grouping (BenchmarkEvaluation is a superset). */
|
||||
export interface ModelRefLike {
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
/** One chart series: the set of evaluations sharing the same (provider, modelId) (older records with no model tag are grouped into a single series). */
|
||||
export interface EvaluationSeries {
|
||||
/** Grouping key (internal grouping only, not used as an id; empty string for untagged model). */
|
||||
key: string;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
/** Matching evaluation indices: global time-axis positions, shared across all series on the same x-axis. */
|
||||
indices: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups evaluations into series by the model they carry: models are ordered by first
|
||||
* appearance (color is picked from SERIES_COLORS by series index, so color follows the model and
|
||||
* doesn't change with filtering); older records with no model tag are grouped into a trailing
|
||||
* unnamed series (shown in gray, labeled "untagged model").
|
||||
*/
|
||||
export function modelSeries(evaluations: readonly ModelRefLike[]): EvaluationSeries[] {
|
||||
const map = new Map<string, EvaluationSeries>();
|
||||
evaluations.forEach((e, index) => {
|
||||
const labeled = e.modelId !== undefined && e.modelId !== "";
|
||||
const key = labeled ? `${e.provider ?? ""}\u0000${e.modelId}` : "";
|
||||
let series = map.get(key);
|
||||
if (!series) {
|
||||
series = {
|
||||
key,
|
||||
...(labeled && e.provider !== undefined ? { provider: e.provider } : {}),
|
||||
...(labeled && e.modelId !== undefined ? { modelId: e.modelId } : {}),
|
||||
indices: [],
|
||||
};
|
||||
map.set(key, series);
|
||||
}
|
||||
series.indices.push(index);
|
||||
});
|
||||
const all = [...map.values()];
|
||||
return [...all.filter((x) => x.key !== ""), ...all.filter((x) => x.key === "")];
|
||||
}
|
||||
|
||||
/** A series' value sequence under the selected metric: indices outside this series are null (skipped point), keeping the global time axis. */
|
||||
export function seriesValues(
|
||||
evaluations: readonly (MetricSourceLike & ModelRefLike)[],
|
||||
series: EvaluationSeries,
|
||||
metric: BenchmarkMetric,
|
||||
): (number | null)[] {
|
||||
const own = new Set(series.indices);
|
||||
return metricValues(evaluations, metric).map((v, i) => (own.has(i) ? v : null));
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* Benchmark page (read-only display):
|
||||
* the left directory lists Benchmarks grouped by Agent (the scoreboard is only fetched once
|
||||
* expanded); the right side shows the selected Benchmark's title info, a chart (switches between
|
||||
* score / cost / duration metrics on the same time axis; **grouped into series by the model each
|
||||
* evaluation carries** — the model isn't part of benchmark_config, each evaluation carries its
|
||||
* own — one color per series plus a legend, with older untagged records shown as a gray series;
|
||||
* missing values are skipped points, breaking the line; reuses the usage center's ChartFrame
|
||||
* coordinate system) and an evaluation detail table (includes a model column; rows expand to
|
||||
* show the evaluation summary — title and body shown separately — and per-case scores, and case
|
||||
* rows further expand to show the raw results of each run, with a Session link straight to that
|
||||
* Session's trace observability).
|
||||
* With a ?agentId= deep link, only the target Agent is expanded by default.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import type {
|
||||
BenchmarkCaseScore,
|
||||
BenchmarkEvaluation,
|
||||
BenchmarkSummary,
|
||||
} from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { useDocumentTitle } from "../../lib/use-document-title";
|
||||
import { formatDateTime, formatMoney, formatScore, humanizeDuration } from "../../lib/format";
|
||||
import { agentDisplayName, useProject } from "../../state/project";
|
||||
import { AgentAvatar } from "../../components/ui/agent-avatar";
|
||||
import { Chevron } from "../../components/ui/chevron";
|
||||
import { Segmented } from "../../components/ui/segmented";
|
||||
import { Truncated } from "../../components/ui/truncated";
|
||||
import { EmptyState } from "../../components/ui/empty-state";
|
||||
import { SkeletonList } from "../../components/ui/skeleton";
|
||||
import { providerInfo } from "@prismshadow/penguin-core/model-catalog";
|
||||
import { seriesColor } from "../../lib/category-colors";
|
||||
import { makeGeom } from "../usage/chart-geom";
|
||||
import { ChartFrame, useChartWidth } from "../usage/chart-svg";
|
||||
import {
|
||||
lineSegments,
|
||||
metricMax,
|
||||
metricValues,
|
||||
modelSeries,
|
||||
seriesValues,
|
||||
} from "./benchmark-metrics";
|
||||
import type { BenchmarkMetric, EvaluationSeries } from "./benchmark-metrics";
|
||||
|
||||
interface Selection {
|
||||
agentId: string;
|
||||
benchmark: BenchmarkSummary;
|
||||
}
|
||||
|
||||
/** Expandable tree node for a single Agent (benchmarks are only fetched once expanded; same shape as the AgentNode on the trace observability page). */
|
||||
function AgentNode({
|
||||
projectId,
|
||||
agentId,
|
||||
name,
|
||||
defaultOpen,
|
||||
selection,
|
||||
onSelect,
|
||||
}: {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
name: string;
|
||||
/** Whether initially expanded: all expanded when there's no deep link; only the target Agent expanded with a ?agentId= deep link. */
|
||||
defaultOpen: boolean;
|
||||
selection: Selection | null;
|
||||
onSelect: (sel: Selection) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [benchmarks, setBenchmarks] = useState<BenchmarkSummary[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || benchmarks) return;
|
||||
api
|
||||
.listBenchmarks(projectId, agentId)
|
||||
.then((data) => setBenchmarks(data.benchmarks))
|
||||
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError));
|
||||
}, [open, benchmarks, projectId, agentId]);
|
||||
|
||||
return (
|
||||
<li className="pt-2.5">
|
||||
<div className="flex items-center px-1 pb-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label={open ? S.nav.collapseGroup : S.nav.expandGroup}
|
||||
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<AgentAvatar id={agentId} size={18} className="shrink-0 rounded" />
|
||||
<span className="min-w-0 truncate text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{name}
|
||||
</span>
|
||||
<Chevron open={open} size={12} className="text-gray-400" />
|
||||
<span className="min-w-0 flex-1" />
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="anim-fade">
|
||||
{error && <p className="px-2.5 py-1 text-xs text-red-500">{error}</p>}
|
||||
{!benchmarks && !error && (
|
||||
<p className="px-2.5 py-1 text-xs text-gray-400">{S.common.loading}</p>
|
||||
)}
|
||||
{benchmarks && benchmarks.length === 0 && (
|
||||
<p className="px-2.5 py-1 text-xs text-gray-400 dark:text-gray-600">
|
||||
{S.benchmark.emptyAgent}
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-0.5">
|
||||
{benchmarks?.map((b) => {
|
||||
const active = selection?.agentId === agentId && selection.benchmark.id === b.id;
|
||||
return (
|
||||
<li key={b.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect({ agentId, benchmark: b })}
|
||||
className={`flex w-full items-center gap-1.5 rounded-md px-2.5 py-1.5 text-left transition-colors duration-150 ${
|
||||
active
|
||||
? "bg-gray-200/70 dark:bg-gray-800"
|
||||
: "hover:bg-gray-200/50 dark:hover:bg-gray-800/70"
|
||||
}`}
|
||||
>
|
||||
<Truncated
|
||||
text={b.title}
|
||||
className={`min-w-0 flex-1 text-sm ${
|
||||
active
|
||||
? "font-medium text-gray-900 dark:text-gray-100"
|
||||
: "text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
/>
|
||||
<span className="shrink-0 font-mono text-[11px] text-gray-400">
|
||||
{b.caseCount}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Display label for a metric (shared by the Segmented options and the chart title; S is a live binding, must be read during render). */
|
||||
function metricLabel(metric: BenchmarkMetric): string {
|
||||
return metric === "score"
|
||||
? S.benchmark.colScore
|
||||
: metric === "cost"
|
||||
? S.benchmark.colCost
|
||||
: S.benchmark.colDuration;
|
||||
}
|
||||
|
||||
/** Display format for a metric value (shared by y-axis ticks and the tooltip): score / cost / duration each have their own formatting rule. */
|
||||
function formatMetric(metric: BenchmarkMetric, v: number): string {
|
||||
return metric === "score"
|
||||
? formatScore(v)
|
||||
: metric === "cost"
|
||||
? formatMoney(v)
|
||||
: humanizeDuration(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Metric-over-time line chart (cloned from the usage center's TrendChart: area + line + data
|
||||
* points, sharing the ChartFrame coordinate system). **Grouped into series** by the model each
|
||||
* evaluation carries: one color per series (SERIES_COLORS is a fixed color sequence, color
|
||||
* follows the model; older records with no model tag get a gray series), all series share the
|
||||
* same time axis and y-axis range. Missing values within a series (indices outside this series,
|
||||
* or cost / durationMs not recorded) are **skipped points** — lineSegments splits the
|
||||
* value-bearing indices into segments, drawing area + line + data points within each segment and
|
||||
* breaking between segments (a single-point segment draws only a point). ChartFrame's x-axis
|
||||
* labels take slice(5) of dates: passing `yyyy-MM-dd HH:mm` displays as `MM-dd HH:mm`. A single
|
||||
* evaluation is still drawn; no evaluations falls back to an empty state.
|
||||
*/
|
||||
function MetricTrendChart({
|
||||
evaluations,
|
||||
series,
|
||||
metric,
|
||||
}: {
|
||||
evaluations: BenchmarkEvaluation[];
|
||||
series: EvaluationSeries[];
|
||||
metric: BenchmarkMetric;
|
||||
}) {
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const [ref, width] = useChartWidth();
|
||||
|
||||
const values = metricValues(evaluations, metric);
|
||||
const geom = makeGeom(evaluations.length, metricMax(values), width);
|
||||
const dates = evaluations.map((e) => formatDateTime(e.time));
|
||||
const baseY = geom.y(0);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{width > 0 && (
|
||||
<ChartFrame
|
||||
geom={geom}
|
||||
fmtY={(v) => formatMetric(metric, v)}
|
||||
dates={dates}
|
||||
hover={hover}
|
||||
onHover={setHover}
|
||||
bubble={(i) => {
|
||||
const e = evaluations[i]!;
|
||||
const v = values[i] ?? null;
|
||||
return (
|
||||
<>
|
||||
<p className="text-gray-400">{formatDateTime(e.time)}</p>
|
||||
<p className="font-mono">
|
||||
{v === null ? "—" : formatMetric(metric, v)}
|
||||
{e.version !== undefined && (
|
||||
<span className="ml-1.5 text-gray-400">v{e.version}</span>
|
||||
)}
|
||||
</p>
|
||||
{e.modelId && <p className="font-mono text-gray-400">{e.modelId}</p>}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{series.map((s, si) => {
|
||||
const segments = lineSegments(seriesValues(evaluations, s, metric));
|
||||
return (
|
||||
<g
|
||||
key={s.key === "" ? "unlabeled" : s.key}
|
||||
className={s.modelId ? seriesColor(si).text : "text-gray-400 dark:text-gray-500"}
|
||||
>
|
||||
{segments.map((seg, k) => {
|
||||
const line = seg
|
||||
.map((p, j) => `${j === 0 ? "M" : "L"}${geom.x(p.index)},${geom.y(p.value)}`)
|
||||
.join(" ");
|
||||
const area = `${line} L${geom.x(seg[seg.length - 1]!.index)},${baseY} L${geom.x(seg[0]!.index)},${baseY} Z`;
|
||||
return (
|
||||
<g key={k}>
|
||||
{/* Area fill: line closed to the baseline, low opacity reinforces the trend's sense of "volume" (no area for single-point segments) */}
|
||||
{seg.length > 1 && (
|
||||
<path
|
||||
d={area}
|
||||
className="fill-current"
|
||||
stroke="none"
|
||||
opacity={hover !== null ? 0.06 : 0.1}
|
||||
/>
|
||||
)}
|
||||
{seg.length > 1 && (
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
opacity={hover !== null ? 0.35 : 1}
|
||||
/>
|
||||
)}
|
||||
{seg.map((p) => (
|
||||
<circle
|
||||
key={p.index}
|
||||
cx={geom.x(p.index)}
|
||||
cy={geom.y(p.value)}
|
||||
r={hover === p.index ? 4 : 2.5}
|
||||
className="fill-current"
|
||||
opacity={hover !== null && hover !== p.index ? 0.25 : 1}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</ChartFrame>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart section: title (follows metric) + metric switch (score / cost / duration, segmented
|
||||
* control) + model legend (only shown with >=2 series; a single series' identity is carried by
|
||||
* the detail table's model column and the hover tooltip instead) + the line chart. When the same
|
||||
* modelId coexists across providers, the legend appends the provider's display name to
|
||||
* disambiguate. Mounted under a keyed container per Benchmark: switching Benchmarks resets back
|
||||
* to "score".
|
||||
*/
|
||||
function TrendSection({ evaluations }: { evaluations: BenchmarkEvaluation[] }) {
|
||||
const [metric, setMetric] = useState<BenchmarkMetric>("score");
|
||||
const series = modelSeries(evaluations);
|
||||
const ids = series.map((s) => s.modelId).filter((v): v is string => v !== undefined);
|
||||
const dupIds = new Set(ids.filter((id, i) => ids.indexOf(id) !== i));
|
||||
const labelOf = (s: EvaluationSeries): string => {
|
||||
if (!s.modelId) return S.benchmark.legendUnlabeled;
|
||||
if (!dupIds.has(s.modelId)) return s.modelId;
|
||||
const provider = s.provider ? (providerInfo(s.provider)?.label ?? s.provider) : "";
|
||||
return provider ? `${s.modelId} · ${provider}` : s.modelId;
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold text-gray-500">
|
||||
{S.benchmark.trendTitle(metricLabel(metric))}
|
||||
</p>
|
||||
<div className="w-44 shrink-0">
|
||||
<Segmented
|
||||
options={[
|
||||
{ value: "score", label: metricLabel("score") },
|
||||
{ value: "cost", label: metricLabel("cost") },
|
||||
{ value: "duration", label: metricLabel("duration") },
|
||||
]}
|
||||
value={metric}
|
||||
onChange={setMetric}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{series.length >= 2 && (
|
||||
<div className="mb-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{series.map((s, i) => (
|
||||
<span
|
||||
key={s.key === "" ? "unlabeled" : s.key}
|
||||
className="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400"
|
||||
title={s.provider ? (providerInfo(s.provider)?.label ?? s.provider) : undefined}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-sm ${
|
||||
s.modelId ? seriesColor(i).swatch : "bg-gray-400 dark:bg-gray-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="font-mono">{labelOf(s)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<MetricTrendChart evaluations={evaluations} series={series} metric={metric} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CELL = "px-3 py-2";
|
||||
|
||||
/** One evaluation record: main row (time/version/total score/cost/duration) + a sub-table of per-case scores that expands on click. */
|
||||
function EvaluationRow({
|
||||
agentId,
|
||||
evaluation,
|
||||
}: {
|
||||
agentId: string;
|
||||
evaluation: BenchmarkEvaluation;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="cursor-pointer border-b border-gray-100 transition-colors duration-150 last:border-b-0 hover:bg-gray-50 dark:border-gray-800/60 dark:hover:bg-gray-800/40"
|
||||
>
|
||||
<td className={CELL}>
|
||||
<span className="flex items-center gap-1.5 text-xs">
|
||||
<Chevron open={open} size={12} className="text-gray-400" />
|
||||
{formatDateTime(evaluation.time)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`${CELL} font-mono text-xs text-gray-500 dark:text-gray-400`}>
|
||||
{evaluation.version !== undefined ? `v${evaluation.version}` : "—"}
|
||||
</td>
|
||||
<td
|
||||
className={`${CELL} max-w-40 truncate font-mono text-xs text-gray-500 dark:text-gray-400`}
|
||||
title={evaluation.provider}
|
||||
>
|
||||
{evaluation.modelId ?? "—"}
|
||||
</td>
|
||||
<td className={`${CELL} font-mono text-xs font-semibold tabular-nums`}>
|
||||
{formatScore(evaluation.score)}
|
||||
</td>
|
||||
<td className={`${CELL} font-mono text-xs tabular-nums text-gray-500 dark:text-gray-400`}>
|
||||
{formatMoney(evaluation.cost)}
|
||||
</td>
|
||||
<td className={`${CELL} font-mono text-xs tabular-nums text-gray-500 dark:text-gray-400`}>
|
||||
{evaluation.durationMs !== undefined ? humanizeDuration(evaluation.durationMs) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
{open && (
|
||||
<tr className="border-b border-gray-100 last:border-b-0 dark:border-gray-800/60">
|
||||
<td colSpan={6} className="bg-gray-50/80 px-3 py-2 dark:bg-gray-950/40">
|
||||
{/* Evaluation summary (title + body shown separately; the generating side always
|
||||
writes both, but the display side tolerates missing values — with an old-style
|
||||
single-paragraph summary only, it's still shown as usual, prefixed with the
|
||||
"Evaluation Summary" label). */}
|
||||
{(evaluation.summaryTitle || evaluation.summary) && (
|
||||
<div className="mb-2">
|
||||
{evaluation.summaryTitle ? (
|
||||
<p className="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||
{evaluation.summaryTitle}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs font-semibold text-gray-500">{S.benchmark.summaryLabel}</p>
|
||||
)}
|
||||
{evaluation.summary && (
|
||||
<p className="mt-0.5 whitespace-pre-wrap text-xs text-gray-600 dark:text-gray-300">
|
||||
{evaluation.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500">
|
||||
<th className="px-2 py-1 font-medium">{S.benchmark.colCase}</th>
|
||||
<th className="px-2 py-1 font-medium">{S.benchmark.colScore}</th>
|
||||
<th className="px-2 py-1 font-medium">{S.benchmark.colCost}</th>
|
||||
<th className="px-2 py-1 font-medium">{S.benchmark.colDuration}</th>
|
||||
<th className="px-2 py-1 font-medium">{S.benchmark.colSession}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{evaluation.cases.map((c) => (
|
||||
<CaseRow key={c.case} agentId={agentId} caseScore={c} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Session deep link: jumps straight to that Session's trace observability (?sessionId= auto-selects it, instead of stopping at the Agent group). */
|
||||
function SessionLink({ agentId, sessionId }: { agentId: string; sessionId?: string }) {
|
||||
if (!sessionId) return <span className="text-gray-400">—</span>;
|
||||
return (
|
||||
<Link
|
||||
to={`/traces?agentId=${encodeURIComponent(agentId)}&sessionId=${encodeURIComponent(sessionId)}`}
|
||||
className="font-mono text-gray-600 underline decoration-gray-300 underline-offset-2 hover:text-gray-900 dark:text-gray-300 dark:decoration-gray-600 dark:hover:text-gray-100"
|
||||
title={sessionId}
|
||||
>
|
||||
{sessionId}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Score row for one case: the case-level metrics = the average of its runs (already computed by
|
||||
* the server, trust its values). With runs[] present, the row can expand to show the raw results
|
||||
* of each run (#index + score / cost / duration / Session link); with the old format lacking
|
||||
* runs, it's not expandable and the case-level single Session link is used as before.
|
||||
*/
|
||||
function CaseRow({ agentId, caseScore: c }: { agentId: string; caseScore: BenchmarkCaseScore }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const runs = c.runs ?? [];
|
||||
const expandable = runs.length > 0;
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
onClick={expandable ? () => setOpen((v) => !v) : undefined}
|
||||
className={`text-xs ${expandable ? "cursor-pointer transition-colors duration-150 hover:bg-gray-100/70 dark:hover:bg-gray-800/40" : ""}`}
|
||||
>
|
||||
<td className="px-2 py-1 font-mono">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{expandable && <Chevron open={open} size={12} className="text-gray-400" />}
|
||||
{c.case}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1 font-mono tabular-nums">{formatScore(c.score)}</td>
|
||||
<td className="px-2 py-1 font-mono tabular-nums text-gray-500 dark:text-gray-400">
|
||||
{formatMoney(c.cost)}
|
||||
</td>
|
||||
<td className="px-2 py-1 font-mono tabular-nums text-gray-500 dark:text-gray-400">
|
||||
{c.durationMs !== undefined ? humanizeDuration(c.durationMs) : "—"}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<SessionLink agentId={agentId} {...(c.sessionId ? { sessionId: c.sessionId } : {})} />
|
||||
</td>
|
||||
</tr>
|
||||
{open &&
|
||||
runs.map((run, i) => (
|
||||
<tr key={i} className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{/* Indented run index row: #1, #2, ... (case-level metrics are their average) */}
|
||||
<td className="py-1 pl-7 pr-2 font-mono">
|
||||
{S.benchmark.colRun} #{i + 1}
|
||||
</td>
|
||||
<td className="px-2 py-1 font-mono tabular-nums">{formatScore(run.score)}</td>
|
||||
<td className="px-2 py-1 font-mono tabular-nums">{formatMoney(run.cost)}</td>
|
||||
<td className="px-2 py-1 font-mono tabular-nums">
|
||||
{run.durationMs !== undefined ? humanizeDuration(run.durationMs) : "—"}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<SessionLink
|
||||
agentId={agentId}
|
||||
{...(run.sessionId ? { sessionId: run.sessionId } : {})}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BenchmarkPage() {
|
||||
useDocumentTitle(S.benchmark.title);
|
||||
const { currentProject, agents, agentsLoading } = useProject();
|
||||
const projectId = currentProject?.projectId ?? null;
|
||||
// ?agentId= deep link (entered from the "Benchmark" tab on the Agent settings page): only the target Agent is expanded by default.
|
||||
const [searchParams] = useSearchParams();
|
||||
const focusAgentId = searchParams.get("agentId");
|
||||
const [selection, setSelection] = useState<Selection | null>(null);
|
||||
|
||||
// Clear the selection when the Project changes.
|
||||
useEffect(() => {
|
||||
setSelection(null);
|
||||
}, [projectId]);
|
||||
|
||||
if (!projectId) return null;
|
||||
|
||||
const bm = selection?.benchmark ?? null;
|
||||
// Chart uses ascending time order (the scoreboard is already ordered, this sort is defensive); the detail table shows newest first.
|
||||
const evaluations = bm ? [...bm.evaluations].sort((a, b) => a.time.localeCompare(b.time)) : [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col md:flex-row">
|
||||
{/* Directory tree: Agent -> Benchmark (left column on >=md; collapsible top area on <md) */}
|
||||
<aside className="max-h-52 shrink-0 overflow-y-auto border-b border-gray-200 bg-gray-50 px-1 py-2 md:max-h-none md:w-72 md:border-b-0 md:border-r dark:border-gray-800 dark:bg-gray-900">
|
||||
<p className="px-3 pb-1 text-xs font-bold uppercase tracking-wide text-gray-500">
|
||||
{S.benchmark.title}
|
||||
</p>
|
||||
{agentsLoading ? (
|
||||
<SkeletonList rows={4} />
|
||||
) : (
|
||||
<ul>
|
||||
{agents.map((a) => (
|
||||
<AgentNode
|
||||
key={a.agentId}
|
||||
projectId={projectId}
|
||||
agentId={a.agentId}
|
||||
name={agentDisplayName(a)}
|
||||
defaultOpen={focusAgentId === null || focusAgentId === a.agentId}
|
||||
selection={selection}
|
||||
onSelect={setSelection}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="min-w-0 flex-1 overflow-y-auto p-3 md:p-4">
|
||||
{selection && bm ? (
|
||||
// Changing the key on Benchmark switch resets expand state (a detail row's open doesn't linger across Benchmarks).
|
||||
<div key={`${selection.agentId}/${bm.id}`} className="mx-auto max-w-4xl space-y-4">
|
||||
{/* Title row: title + case count (the model isn't part of config — each evaluation
|
||||
carries its own, see the chart legend and the detail table's model column) +
|
||||
description */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<h1 className="min-w-0 truncate text-lg font-semibold">{bm.title}</h1>
|
||||
<span className="text-xs text-gray-500">{S.benchmark.caseCount(bm.caseCount)}</span>
|
||||
</div>
|
||||
{bm.description && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{bm.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{evaluations.length === 0 ? (
|
||||
<EmptyState title={S.benchmark.noEvaluations} />
|
||||
) : (
|
||||
<>
|
||||
<TrendSection evaluations={evaluations} />
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold text-gray-500">
|
||||
{S.benchmark.evaluations}
|
||||
</p>
|
||||
<div className="overflow-x-auto overflow-y-clip rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<table className="w-full min-w-[600px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50/80 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900">
|
||||
<th className="px-3 py-2.5">{S.benchmark.colTime}</th>
|
||||
<th className="px-3 py-2.5">{S.benchmark.colVersion}</th>
|
||||
<th className="px-3 py-2.5">{S.benchmark.colModel}</th>
|
||||
<th className="px-3 py-2.5">{S.benchmark.colScore}</th>
|
||||
<th className="px-3 py-2.5">{S.benchmark.colCost}</th>
|
||||
<th className="px-3 py-2.5">{S.benchmark.colDuration}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...evaluations].reverse().map((ev, i) => (
|
||||
<EvaluationRow key={i} agentId={selection.agentId} evaluation={ev} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title={S.benchmark.selectBenchmark} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @-agent handoff for the chat input area (pure logic, shared by chat-input.tsx /
|
||||
* chat-page.tsx and unit tests). Only a **leading** @ is meaningful: a target picked from
|
||||
* the menu is pinned as a highlighted chip at the front of the input (the text itself
|
||||
* carries no @ marker); hand-typed/pasted text starting with `@<agentId>` takes effect the
|
||||
* same way on send. Any @ elsewhere in the text is plain text.
|
||||
* - `matchMention`: finds the `@` prefix currently being typed from the text before the
|
||||
* caret, driving the agent-picker popup;
|
||||
* - `filterAgents`: filters candidates by prefix (agentId or display name, case-insensitive);
|
||||
* - `splitLeadingMention`: on send, parses a leading `@<agentId>`, splitting off the target
|
||||
* agent from the remaining text;
|
||||
* - `handoffMessage`: generates the first message for the @-mentioned agent's **new
|
||||
* conversation** — an origin note (`<handoff_from>` block) carrying the source agent /
|
||||
* Session / Workspace, followed by the user's text as a subsequent text part).
|
||||
*/
|
||||
import type { AgentSummary } from "@prismshadow/penguin-server/api";
|
||||
|
||||
/** Id characters allowed between `@` and the caret (matches core's id convention: letters, digits, underscore, hyphen). */
|
||||
const ID_PREFIX = /^[\w-]*$/;
|
||||
|
||||
/**
|
||||
* The @ mention currently being typed: `start` is the index of `@` in the full text,
|
||||
* `query` is the prefix between `@` and the caret, and `end` is the end position of the
|
||||
* same token to the right of the caret — selecting a candidate replaces the **entire**
|
||||
* `start..end` token (no leftover tail when the caret sits mid-token).
|
||||
*/
|
||||
export interface MentionMatch {
|
||||
start: number;
|
||||
end: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the @ mention currently being typed at the caret; returns null if none.
|
||||
* `@` must be at the start of the text or preceded by whitespace (to avoid treating
|
||||
* ordinary text like emails as mentions); only id characters are allowed between `@` and
|
||||
* the caret.
|
||||
*/
|
||||
export function matchMention(text: string, caret: number): MentionMatch | null {
|
||||
const before = text.slice(0, caret);
|
||||
const at = before.lastIndexOf("@");
|
||||
if (at < 0) return null;
|
||||
if (at > 0 && !/\s/.test(before[at - 1]!)) return null;
|
||||
const query = before.slice(at + 1);
|
||||
if (!ID_PREFIX.test(query)) return null;
|
||||
const rest = /^[\w-]*/.exec(text.slice(caret))![0];
|
||||
return { start: at, end: caret + rest.length, query };
|
||||
}
|
||||
|
||||
/** Filters candidate agents by prefix (agentId or display name, case-insensitive); an empty prefix returns all. */
|
||||
export function filterAgents(agents: AgentSummary[], query: string): AgentSummary[] {
|
||||
const q = query.toLowerCase();
|
||||
return agents.filter(
|
||||
(a) => a.agentId.toLowerCase().startsWith(q) || (a.name ?? "").toLowerCase().startsWith(q),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a leading mention: when text (expected to already be trimmed) starts with
|
||||
* `@<existing agentId>`, splits off the target agent from the remaining text (the id is
|
||||
* the longest `[\w-]+` run after `@`, and must exactly match an existing agentId — `@foo2`
|
||||
* does not count as @-ing foo; leading whitespace in the remaining text is trimmed).
|
||||
* Returns null when the text doesn't start with an @ for an existing agent; an @ elsewhere
|
||||
* in the text is never parsed.
|
||||
*/
|
||||
export function splitLeadingMention(
|
||||
text: string,
|
||||
agents: AgentSummary[],
|
||||
): { agent: AgentSummary; rest: string } | null {
|
||||
const m = /^@([\w-]+)([\s\S]*)$/.exec(text);
|
||||
if (!m) return null;
|
||||
const agent = agents.find((a) => a.agentId === m[1]);
|
||||
if (!agent) return null;
|
||||
return { agent, rest: m[2]!.trimStart() };
|
||||
}
|
||||
|
||||
/** Origin info for an @-handoff new conversation: source agent is always present; source Session is omitted while it's still a draft (not yet created). */
|
||||
export interface HandoffOrigin {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
sessionId?: string;
|
||||
sessionTitle?: string;
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* First message of an @-handoff new conversation (in English): the `<handoff_from>` block
|
||||
* states that this conversation was opened by an @ mention and carries the source agent /
|
||||
* Session / Workspace, so the @-mentioned agent knows its origin (e.g. defaulting to the
|
||||
* source agent as its working target, or reaching source files via the Workspace path);
|
||||
* the parenthetical label is omitted when the display name/title equals the id or is
|
||||
* absent. When rendering the message stream, `parseHandoffMessage` collapses this into a
|
||||
* one-line handoff notice — the raw text isn't shown (the model still sees it as usual).
|
||||
*/
|
||||
export function handoffMessage(origin: HandoffOrigin): string {
|
||||
const name =
|
||||
origin.agentName && origin.agentName !== origin.agentId ? ` (${origin.agentName})` : "";
|
||||
const lines = [`agent: ${origin.agentId}${name}`];
|
||||
if (origin.sessionId) {
|
||||
const title = origin.sessionTitle ? ` (${origin.sessionTitle})` : "";
|
||||
lines.push(`session: ${origin.sessionId}${title}`);
|
||||
}
|
||||
if (origin.workspace) lines.push(`workspace: ${origin.workspace}`);
|
||||
return [
|
||||
"<handoff_from>",
|
||||
"This conversation was opened by @-mentioning you from another conversation; its origin is listed below and the user's message, if any, follows. When the request refers to an agent, session, or files without naming them, it means this origin.",
|
||||
...lines,
|
||||
"</handoff_from>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse parse of `handoffMessage` (lets the message stream collapse the origin block into
|
||||
* a handoff notice): returns origin info when the whole message is strictly one
|
||||
* `<handoff_from>` block, otherwise returns null (a normal user message renders as-is).
|
||||
* Field lines are parsed as `key: id (label)`; non-field lines such as the explanation
|
||||
* sentence are ignored.
|
||||
*/
|
||||
export function parseHandoffMessage(text: string): HandoffOrigin | null {
|
||||
const block = /^<handoff_from>\n([\s\S]*)\n<\/handoff_from>$/.exec(text.trim());
|
||||
if (!block) return null;
|
||||
const origin: HandoffOrigin = { agentId: "" };
|
||||
for (const line of block[1]!.split("\n")) {
|
||||
const kv = /^(agent|session|workspace): (.+)$/.exec(line);
|
||||
if (!kv) continue;
|
||||
const key = kv[1]!;
|
||||
const value = kv[2]!;
|
||||
if (key === "workspace") {
|
||||
origin.workspace = value;
|
||||
continue;
|
||||
}
|
||||
const labeled = /^([\w-]+) \((.*)\)$/.exec(value);
|
||||
const id = labeled ? labeled[1]! : value;
|
||||
const label = labeled?.[2];
|
||||
if (key === "agent") {
|
||||
origin.agentId = id;
|
||||
if (label !== undefined) origin.agentName = label;
|
||||
} else {
|
||||
origin.sessionId = id;
|
||||
if (label !== undefined) origin.sessionTitle = label;
|
||||
}
|
||||
}
|
||||
return origin.agentId ? origin : null;
|
||||
}
|
||||
|
||||
/** Origin info for a scheduled-task trigger (the server's scheduledMessage `<scheduled_task>` block). */
|
||||
export interface ScheduledOrigin {
|
||||
/** Task name (filename minus .toml). */
|
||||
name: string;
|
||||
/** Trigger timestamp (ISO 8601); empty string when absent from the block. */
|
||||
firedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse parse of the server's scheduledMessage (lets the message stream collapse the
|
||||
* origin block into a scheduled-task notice): returns origin info and the remaining text
|
||||
* when the message **starts with** a `<scheduled_task>` block, otherwise returns null.
|
||||
* Unlike handoff, the block is followed by the task's Prompt body, which must be returned
|
||||
* alongside it for normal rendering (the raw block isn't shown; the Trace page shows it
|
||||
* as-is).
|
||||
*/
|
||||
export function parseScheduledMessage(
|
||||
text: string,
|
||||
): { origin: ScheduledOrigin; rest: string } | null {
|
||||
const m = /^<scheduled_task>\n([\s\S]*?)\n<\/scheduled_task>/.exec(text);
|
||||
if (!m) return null;
|
||||
const origin: ScheduledOrigin = { name: "", firedAt: "" };
|
||||
for (const line of m[1]!.split("\n")) {
|
||||
const kv = /^(schedule|fired_at): (.+)$/.exec(line);
|
||||
if (!kv) continue;
|
||||
if (kv[1] === "schedule") origin.name = kv[2]!;
|
||||
else origin.firedAt = kv[2]!;
|
||||
}
|
||||
if (!origin.name) return null;
|
||||
return { origin, rest: text.slice(m[0].length).replace(/^\n+/, "") };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Approval buttons: appear on the
|
||||
* corresponding tool card when always-ask is set and there's a pending approval; the decision
|
||||
* is submitted via POST /api/sessions/:s/approvals/:toolCallId.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { S } from "../../lib/strings";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
export function ApprovalButtons({
|
||||
onDecide,
|
||||
}: {
|
||||
onDecide: (decision: "allow" | "deny") => Promise<void>;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const decide = async (decision: "allow" | "deny") => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await onDecide(decision);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="primary" disabled={busy} onClick={() => void decide("allow")}>
|
||||
{S.chat.approve}
|
||||
</Button>
|
||||
<Button size="sm" disabled={busy} onClick={() => void decide("deny")}>
|
||||
{S.chat.deny}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,756 @@
|
||||
/**
|
||||
* Chat page (refactored version):
|
||||
* a thin top toolbar (Session title / status / iconized stats / Files toggle / details popup) +
|
||||
* the message stream and input area (input box vertically centered when there are no messages).
|
||||
* Files is no longer a mutually exclusive tab — it's a persistent, closable, resizable docked
|
||||
* panel on the right (use-files-panel.ts), and each message's trailing file summary card jumps to
|
||||
* and locates the file in the tree via onOpenFile.
|
||||
* Approval mode and Model/context usage live in the input area's toolbar; context is compacted
|
||||
* via the /compact slash command.
|
||||
* Draft state (/chat/new) is carried by DraftView: Agent / Workspace / approval mode / Model are
|
||||
* chosen before sending, and everything except approval mode is locked once the Session is
|
||||
* created. The Session list and the new-chat entry point live in the global sidebar.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import type {
|
||||
AgentSummary,
|
||||
ApprovalMode,
|
||||
ModelsResponse,
|
||||
SkillMetadataItem,
|
||||
TaskInputPart,
|
||||
} from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { apiErrorText } from "../../lib/api-error";
|
||||
import { useDocumentTitle } from "../../lib/use-document-title";
|
||||
import { formatDateTime, formatMoney, humanizeDuration, humanizeTokens } from "../../lib/format";
|
||||
import { approvalKey } from "../../lib/omni/stream-model";
|
||||
import { useTheme } from "../../state/theme";
|
||||
import { useProject } from "../../state/project";
|
||||
import { useSessions } from "../../state/sessions";
|
||||
import { Modal } from "../../components/ui/modal";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Skeleton } from "../../components/ui/skeleton";
|
||||
import { Truncated } from "../../components/ui/truncated";
|
||||
import { Dropdown } from "../../components/ui/dropdown";
|
||||
import { EmptyState } from "../../components/ui/empty-state";
|
||||
import { toastError } from "../../components/ui/toast";
|
||||
import { MessageStream } from "./message-stream";
|
||||
import type { StreamRenderContext } from "./message-stream";
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { DraftView } from "./draft-view";
|
||||
import { handoffMessage } from "./agent-mentions";
|
||||
import { sameModelRef } from "../models/model-grouping";
|
||||
import { providerInfo } from "@prismshadow/penguin-core/model-catalog";
|
||||
import { FilesPanel } from "./files-panel";
|
||||
import { useFilesPanel } from "./use-files-panel";
|
||||
import { useSessionDraft } from "./use-session-draft";
|
||||
import { useSessionStream } from "./use-session-stream";
|
||||
|
||||
const STAT_ICONS = {
|
||||
// Tokens (database / stacked cylinders)
|
||||
tokens:
|
||||
"M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3zm0 0v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3",
|
||||
// Cost (circled dollar sign)
|
||||
cost: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zm0-15v12m2.6-9.3c-.5-.8-1.5-1.2-2.6-1.2-1.5 0-2.7.8-2.7 2 0 2.7 5.4 1.3 5.4 4 0 1.2-1.2 2-2.7 2-1.2 0-2.2-.5-2.7-1.4",
|
||||
// Elapsed time (clock)
|
||||
elapsed: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zm0-14v5l3 2",
|
||||
// Files (folder)
|
||||
folder: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",
|
||||
} as const;
|
||||
|
||||
/** Iconized stat item: a symbol + a value, with the title giving the full meaning. */
|
||||
function StatChip({ icon, value, label }: { icon: string; value: string; label: string }) {
|
||||
return (
|
||||
<span
|
||||
title={label}
|
||||
className="flex shrink-0 items-center gap-1 font-mono text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d={icon} />
|
||||
</svg>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route id for a draft chat (`/chat/new`): the Session hasn't been persisted yet — the user may
|
||||
* still want to change the model or configure a key first. The actual Session is only created
|
||||
* once **the first message is sent** (once created, the model is locked into its meta).
|
||||
* Real session ids always start with `session-`, so there's no collision with this constant.
|
||||
*/
|
||||
export const DRAFT_SESSION_ID = "new";
|
||||
|
||||
export function ChatPage() {
|
||||
const navigate = useNavigate();
|
||||
const params = useParams<{ sessionId?: string }>();
|
||||
const { currency } = useTheme();
|
||||
const { currentProject, currentAgent, setCurrentAgentId, reloadAgents, agents } = useProject();
|
||||
const projectId = currentProject?.projectId ?? null;
|
||||
const agentId = currentAgent?.agentId ?? null;
|
||||
const {
|
||||
sessions,
|
||||
loading: sessionsLoading,
|
||||
reload: reloadSessions,
|
||||
add: addSession,
|
||||
replace,
|
||||
setStatus,
|
||||
setTitle,
|
||||
} = useSessions();
|
||||
|
||||
const [sessionCost, setSessionCost] = useState<number | null>(null);
|
||||
const [costUncosted, setCostUncosted] = useState(false);
|
||||
const [credentialGuide, setCredentialGuide] = useState(false);
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const [modeSaving, setModeSaving] = useState(false);
|
||||
const [models, setModels] = useState<ModelsResponse | null>(null);
|
||||
|
||||
const routeSessionId = params.sessionId ?? null;
|
||||
const filesPanel = useFilesPanel(routeSessionId);
|
||||
const draft = routeSessionId === DRAFT_SESSION_ID;
|
||||
const selected = draft ? null : (sessions.find((s) => s.sessionId === routeSessionId) ?? null);
|
||||
// Currently effective model (session state, the model reference comes from the Session DTO): model selection in draft state is handled internally by DraftView.
|
||||
const activeModelRef = selected
|
||||
? { provider: selected.provider, modelId: selected.modelId }
|
||||
: null;
|
||||
|
||||
// Tab title follows the current Session (refreshes in sync once the auto-generated title arrives).
|
||||
useDocumentTitle(selected ? (selected.title ?? S.chat.defaultSessionTitle) : S.nav.chat);
|
||||
|
||||
const stream = useSessionStream(
|
||||
selected?.sessionId ?? null,
|
||||
selected?.status ?? "idle",
|
||||
setTitle,
|
||||
// Sub-session registration notice (session_created is pushed over the parent session's channel): reload the list so it appears immediately.
|
||||
() => void reloadSessions(),
|
||||
);
|
||||
|
||||
// Chat input area draft: caches text, @ target, and selected skills keyed by sessionId; restored after navigating away and back or a refresh, discarded on successful send.
|
||||
const {
|
||||
initial: sessionDraft,
|
||||
onTextChange: onDraftTextChange,
|
||||
onHandoffTargetChange: onDraftHandoffChange,
|
||||
onSkillsChange: onDraftSkillsChange,
|
||||
discard: discardSessionDraft,
|
||||
} = useSessionDraft(selected?.sessionId ?? null);
|
||||
|
||||
// Current Agent follows the Session in the route (keeps the sidebar and stats aligned on deep
|
||||
// links / refresh). Only aligns when **the selected Session changes** — never put agentId in
|
||||
// the dependency array: otherwise, when switching from a "running session" to a new chat with
|
||||
// a different Agent, navigate and setCurrentAgentId aren't in the same batch — a transitional
|
||||
// render of "new agentId + old route (old session still selected)" would appear first, and
|
||||
// this effect would then flip the Agent back to the old session's Agent based on that,
|
||||
// causing the new chat to end up created on the old Agent.
|
||||
const selectedSessionId = selected?.sessionId ?? null;
|
||||
const selectedAgentId = selected?.agentId ?? null;
|
||||
useEffect(() => {
|
||||
if (selectedSessionId && selectedAgentId) setCurrentAgentId(selectedAgentId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedSessionId, selectedAgentId, setCurrentAgentId]);
|
||||
|
||||
// Skills installed on the session's Agent (candidates for the input area's skill dropdown):
|
||||
// fetched keyed on the session's Agent; on switch, cleared first (which also clears the input
|
||||
// area's selection) before refetching; a failed fetch is silently treated as no skills.
|
||||
// Clearing preserves reference identity (an already-empty array isn't replaced), matching
|
||||
// draft-view's convention.
|
||||
const [agentSkills, setAgentSkills] = useState<SkillMetadataItem[]>([]);
|
||||
useEffect(() => {
|
||||
setAgentSkills((prev) => (prev.length > 0 ? [] : prev));
|
||||
if (!projectId || !selectedAgentId) return;
|
||||
let cancelled = false;
|
||||
api
|
||||
.getAgentSkills(projectId, selectedAgentId)
|
||||
.then((res) => {
|
||||
if (!cancelled) setAgentSkills(res.skills);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, selectedAgentId]);
|
||||
|
||||
// Auto-select the most recent Session when the route doesn't select one; if there are none at all, fall back to draft state (instead of auto-creating one).
|
||||
useEffect(() => {
|
||||
if (sessionsLoading || draft) return;
|
||||
if (routeSessionId && sessions.some((s) => s.sessionId === routeSessionId)) return;
|
||||
const first = sessions[0];
|
||||
navigate(first ? `/chat/${first.sessionId}` : `/chat/${DRAFT_SESSION_ID}`, { replace: true });
|
||||
}, [sessionsLoading, draft, routeSessionId, sessions, navigate]);
|
||||
|
||||
// Sync task_state to the sidebar list badge.
|
||||
useEffect(() => {
|
||||
if (selected) setStatus(selected.sessionId, stream.taskState);
|
||||
}, [stream.taskState, selected, setStatus]);
|
||||
|
||||
// Task returns from running/compacting to idle: this turn may have spawned a sub-session or
|
||||
// auto-created a new Agent — reload the session and Agent lists so they appear in the sidebar
|
||||
// immediately (no manual refresh needed).
|
||||
const prevTaskRef = useRef(stream.taskState);
|
||||
useEffect(() => {
|
||||
const prev = prevTaskRef.current;
|
||||
prevTaskRef.current = stream.taskState;
|
||||
if (prev !== "idle" && stream.taskState === "idle") {
|
||||
void reloadSessions();
|
||||
void reloadAgents();
|
||||
}
|
||||
}, [stream.taskState, reloadSessions, reloadAgents]);
|
||||
|
||||
// Existence cache for message file cards (session-level): normalized relative path -> whether
|
||||
// it exists; while a lookup is in flight, the cache shares a single Promise, so a batch of
|
||||
// concurrent mounts only issues one files/stat call.
|
||||
const statCacheRef = useRef(new Map<string, boolean | Promise<boolean>>());
|
||||
|
||||
// Session switch: resets the cost and the file-card existence cache, avoiding stale data from
|
||||
// the previous Session (Files panel state resets itself keyed on sessionId inside use-files-panel).
|
||||
useEffect(() => {
|
||||
setSessionCost(null);
|
||||
setCostUncosted(false);
|
||||
statCacheRef.current = new Map();
|
||||
}, [routeSessionId]);
|
||||
|
||||
// Batched existence check (message file cards): merges only the cache-miss paths into a single
|
||||
// files/stat call, and the result lands in the session-level cache — during streaming, the
|
||||
// candidate set is re-checked on every change, and the cache ensures only new paths trigger a
|
||||
// request. On request failure, the placeholder is cleared (don't permanently cache a "couldn't
|
||||
// find" as "doesn't exist"), returned as not-existing this time, and re-checked on the next mount.
|
||||
const statFiles = useCallback(
|
||||
async (paths: string[]): Promise<ReadonlySet<string>> => {
|
||||
const sessionId = selected?.sessionId ?? null;
|
||||
const cache = statCacheRef.current;
|
||||
const misses = sessionId === null ? [] : paths.filter((p) => !cache.has(p));
|
||||
if (sessionId !== null && misses.length > 0) {
|
||||
const batch = api
|
||||
.statSessionFiles(sessionId, misses)
|
||||
.then((res) => new Set(res.existing))
|
||||
.catch(() => null);
|
||||
for (const p of misses) {
|
||||
cache.set(
|
||||
p,
|
||||
batch.then((existing) => {
|
||||
if (existing === null) {
|
||||
cache.delete(p);
|
||||
return false;
|
||||
}
|
||||
const exists = existing.has(p);
|
||||
cache.set(p, exists);
|
||||
return exists;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
const result = new Set<string>();
|
||||
await Promise.all(
|
||||
paths.map(async (p) => {
|
||||
const hit = cache.get(p);
|
||||
if (hit === true || (hit instanceof Promise && (await hit))) result.add(p);
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
},
|
||||
[selected?.sessionId],
|
||||
);
|
||||
|
||||
// Session's cumulative cost: refreshed on entry and every time it returns to idle (cost is computed by the server in real time based on current pricing).
|
||||
useEffect(() => {
|
||||
if (!projectId || !selected || stream.taskState !== "idle") return;
|
||||
let cancelled = false;
|
||||
api
|
||||
.getUsage(projectId, { groupBy: "session", agentId: selected.agentId })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
const row = res.groups.find((g) => g.key === selected.sessionId);
|
||||
setSessionCost(row?.cost ?? null);
|
||||
setCostUncosted(row?.hasUncosted ?? false);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, selected, stream.taskState]);
|
||||
|
||||
// Model config (context window + credential guide): fetched once per Project.
|
||||
//
|
||||
// The credential guide **only ever nags once per lifetime** (first entry after registration):
|
||||
// gated by the server prefs' credentialGuideSeen — previously it checked "default model has no
|
||||
// key" and popped up a dialog on every visit to the chat page, which was repeated nagging for
|
||||
// users who simply don't intend to configure a key / use environment variables instead.
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
let cancelled = false;
|
||||
setModels(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await api.getModels(projectId);
|
||||
if (cancelled) return;
|
||||
setModels(res);
|
||||
const { prefs } = await api.getPrefs();
|
||||
if (cancelled || prefs.credentialGuideSeen) return;
|
||||
const def = res.models.find((m) => sameModelRef(m, res.defaultModel));
|
||||
const missing = !res.defaultModel || !def?.credential?.apiKeyMasked;
|
||||
if (missing) setCredentialGuide(true);
|
||||
// Mark as "seen" regardless of whether the dialog actually popped up: only ever once.
|
||||
void api.putPrefs({ credentialGuideSeen: true }).catch(() => undefined);
|
||||
} catch {
|
||||
// A failed fetch doesn't affect the rest of the page.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// Self-heal: the server returned a new session_id, update the route and list (shared by tasks and compact).
|
||||
const syncHealedSessionId = useCallback(
|
||||
async (currentId: string, respondedId: string) => {
|
||||
if (respondedId === currentId) return;
|
||||
await reloadSessions();
|
||||
navigate(`/chat/${respondedId}`, { replace: true });
|
||||
},
|
||||
[reloadSessions, navigate],
|
||||
);
|
||||
|
||||
const onSend = useCallback(
|
||||
async (input: TaskInputPart[]): Promise<boolean> => {
|
||||
if (!selected) return false;
|
||||
try {
|
||||
const res = await api.postTask(selected.sessionId, { input });
|
||||
discardSessionDraft();
|
||||
await syncHealedSessionId(selected.sessionId, res.sessionId);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Returning false -> the input area keeps the draft, letting the user fix it and resend (the error copy includes the session model's upstream id).
|
||||
toastError(apiErrorText(e, { modelId: selected.modelId }));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[selected, discardSessionDraft, syncHealedSessionId],
|
||||
);
|
||||
|
||||
// @ handoff: doesn't use the current Session — creates a new chat for the @-mentioned agent
|
||||
// (approval mode carries over from the input area's current value; model/Workspace use the
|
||||
// creation defaults). The first input = a <handoff_from> source block (current agent / Session
|
||||
// / Workspace info) + the user's input and images with the @ mention stripped; jumps to the new
|
||||
// chat once sent.
|
||||
// Returns false on failure, keeping the draft so it can be resent (deletes the empty Session that never got its first message sent).
|
||||
const onHandoff = useCallback(
|
||||
async (target: AgentSummary, input: TaskInputPart[]): Promise<boolean> => {
|
||||
if (!projectId || !currentAgent || !selected) return false;
|
||||
const origin: TaskInputPart = {
|
||||
type: "text",
|
||||
text: handoffMessage({
|
||||
agentId: currentAgent.agentId,
|
||||
...(currentAgent.name !== undefined ? { agentName: currentAgent.name } : {}),
|
||||
sessionId: selected.sessionId,
|
||||
workspace: selected.workspace,
|
||||
...(selected.title !== undefined ? { sessionTitle: selected.title } : {}),
|
||||
}),
|
||||
};
|
||||
let createdId: string | null = null;
|
||||
try {
|
||||
const created = await api.createSession(projectId, target.agentId, {
|
||||
approvalMode: selected.approvalMode,
|
||||
});
|
||||
createdId = created.session.sessionId;
|
||||
const res = await api.postTask(createdId, { input: [origin, ...input] });
|
||||
addSession(created.session);
|
||||
// The text body has been handed off into the new chat: discard the current session's input draft along with it.
|
||||
discardSessionDraft();
|
||||
navigate(`/chat/${res.sessionId}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (createdId) void api.deleteSession(createdId).catch(() => undefined);
|
||||
// The new chat uses the project's default model (createSession doesn't specify a model reference), so the error copy's model context follows suit.
|
||||
toastError(
|
||||
apiErrorText(e, models?.defaultModel ? { modelId: models.defaultModel.modelId } : {}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[projectId, currentAgent, selected, addSession, discardSessionDraft, navigate, models],
|
||||
);
|
||||
|
||||
const onStop = useCallback(async () => {
|
||||
if (!selected) return;
|
||||
await api.postAbort(selected.sessionId).catch(() => undefined);
|
||||
}, [selected]);
|
||||
|
||||
const onApprove = useCallback(
|
||||
async (toolCallId: string, decision: "allow" | "deny", origin: string[]) => {
|
||||
if (!selected) return;
|
||||
// A decision clicked locally is marked "manual"; removed from the pending table keyed by the origin composite key.
|
||||
stream.markLocalDecision(toolCallId);
|
||||
const key = approvalKey(origin, toolCallId);
|
||||
try {
|
||||
await api.postApproval(selected.sessionId, toolCallId, { decision });
|
||||
stream.resolveApproval(key);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) stream.resolveApproval(key);
|
||||
}
|
||||
},
|
||||
[selected, stream],
|
||||
);
|
||||
|
||||
const onChangeApprovalMode = useCallback(
|
||||
(mode: ApprovalMode) => {
|
||||
if (!selected || modeSaving) return;
|
||||
setModeSaving(true);
|
||||
void api
|
||||
.patchSession(selected.sessionId, { approvalMode: mode })
|
||||
.then((res) => replace(res.session))
|
||||
.catch((e: unknown) => {
|
||||
toastError(apiErrorText(e));
|
||||
})
|
||||
.finally(() => setModeSaving(false));
|
||||
},
|
||||
[selected, modeSaving, replace],
|
||||
);
|
||||
|
||||
const onCompact = useCallback(async () => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
// compact shares get-or-resume-or-heal with tasks: it can likewise self-heal to a new session_id.
|
||||
const res = await api.postCompact(selected.sessionId);
|
||||
await syncHealedSessionId(selected.sessionId, res.sessionId);
|
||||
} catch (e) {
|
||||
toastError(apiErrorText(e, { modelId: selected.modelId }));
|
||||
}
|
||||
}, [selected, syncHealedSessionId]);
|
||||
|
||||
// "New Chat" = enter draft state: no Session is created until the first message is sent.
|
||||
const newChat = useCallback(() => {
|
||||
navigate(`/chat/${DRAFT_SESSION_ID}`);
|
||||
}, [navigate]);
|
||||
|
||||
// Real-time cost for this turn: converts the Task's bucketed usage using the session Model's
|
||||
// (paired reference) current pricing; null if no pricing is configured.
|
||||
const modelPricing = models?.models.find((m) => sameModelRef(m, activeModelRef))?.pricing;
|
||||
const ctx: StreamRenderContext = {
|
||||
pendingApprovals: stream.pendingApprovals,
|
||||
onApprove,
|
||||
origin: [],
|
||||
// Any non-idle state (running / compacting) counts as "not yet stopped": compaction can
|
||||
// happen mid-turn, and if only running were checked, the trailing group would flash
|
||||
// "finished running" during compaction before flipping back to "running".
|
||||
taskRunning: stream.taskState !== "idle",
|
||||
taskCost: (stats) => {
|
||||
if (!modelPricing) return null;
|
||||
const b = stats.tokensByBucket;
|
||||
return (
|
||||
(b.cacheRead * modelPricing.cacheRead +
|
||||
b.cacheWrite * modelPricing.cacheWrite +
|
||||
b.output * modelPricing.output) /
|
||||
1e6
|
||||
);
|
||||
},
|
||||
onOpenFile: (path) => {
|
||||
// The file card has already normalized the text path to a Workspace-relative path
|
||||
// (toWorkspaceRelative, including stripping absolute-path prefixes and converting Windows
|
||||
// separators), so this just opens the panel and navigates to it directly.
|
||||
filesPanel.setOpen(true);
|
||||
filesPanel.browsePath(path);
|
||||
},
|
||||
workspace: selected?.workspace ?? null,
|
||||
statFiles,
|
||||
};
|
||||
|
||||
if (!projectId || !agentId) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalTokens = stream.model.stats.sessionTotal + stream.model.stats.subagentTotal;
|
||||
const modelInfo = models?.models.find((m) => sameModelRef(m, activeModelRef));
|
||||
const contextWindow = modelInfo?.contextWindow;
|
||||
// Assumed supported by default: only models explicitly marked vision=false show a blocking hint when adding images.
|
||||
const vision = modelInfo?.vision !== false;
|
||||
const emptyChat =
|
||||
selected !== null && !stream.loading && !stream.error && stream.model.items.length === 0;
|
||||
|
||||
// Input area in session state: Agent / Workspace / Model are already locked by the Session
|
||||
// (the model selector isn't rendered; models is only used to look up the locked model's
|
||||
// provider logo and display name for a read-only display) — only approval mode can still be
|
||||
// changed (saved immediately on change).
|
||||
const input = selected && (
|
||||
<ChatInput
|
||||
status={stream.taskState}
|
||||
onSend={onSend}
|
||||
onStop={onStop}
|
||||
onCompact={onCompact}
|
||||
modelRef={activeModelRef}
|
||||
{...(models !== null ? { models: models.models } : {})}
|
||||
{...(contextWindow !== undefined ? { contextWindow } : {})}
|
||||
contextNow={stream.model.stats.contextNow}
|
||||
contextStale={stream.model.stats.contextStale}
|
||||
vision={vision}
|
||||
approvalMode={selected.approvalMode}
|
||||
onChangeApprovalMode={onChangeApprovalMode}
|
||||
modeSaving={modeSaving}
|
||||
autoFocus
|
||||
agents={agents}
|
||||
skills={agentSkills}
|
||||
{...(sessionDraft.skills && sessionDraft.skills.length > 0
|
||||
? { initialSkills: sessionDraft.skills }
|
||||
: {})}
|
||||
onSkillsChange={onDraftSkillsChange}
|
||||
onHandoff={onHandoff}
|
||||
initialText={sessionDraft.text ?? ""}
|
||||
onTextChange={onDraftTextChange}
|
||||
{...(sessionDraft.handoffAgentId
|
||||
? { initialHandoffTargetId: sessionDraft.handoffAgentId }
|
||||
: {})}
|
||||
onHandoffTargetChange={onDraftHandoffChange}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white dark:bg-gray-950">
|
||||
{/* Thin top toolbar */}
|
||||
{selected && (
|
||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-gray-200 px-3 py-2 md:px-4 dark:border-gray-800">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<h1 className="flex min-w-0 text-[15px] font-semibold">
|
||||
<Truncated text={selected.title ?? S.chat.defaultSessionTitle} />
|
||||
</h1>
|
||||
{/* Running indicator (placed to the right of the title); the compacting state is shown separately by the compaction banner within the message stream, not repeated here. */}
|
||||
{stream.taskState === "running" && (
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
|
||||
{S.chat.statusRunning}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats: Token / cost / elapsed time (icon + title for the full meaning) */}
|
||||
<div className="hidden items-center gap-3 sm:flex">
|
||||
<StatChip
|
||||
icon={STAT_ICONS.tokens}
|
||||
value={humanizeTokens(totalTokens)}
|
||||
label={`${S.chat.statTokens}(Token)`}
|
||||
/>
|
||||
{/* When there's no cost (the Model has no pricing configured), don't render this stat
|
||||
at all, rather than showing a "—" — that would take up space while saying
|
||||
nothing, only making people think the cost is zero or something's broken. */}
|
||||
{sessionCost != null && (
|
||||
<StatChip
|
||||
icon={STAT_ICONS.cost}
|
||||
value={`${formatMoney(sessionCost, currency)}${costUncosted ? " *" : ""}`}
|
||||
label={`${S.chat.statCost}(${currency})${costUncosted ? ` · ${S.usage.uncostedNote}` : ""}`}
|
||||
/>
|
||||
)}
|
||||
<StatChip
|
||||
icon={STAT_ICONS.elapsed}
|
||||
value={humanizeDuration(stream.model.stats.sessionElapsedMs)}
|
||||
label={S.chat.statElapsed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Files panel toggle: docks on the right of the chat instead of replacing it full-screen (use-files-panel.ts). */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={filesPanel.open}
|
||||
onClick={() => filesPanel.setOpen(!filesPanel.open)}
|
||||
title={S.chat.openWorkspace}
|
||||
className={`flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors duration-150 ${
|
||||
filesPanel.open
|
||||
? "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
||||
: "text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
aria-hidden
|
||||
>
|
||||
<path d={STAT_ICONS.folder} />
|
||||
</svg>
|
||||
{S.chat.openWorkspace}
|
||||
</button>
|
||||
|
||||
{/* Details popup: Model / Workspace / created time / stats */}
|
||||
<Dropdown
|
||||
open={infoOpen}
|
||||
setOpen={setInfoOpen}
|
||||
menuClass="right-0 top-full mt-1 w-80 max-w-[calc(100vw-1.5rem)] origin-top-right"
|
||||
button={
|
||||
<button
|
||||
type="button"
|
||||
title={S.chat.infoPanel}
|
||||
onClick={() => setInfoOpen(!infoOpen)}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 11v5m0-8h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 px-3.5 py-2.5 text-sm">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{S.chat.model}
|
||||
</p>
|
||||
{/* Paired display: upstream model_id + provider name (two separate fields on the Session DTO). */}
|
||||
<p className="truncate text-xs">
|
||||
<span className="font-mono">{selected.modelId}</span>
|
||||
<span className="ml-1.5 text-gray-400 dark:text-gray-500">
|
||||
{providerInfo(selected.provider)?.label ?? selected.provider}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{S.chat.workspace}
|
||||
</p>
|
||||
<p className="break-all font-mono text-xs leading-5">{selected.workspace}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{S.models.createdAt}
|
||||
</p>
|
||||
<p className="font-mono text-xs">{formatDateTime(selected.createdAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{S.chat.sessionStats}
|
||||
</p>
|
||||
{/* Same as above: if there's no cost, the whole item is omitted, not left as "Cost —". */}
|
||||
<p className="font-mono text-xs">
|
||||
{S.chat.statTokens} {humanizeTokens(totalTokens)}
|
||||
{sessionCost != null &&
|
||||
` · ${S.chat.statCost} ${formatMoney(sessionCost, currency)}`}{" "}
|
||||
· {S.chat.statElapsed} {humanizeDuration(stream.model.stats.sessionElapsedMs)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body: chat column + the docked Files panel on the right (message file cards jump to and locate a file in the tree via onOpenFile). */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{draft ? (
|
||||
// Draft state: DraftView's vertically centered input card + Agent / Workspace
|
||||
// selection panel; the Session is only created once the first message is sent. Keyed
|
||||
// by Project: switching Project remounts and switches to that Project's draft cache
|
||||
// (Agent selection happens inside the draft itself, so it's no longer part of the key).
|
||||
<DraftView key={`draft:${projectId}`} projectId={projectId} models={models} />
|
||||
) : (
|
||||
// Keyed by Session: the whole block does a light fade-in when switching sessions.
|
||||
<div
|
||||
key={selected?.sessionId ?? "empty"}
|
||||
className="anim-fade flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
{selected ? (
|
||||
stream.error ? (
|
||||
// History failed to load: show a clear error and a retry entry point, instead of staying on a misleading empty state.
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-6">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{S.chat.historyLoadFailed}:{stream.error}
|
||||
</p>
|
||||
<Button onClick={stream.retry}>{S.common.retry}</Button>
|
||||
</div>
|
||||
) : stream.loading ? (
|
||||
<div className="space-y-3 p-6">
|
||||
<Skeleton className="h-5 w-1/3" />
|
||||
<Skeleton className="h-5 w-2/3" />
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
</div>
|
||||
) : (
|
||||
// The empty state shares the same structure as the message stream (message
|
||||
// area + bottom input area): only the message area's content differs, and
|
||||
// ChatInput always mounts in the same JSX slot, so it isn't unmounted and
|
||||
// recreated when the first message arrives (preserving draft/focus).
|
||||
<>
|
||||
<div className="min-h-0 flex-1">
|
||||
{emptyChat ? (
|
||||
<div className="flex h-full items-center justify-center px-4">
|
||||
<p className="text-lg font-medium text-gray-400 dark:text-gray-500">
|
||||
{S.chat.emptyGreeting}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<MessageStream
|
||||
items={stream.model.items}
|
||||
version={stream.version}
|
||||
ctx={ctx}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 border-t border-gray-200 bg-white px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3 md:pb-3 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div className="mx-auto max-w-3xl">{input}</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : sessionsLoading ? (
|
||||
<div className="space-y-3 p-6">
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={S.chat.noSessions}
|
||||
action={<Button onClick={newChat}>{S.nav.newChat}</Button>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected && <FilesPanel session={selected} panel={filesPanel} />}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={credentialGuide}
|
||||
title={S.project.noCredentialTitle}
|
||||
onClose={() => setCredentialGuide(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setCredentialGuide(false)}>{S.project.later}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setCredentialGuide(false);
|
||||
navigate("/models");
|
||||
}}
|
||||
>
|
||||
{S.project.goToModels}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{S.project.noCredentialBody}</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Code block (visual reference: better-chatbot's pre-block): top bar = language label + copy
|
||||
* button, body highlighted via Shiki — inline CSS variables for both the github-light /
|
||||
* github-dark themes, with dark mode switched via style overrides under html.dark (see
|
||||
* styles.css), so theme switching doesn't require re-highlighting.
|
||||
* Shiki is dynamically imported (its own chunk, loaded only once the first code block appears);
|
||||
* before loading completes and for unknown languages, it falls back to an unhighlighted <pre>.
|
||||
*
|
||||
* highlight=false (while a message is streaming) skips highlighting and falls back to plain
|
||||
* text: every streaming frame re-renders the full code with a growing length, and re-tokenizing
|
||||
* the whole block each time would be O(n^2) main-thread cost, and an in-progress highlight can't
|
||||
* be canceled; once streaming settles, highlight flips true and a single final highlight is done.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { S } from "../../lib/strings";
|
||||
import { STAT_ICONS } from "../../lib/stat-icons";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
|
||||
const COPIED_MS = 1500;
|
||||
|
||||
/** Copy button in the header bar (same visual style as MessageMeta's copy button; always visible — the header bar has no hover-gated container). */
|
||||
function CopyCodeButton({ code }: { code: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = () => {
|
||||
void navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), COPIED_MS);
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={copied ? S.common.copied : S.chat.copyCode}
|
||||
aria-label={S.chat.copyCode}
|
||||
onClick={copy}
|
||||
className="rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
|
||||
>
|
||||
<GlyphIcon d={copied ? STAT_ICONS.check : STAT_ICONS.copy} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeBlock({
|
||||
language,
|
||||
code,
|
||||
highlight = true,
|
||||
}: {
|
||||
language: string;
|
||||
code: string;
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlight) {
|
||||
setHtml(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
void import("shiki")
|
||||
.then((shiki) =>
|
||||
shiki.codeToHtml(code, {
|
||||
lang: language || "text",
|
||||
themes: { light: "github-light", dark: "github-dark" },
|
||||
}),
|
||||
)
|
||||
.then((out) => {
|
||||
if (alive) setHtml(out);
|
||||
})
|
||||
.catch(() => {
|
||||
// Unknown language / failed to load: keep the unhighlighted fallback.
|
||||
if (alive) setHtml(null);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [code, language, highlight]);
|
||||
|
||||
return (
|
||||
<div className="code-block my-2 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-800">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-3 py-1 dark:border-gray-800 dark:bg-gray-900">
|
||||
<span className="font-mono text-xs lowercase text-gray-500 dark:text-gray-400">
|
||||
{language || "text"}
|
||||
</span>
|
||||
<CopyCodeButton code={code} />
|
||||
</div>
|
||||
<div className="overflow-x-auto bg-white text-[13px] leading-relaxed dark:bg-gray-950">
|
||||
{html ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
) : (
|
||||
<pre className="m-0 px-3 py-2.5 font-mono text-gray-800 dark:text-gray-200">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Compaction banner: shows "Compaction in
|
||||
* progress (summarize/discard)" between compaction_begin and compaction_end, then a one-line
|
||||
* message once done.
|
||||
* Doesn't show Tokens: the banner only needs to state whether compaction happened and whether it
|
||||
* succeeded. Compaction's cost lands in different places depending on when it occurs — compaction
|
||||
* that happens **mid-turn** counts toward that turn's stats line and cost; compaction **after a
|
||||
* turn ends** and manual compaction both go into the Session total (the Trace page lists
|
||||
* compaction turns separately); see the task-stats module comments.
|
||||
*/
|
||||
import { S } from "../../lib/strings";
|
||||
import type { CompactionItem } from "../../lib/omni/stream-model";
|
||||
|
||||
export function CompactionBanner({ item }: { item: CompactionItem }) {
|
||||
if (item.running) {
|
||||
return (
|
||||
<div className="anim-msg my-2 flex items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
|
||||
{S.chat.compactionRunning(item.mode)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const text =
|
||||
item.status === "completed"
|
||||
? S.chat.compactionDone(item.mode)
|
||||
: S.chat.compactionFailed(item.status ?? "failed");
|
||||
return <p className="my-1 font-mono text-xs text-gray-500 dark:text-gray-400">{text}</p>;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Draft cache (localStorage; one entry per "user × Project" for new conversations, one per
|
||||
* "user × Session" for existing sessions): reads validate field-by-field — storage may have been
|
||||
* corrupted externally, so bad fields are dropped rather than crashing the page; writes are
|
||||
* best-effort (silently fail under quota limits/private browsing). Pure functions + injectable
|
||||
* storage: vitest runs in a Node environment (no localStorage), so unit tests inject an in-memory
|
||||
* implementation.
|
||||
*
|
||||
* The key must include userId (#68): if the same browser logs into different accounts in
|
||||
* succession and the key only contains the Project/Session ID, the later user would recover the
|
||||
* previous user's text, Workspace, model selection, and @ target — a cross-account information leak.
|
||||
*/
|
||||
import type { ApprovalMode } from "@prismshadow/penguin-server/api";
|
||||
|
||||
const APPROVAL_MODES: ApprovalMode[] = ["always-ask", "read-only", "allow-all", "deny-all"];
|
||||
|
||||
export interface DraftCache {
|
||||
text?: string;
|
||||
agentId?: string;
|
||||
workspace?: string;
|
||||
approvalMode?: ApprovalMode;
|
||||
/**
|
||||
* The model selected in the draft (a paired reference; (provider, modelId) is the unique key):
|
||||
* load validates the object shape; the old string-typed modelId field is simply dropped
|
||||
* (product hasn't shipped, so no migration is done).
|
||||
*/
|
||||
modelRef?: { provider: string; modelId: string };
|
||||
/** The @ handoff target (chip) at the front of the input box: resolved again by id on restore, dropped if no longer valid. */
|
||||
handoffAgentId?: string;
|
||||
/**
|
||||
* Preselected skill names (written by the quick-invoke action on the Skill library page):
|
||||
* used as the initial selection when ChatInput mounts, then trimmed to remove names not in the
|
||||
* installed list once it's ready; cleared along with the entire draft on successful send.
|
||||
*/
|
||||
skills?: string[];
|
||||
}
|
||||
|
||||
/** Minimal storage interface (a subset of localStorage). */
|
||||
export interface DraftStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
/** Cache key for a new conversation (draft page): one per "user × Project". */
|
||||
export const draftKey = (userId: string, projectId: string): string =>
|
||||
`penguin.chatDraft.${userId}.${projectId}`;
|
||||
|
||||
/** Cache key for an existing session's input area: one per "user × Session" (only stores text and @ target; everything else is locked to the Session). */
|
||||
export const sessionDraftKey = (userId: string, sessionId: string): string =>
|
||||
`penguin.chatDraft.session.${userId}.${sessionId}`;
|
||||
|
||||
/** Parses and validates raw JSON field-by-field: null / malformed JSON / non-object / invalid fields are all dropped. */
|
||||
export function parseDraft(raw: string | null): DraftCache {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null) return {};
|
||||
const o = parsed as Record<string, unknown>;
|
||||
const out: DraftCache = {};
|
||||
if (typeof o.text === "string") out.text = o.text;
|
||||
if (typeof o.agentId === "string") out.agentId = o.agentId;
|
||||
if (typeof o.workspace === "string") out.workspace = o.workspace;
|
||||
// The model reference must be a paired { provider, modelId } object; the old string-typed
|
||||
// modelId and any malformed shape are dropped.
|
||||
if (typeof o.modelRef === "object" && o.modelRef !== null) {
|
||||
const r = o.modelRef as Record<string, unknown>;
|
||||
if (typeof r.provider === "string" && typeof r.modelId === "string") {
|
||||
out.modelRef = { provider: r.provider, modelId: r.modelId };
|
||||
}
|
||||
}
|
||||
if (typeof o.handoffAgentId === "string") out.handoffAgentId = o.handoffAgentId;
|
||||
if (Array.isArray(o.skills)) {
|
||||
// Elements are validated one by one: non-string items are filtered out; if empty after
|
||||
// filtering, the whole field is omitted.
|
||||
const skills = o.skills.filter((s): s is string => typeof s === "string");
|
||||
if (skills.length > 0) out.skills = skills;
|
||||
}
|
||||
if (
|
||||
typeof o.approvalMode === "string" &&
|
||||
APPROVAL_MODES.includes(o.approvalMode as ApprovalMode)
|
||||
) {
|
||||
out.approvalMode = o.approvalMode as ApprovalMode;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function loadDraft(key: string, storage: DraftStorage = localStorage): DraftCache {
|
||||
try {
|
||||
return parseDraft(storage.getItem(key));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function saveDraft(
|
||||
key: string,
|
||||
draft: DraftCache,
|
||||
storage: DraftStorage = localStorage,
|
||||
): void {
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify(draft));
|
||||
} catch {
|
||||
/* Write fails under quota limits/private browsing: draft cache is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDraft(key: string, storage: DraftStorage = localStorage): void {
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
/**
|
||||
* Draft view (/chat/new): the pre-persistence form of a new
|
||||
* conversation, before any Session exists. The input card sits vertically centered;
|
||||
* before sending, this is where Agent / Workspace / approval mode / Model are all
|
||||
* chosen in one place — two small dropdown pills sit right below the card (pill
|
||||
* buttons, styled after ChatGPT's project picker): Agent selection and Workspace
|
||||
* directory selection (the menu browses server-side directories, and the current
|
||||
* path can be edited directly); the model picker lives in the input card's bottom
|
||||
* toolbar, left of the send button (with a vendor logo). The Session is only
|
||||
* created when **the first message is sent**; once created, Agent / Workspace /
|
||||
* Model are locked in via meta, and only approval mode remains editable (in the
|
||||
* session-mode input area).
|
||||
*
|
||||
* Draft auto-cache (storage and validation in draft-cache.ts; keys are isolated by
|
||||
* "user × Project", #68): the four selections are saved as soon as they change;
|
||||
* body text is keystroke-frequent and deferred/coalesced (if there's an unsaved
|
||||
* change before unmount, one final write is flushed) — closing and returning to
|
||||
* the page resumes where you left off; the cache is cleared on successful send.
|
||||
* The sidebar group header "+" / menu "New conversation" explicitly specify an
|
||||
* Agent via route state (overriding the cached selection); a direct visit or
|
||||
* refresh falls back to the cache.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import type {
|
||||
AgentSummary,
|
||||
ApprovalMode,
|
||||
DirListResponse,
|
||||
ModelRefDto,
|
||||
ModelsResponse,
|
||||
SessionCreateRequest,
|
||||
SkillMetadataItem,
|
||||
TaskInputPart,
|
||||
} from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { apiErrorText } from "../../lib/api-error";
|
||||
import { useAuth } from "../../state/auth";
|
||||
import { agentDisplayName, useProject } from "../../state/project";
|
||||
import { useSessions } from "../../state/sessions";
|
||||
import { AgentAvatar } from "../../components/ui/agent-avatar";
|
||||
import { Chevron } from "../../components/ui/chevron";
|
||||
import { Dropdown } from "../../components/ui/dropdown";
|
||||
import { PenguinLogo } from "../../components/ui/penguin-logo";
|
||||
import { toastError } from "../../components/ui/toast";
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { clearDraft, draftKey, loadDraft, saveDraft } from "./draft-cache";
|
||||
import type { DraftCache } from "./draft-cache";
|
||||
import { handoffMessage } from "./agent-mentions";
|
||||
import { sameModelRef } from "../models/model-grouping";
|
||||
|
||||
/** Coalescing window for writing body text to the cache: keystrokes are frequent, so a short batch accumulates before persisting (option changes are still written immediately). */
|
||||
const DRAFT_SAVE_DEBOUNCE_MS = 300;
|
||||
|
||||
export function DraftView({
|
||||
projectId,
|
||||
models,
|
||||
}: {
|
||||
projectId: string;
|
||||
/** Project model config (already fetched by ChatPage): candidate list and default model. */
|
||||
models: ModelsResponse | null;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { agents, currentAgent, setCurrentAgentId } = useProject();
|
||||
const { add } = useSessions();
|
||||
// The draft key includes a user dimension (#68 cross-account leakage). RequireAuth
|
||||
// guarantees the user is logged in here; on the off chance there's no user (the
|
||||
// type allows null), it's better to disable caching entirely than to read/write a
|
||||
// key that isn't account-scoped.
|
||||
const userId = useAuth().user?.userId ?? null;
|
||||
|
||||
// The cache is read only once, on mount: the component remounts keyed by Project,
|
||||
// so switching Projects automatically switches to the corresponding draft; switching
|
||||
// accounts always goes through logout (clearing the user unmounts the whole route
|
||||
// tree), so logging back in is likewise a fresh mount.
|
||||
const [cached] = useState<DraftCache>(() =>
|
||||
userId ? loadDraft(draftKey(userId, projectId)) : {},
|
||||
);
|
||||
|
||||
const [agentId, setAgentId] = useState<string | null>(
|
||||
cached.agentId ?? currentAgent?.agentId ?? null,
|
||||
);
|
||||
const [workspace, setWorkspace] = useState(cached.workspace ?? "");
|
||||
const [approvalMode, setApprovalMode] = useState<ApprovalMode>(
|
||||
cached.approvalMode ?? "allow-all",
|
||||
);
|
||||
const [modelRef, setModelRef] = useState<ModelRefDto | null>(cached.modelRef ?? null);
|
||||
/** @-handoff target (chip): draft content just like the body text, cached alongside it (fed in via the ChatInput callback). */
|
||||
const [handoffAgentId, setHandoffAgentId] = useState<string | null>(
|
||||
cached.handoffAgentId ?? null,
|
||||
);
|
||||
const textRef = useRef(cached.text ?? "");
|
||||
/**
|
||||
* Selected skills (prefilled by "quick invoke" from the Skills page + checked in
|
||||
* the input area): passed to ChatInput as the initial selection via initialSkills
|
||||
* on mount, then written back through onSkillsChange and persisted immediately
|
||||
* (discrete clicks) — survives a refresh; cleared along with the whole draft on
|
||||
* successful send, kept on failure so it can be resent.
|
||||
*/
|
||||
const skillsRef = useRef<string[]>(cached.skills ?? []);
|
||||
|
||||
// Unified resolution of the Agent selection (a single effect, single writer):
|
||||
// explicit route state > current valid value (from cache / panel selection) >
|
||||
// default_agent > the first one. Explicit intent (sidebar group header "+" / menu
|
||||
// "New conversation") is applied only once per location.key — clicking "+" again
|
||||
// for the same Agent gets a new key and re-aligns, while the user's subsequent
|
||||
// reselection in the panel won't keep getting overridden. Merging this into one
|
||||
// effect is essential: splitting it into an "apply state" effect and a "fallback
|
||||
// on invalid value" effect would let the former write B in one render while the
|
||||
// latter, still judging by the stale closure's invalid value, writes the default
|
||||
// Agent and clobbers B.
|
||||
const stateAgentId = (location.state as { agentId?: string } | null)?.agentId;
|
||||
const appliedStateKey = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (agents.length === 0) return; // list not ready yet, nothing to validate against — wait for the next pass
|
||||
const valid = (id: string | null | undefined): id is string =>
|
||||
!!id && agents.some((a) => a.agentId === id);
|
||||
if (stateAgentId && appliedStateKey.current !== location.key) {
|
||||
appliedStateKey.current = location.key;
|
||||
if (valid(stateAgentId)) {
|
||||
setAgentId(stateAgentId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (valid(agentId)) return;
|
||||
setAgentId((agents.find((a) => a.agentId === "default_agent") ?? agents[0])?.agentId ?? null);
|
||||
}, [agents, agentId, location.key, stateAgentId]);
|
||||
|
||||
// Model fallback: once config is ready, if nothing is selected or the selection is no longer valid, fall back to the project default → the first model (always as a paired reference).
|
||||
useEffect(() => {
|
||||
if (!models) return;
|
||||
if (modelRef && models.models.some((m) => sameModelRef(m, modelRef))) return;
|
||||
const first = models.models[0];
|
||||
setModelRef(
|
||||
models.defaultModel ?? (first ? { provider: first.provider, modelId: first.modelId } : null),
|
||||
);
|
||||
}, [models, modelRef]);
|
||||
|
||||
// Skills installed on the currently selected Agent (candidates for the input
|
||||
// area's skills dropdown): switching Agents first clears the list (which also
|
||||
// clears the selection in the input area), then refetches; a fetch failure is
|
||||
// silently treated as no skills. Clearing preserves the reference when already
|
||||
// empty (doesn't swap in a new array): swapping the reference on the very first
|
||||
// mount render would trigger ChatInput's pruning effect and wrongly clear the
|
||||
// quick-invoke preselection.
|
||||
const [agentSkills, setAgentSkills] = useState<SkillMetadataItem[]>([]);
|
||||
useEffect(() => {
|
||||
setAgentSkills((prev) => (prev.length > 0 ? [] : prev));
|
||||
if (!agentId) return;
|
||||
let cancelled = false;
|
||||
api
|
||||
.getAgentSkills(projectId, agentId)
|
||||
.then((res) => {
|
||||
if (!cancelled) setAgentSkills(res.skills);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, agentId]);
|
||||
|
||||
// —— Auto-cache ——
|
||||
// Options (Agent / Workspace / approval mode / Model) are discrete clicks: written
|
||||
// immediately on change; body text is keystroke-frequent: debounced trailing write,
|
||||
// with a final flush on unmount if there's an unsaved change.
|
||||
const saveTimer = useRef<number | null>(null);
|
||||
const cancelPendingSave = useCallback(() => {
|
||||
if (saveTimer.current !== null) {
|
||||
window.clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persistNow = useCallback(() => {
|
||||
cancelPendingSave();
|
||||
if (!userId) return;
|
||||
const data: DraftCache = { text: textRef.current, workspace, approvalMode };
|
||||
if (agentId) data.agentId = agentId;
|
||||
if (modelRef) data.modelRef = modelRef;
|
||||
if (handoffAgentId) data.handoffAgentId = handoffAgentId;
|
||||
if (skillsRef.current.length > 0) data.skills = skillsRef.current;
|
||||
saveDraft(draftKey(userId, projectId), data);
|
||||
}, [
|
||||
cancelPendingSave,
|
||||
userId,
|
||||
projectId,
|
||||
agentId,
|
||||
workspace,
|
||||
approvalMode,
|
||||
modelRef,
|
||||
handoffAgentId,
|
||||
]);
|
||||
|
||||
// The timer and unmount cleanup read persistNow via a ref to always get the **latest version**: a stale closure would write back outdated options.
|
||||
const persistRef = useRef(persistNow);
|
||||
useEffect(() => {
|
||||
persistRef.current = persistNow;
|
||||
// Write immediately on option change (also writes once on mount, idempotently).
|
||||
persistNow();
|
||||
}, [persistNow]);
|
||||
|
||||
const onTextChange = useCallback(
|
||||
(text: string) => {
|
||||
textRef.current = text;
|
||||
cancelPendingSave();
|
||||
saveTimer.current = window.setTimeout(() => {
|
||||
saveTimer.current = null;
|
||||
persistRef.current();
|
||||
}, DRAFT_SAVE_DEBOUNCE_MS);
|
||||
},
|
||||
[cancelPendingSave],
|
||||
);
|
||||
|
||||
/** Skill checklist change: writes back to the ref and persists immediately (discrete click, same convention as Agent/Model and other options). */
|
||||
const onSkillsChange = useCallback((names: string[]) => {
|
||||
skillsRef.current = names;
|
||||
persistRef.current();
|
||||
}, []);
|
||||
|
||||
// Unmount: if there's still unsaved body text, flush it (so a route change/page switch doesn't lose the last few keystrokes).
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (saveTimer.current !== null) {
|
||||
window.clearTimeout(saveTimer.current);
|
||||
persistRef.current();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** Discard the draft after a successful send: first cancels the pending save timer, otherwise it would write the just-cleared draft back. */
|
||||
const discardDraft = useCallback(() => {
|
||||
cancelPendingSave();
|
||||
// Clear the preselected skills too: any subsequent write (e.g. the unmount flush) must not resurrect a selection that's already been sent.
|
||||
skillsRef.current = [];
|
||||
if (userId) clearDraft(draftKey(userId, projectId));
|
||||
}, [cancelPendingSave, userId, projectId]);
|
||||
|
||||
const selectAgent = (a: AgentSummary) => {
|
||||
setAgentId(a.agentId);
|
||||
// Follow through to the global current Agent: keeps the sidebar memory and stats convention consistent.
|
||||
setCurrentAgentId(a.agentId);
|
||||
};
|
||||
|
||||
// First message sent: only now is the Session created (Agent / Workspace / Model / approval
|
||||
// mode are all locked in together), then the route jumps once sent; returns false on any
|
||||
// failure, so the input area keeps the draft and can resend.
|
||||
const onSend = useCallback(
|
||||
async (input: TaskInputPart[]): Promise<boolean> => {
|
||||
if (!agentId) return false;
|
||||
let createdId: string | null = null;
|
||||
try {
|
||||
const body: SessionCreateRequest = { approvalMode };
|
||||
// Model reference is submitted as a pair (provider + modelId; falls back to the Project default when not set).
|
||||
if (modelRef) {
|
||||
body.modelId = modelRef.modelId;
|
||||
body.provider = modelRef.provider;
|
||||
}
|
||||
if (workspace.trim()) body.workspace = workspace.trim();
|
||||
const created = await api.createSession(projectId, agentId, body);
|
||||
createdId = created.session.sessionId;
|
||||
const res = await api.postTask(createdId, { input });
|
||||
add(created.session);
|
||||
discardDraft();
|
||||
navigate(`/chat/${res.sessionId}`, { replace: true });
|
||||
return true;
|
||||
} catch (e) {
|
||||
// The Session was created but the first message failed to send (postTask failed): delete
|
||||
// this empty Session, otherwise every resend attempt would create another one, piling up
|
||||
// empty sessions with no messages in the sidebar (best-effort cleanup).
|
||||
if (createdId) void api.deleteSession(createdId).catch(() => undefined);
|
||||
toastError(apiErrorText(e, modelRef ? { modelId: modelRef.modelId } : {}));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[projectId, agentId, approvalMode, modelRef, workspace, add, discardDraft, navigate],
|
||||
);
|
||||
|
||||
// @ handoff: opens a new chat for the @-mentioned agent (approval mode carries over from the
|
||||
// draft's current value; model/Workspace use the creation defaults), first input =
|
||||
// <handoff_from> source block + the text and images with the @ mention stripped.
|
||||
const selectedAgent = agents.find((a) => a.agentId === agentId) ?? null;
|
||||
const onHandoff = useCallback(
|
||||
async (target: AgentSummary, input: TaskInputPart[]): Promise<boolean> => {
|
||||
if (!selectedAgent) return false;
|
||||
const origin: TaskInputPart = {
|
||||
type: "text",
|
||||
text: handoffMessage({
|
||||
agentId: selectedAgent.agentId,
|
||||
...(selectedAgent.name !== undefined ? { agentName: selectedAgent.name } : {}),
|
||||
}),
|
||||
};
|
||||
let createdId: string | null = null;
|
||||
try {
|
||||
const created = await api.createSession(projectId, target.agentId, { approvalMode });
|
||||
createdId = created.session.sessionId;
|
||||
const res = await api.postTask(createdId, { input: [origin, ...input] });
|
||||
add(created.session);
|
||||
discardDraft();
|
||||
navigate(`/chat/${res.sessionId}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (createdId) void api.deleteSession(createdId).catch(() => undefined);
|
||||
// The new chat uses the project's default model (createSession doesn't specify a model reference), so the error copy's model context follows suit.
|
||||
toastError(
|
||||
apiErrorText(e, models?.defaultModel ? { modelId: models.defaultModel.modelId } : {}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[projectId, selectedAgent, approvalMode, add, discardDraft, navigate, models],
|
||||
);
|
||||
|
||||
// Capability info for the currently selected model (vision/context window) switches instantly with the selection (matched by paired reference).
|
||||
const modelInfo = models?.models.find((m) => sameModelRef(m, modelRef));
|
||||
const contextWindow = modelInfo?.contextWindow;
|
||||
const vision = modelInfo?.vision !== false;
|
||||
|
||||
return (
|
||||
<div className="anim-fade flex min-h-0 flex-1 flex-col overflow-y-auto px-3 md:px-4">
|
||||
{/*
|
||||
* Vertical layout: two symmetric flex-1 spaces above and below, so **the input card lands
|
||||
* exactly on the viewport's centerline**; the brand area sits at the bottom of the upper
|
||||
* space (right above the input card). When the viewport is too short, the upper space
|
||||
* shrinks to the brand area's height, the lower space compresses to padding, and the whole
|
||||
* page falls back to natural document-flow scrolling (without clipping the top).
|
||||
*/}
|
||||
<div className="flex flex-1 flex-col justify-end pt-6">
|
||||
{/* Large brand logo + brand name + subtitle (e2e tests identify the draft page by this
|
||||
heading). The asset is square-cropped and the graphic already has a bit of built-in
|
||||
padding, so a small margin is enough to sit visually close to the title. */}
|
||||
<div className="mb-10 text-center">
|
||||
<PenguinLogo className="mx-auto mb-1 h-36 w-36 rounded-3xl" />
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100">
|
||||
{S.appName}
|
||||
</h1>
|
||||
<p className="mt-2 text-base text-gray-400 dark:text-gray-500">{S.chat.draftSubtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<ChatInput
|
||||
status="idle"
|
||||
onSend={onSend}
|
||||
onStop={async () => undefined}
|
||||
onCompact={async () => undefined}
|
||||
modelRef={modelRef}
|
||||
models={models?.models ?? []}
|
||||
onChangeModel={setModelRef}
|
||||
{...(models?.defaultModel !== undefined ? { defaultModel: models.defaultModel } : {})}
|
||||
{...(contextWindow !== undefined ? { contextWindow } : {})}
|
||||
contextNow={0}
|
||||
vision={vision}
|
||||
approvalMode={approvalMode}
|
||||
onChangeApprovalMode={setApprovalMode}
|
||||
modeSaving={false}
|
||||
autoFocus
|
||||
agents={agents}
|
||||
skills={agentSkills}
|
||||
{...(cached.skills && cached.skills.length > 0 ? { initialSkills: cached.skills } : {})}
|
||||
onSkillsChange={onSkillsChange}
|
||||
onHandoff={onHandoff}
|
||||
initialText={cached.text ?? ""}
|
||||
onTextChange={onTextChange}
|
||||
{...(cached.handoffAgentId ? { initialHandoffTargetId: cached.handoffAgentId } : {})}
|
||||
onHandoffTargetChange={setHandoffAgentId}
|
||||
/>
|
||||
|
||||
{/* Ownership selection right below the card (small pill dropdowns, styled after ChatGPT's project picker button) */}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<AgentSelect agents={agents} selected={selectedAgent} onSelect={selectAgent} />
|
||||
<WorkspaceSelect projectId={projectId} workspace={workspace} onChange={setWorkspace} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lower symmetric space (compresses to padding at minimum height) */}
|
||||
<div className="flex-1 pb-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared style for pill trigger buttons (ChatGPT project button style: small rounded pill + icon + short name + collapse arrow). */
|
||||
const pillClass =
|
||||
"flex max-w-64 items-center gap-1.5 rounded-full border border-gray-300 bg-white py-1 pl-1.5 pr-2 " +
|
||||
"text-xs text-gray-600 transition-colors duration-150 hover:bg-gray-50 hover:text-gray-900 " +
|
||||
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100";
|
||||
|
||||
/** Agent selection (pill dropdown): avatar + name, menu opens downward with an internal scroll cap. */
|
||||
function AgentSelect({
|
||||
agents,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
agents: AgentSummary[];
|
||||
selected: AgentSummary | null;
|
||||
onSelect: (agent: AgentSummary) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dropdown
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
menuClass="left-0 top-full mt-1 w-72 max-w-[calc(100vw-2rem)] origin-top-left"
|
||||
button={
|
||||
<button
|
||||
type="button"
|
||||
title={S.chat.chooseAgent}
|
||||
aria-label={S.chat.chooseAgent}
|
||||
onClick={() => setOpen(!open)}
|
||||
className={pillClass}
|
||||
>
|
||||
{selected ? (
|
||||
<AgentAvatar id={selected.agentId} size={16} className="shrink-0 rounded" />
|
||||
) : null}
|
||||
<span className="min-w-0 truncate">
|
||||
{selected ? agentDisplayName(selected) : S.common.loading}
|
||||
</span>
|
||||
<Chevron open={open} size={12} className="shrink-0 text-gray-400" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{agents.length === 0 && (
|
||||
<p className="px-3 py-1.5 text-xs text-gray-400">{S.common.loading}</p>
|
||||
)}
|
||||
{agents.map((a) => {
|
||||
const active = a.agentId === selected?.agentId;
|
||||
return (
|
||||
<button
|
||||
key={a.agentId}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => {
|
||||
onSelect(a);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<AgentAvatar id={a.agentId} size={20} className="shrink-0 rounded" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={`block truncate text-xs ${
|
||||
active
|
||||
? "font-medium text-gray-900 dark:text-gray-100"
|
||||
: "text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{agentDisplayName(a)}
|
||||
</span>
|
||||
{a.description && (
|
||||
<span className="block truncate text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{a.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="w-4 shrink-0 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
{active ? "✓" : ""}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace selection (pill dropdown): the button shows the selected directory name (empty =
|
||||
* auto temporary directory). The menu browses server-side directories: **the current path can be
|
||||
* edited directly** at the top (Enter/blur commits it, an invalid directory toasts and reverts
|
||||
* to the previous path), the list omits hidden directories, and the hint text sits at the bottom
|
||||
* of the menu; only loads on first expand.
|
||||
*/
|
||||
function WorkspaceSelect({
|
||||
projectId,
|
||||
workspace,
|
||||
onChange,
|
||||
}: {
|
||||
projectId: string;
|
||||
workspace: string;
|
||||
onChange: (path: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const browsedRef = useRef(false);
|
||||
|
||||
const [dir, setDir] = useState<DirListResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Edit draft for the path row: synced with the browsing position, reverts on a failed commit. */
|
||||
const [pathDraft, setPathDraft] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setPathDraft(dir?.path ?? "");
|
||||
}, [dir]);
|
||||
|
||||
/** Browses level by level (clicking a directory/parent); an empty string means the server's home directory (the default starting point). */
|
||||
const loadDir = useCallback(
|
||||
(abs: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.listDirs(projectId, abs)
|
||||
.then(setDir)
|
||||
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError))
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const toggle = () => {
|
||||
const next = !open;
|
||||
setOpen(next);
|
||||
// Only loads on first expand: an already-filled absolute path is used as the starting point, otherwise the server falls back to the home directory.
|
||||
if (next && !browsedRef.current) {
|
||||
browsedRef.current = true;
|
||||
const ws = workspace.trim();
|
||||
loadDir(ws.startsWith("/") ? ws : "");
|
||||
}
|
||||
};
|
||||
|
||||
/** Commits the edited path: navigates to it if it exists, otherwise toasts and reverts to the current browsing position. */
|
||||
const commitPathEdit = async () => {
|
||||
const p = pathDraft.trim();
|
||||
if (!p || p === dir?.path) {
|
||||
setPathDraft(dir?.path ?? "");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setDir(await api.listDirs(projectId, p));
|
||||
} catch {
|
||||
toastError(S.chat.workspaceDirInvalid);
|
||||
setPathDraft(dir?.path ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
const trimmed = workspace.trim();
|
||||
// Pill short name: the last segment of the directory name (root gives "/"); shows "auto temp directory" when empty.
|
||||
const label = trimmed ? (trimmed.split("/").filter(Boolean).pop() ?? "/") : S.chat.workspaceAuto;
|
||||
const parentPath = dir?.parent ?? null;
|
||||
// Hidden directories (starting with .) are excluded from the list.
|
||||
const entries = (dir?.entries ?? []).filter((e) => !e.name.startsWith("."));
|
||||
return (
|
||||
<Dropdown
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
menuClass="left-0 top-full mt-1 w-80 max-w-[calc(100vw-2rem)] origin-top-left"
|
||||
button={
|
||||
<button
|
||||
type="button"
|
||||
title={trimmed ? `${S.chat.workspace}:${trimmed}` : S.chat.workspaceHint}
|
||||
aria-label={S.chat.workspace}
|
||||
onClick={toggle}
|
||||
className={pillClass}
|
||||
>
|
||||
{/* Folder icon */}
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="ml-0.5 shrink-0 text-gray-400"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className={`min-w-0 truncate ${trimmed ? "font-mono" : ""}`}>{label}</span>
|
||||
<Chevron open={open} size={12} className="shrink-0 text-gray-400" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-1.5 px-2.5 pb-2.5 pt-2">
|
||||
<div className="rounded-md border border-gray-200 dark:border-gray-800">
|
||||
{/* Current path (editable: Enter/blur commits, Escape discards) + "Use this directory" (closes the menu once selected) */}
|
||||
<div className="flex items-center gap-1.5 border-b border-gray-100 px-1.5 py-1 dark:border-gray-800">
|
||||
<input
|
||||
value={pathDraft}
|
||||
placeholder="…"
|
||||
aria-label={S.chat.workspace}
|
||||
onChange={(e) => setPathDraft(e.target.value)}
|
||||
onBlur={() => void commitPathEdit()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
void commitPathEdit();
|
||||
} else if (e.key === "Escape") {
|
||||
// Discard the edit: only reverts the draft; Escape bubbles up to Dropdown, which closes the menu.
|
||||
setPathDraft(dir?.path ?? "");
|
||||
}
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 font-mono text-xs text-gray-600 focus:border-gray-300 focus:outline-none dark:text-gray-300 dark:focus:border-gray-600"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dir}
|
||||
onClick={() => {
|
||||
if (!dir) return;
|
||||
onChange(dir.path);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="shrink-0 rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-700 transition-colors duration-150 hover:bg-gray-100 disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
{S.chat.workspaceUseThis}
|
||||
</button>
|
||||
</div>
|
||||
{/* Directory list (excludes hidden directories) */}
|
||||
<ul className="max-h-40 overflow-y-auto py-1">
|
||||
{parentPath !== null && (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadDir(parentPath)}
|
||||
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left font-mono text-xs text-gray-500 transition-colors duration-150 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
↰ {S.chat.workspaceUp}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadDir(entry.path)}
|
||||
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left font-mono text-xs text-gray-700 transition-colors duration-150 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="shrink-0 text-gray-400"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{dir && entries.length === 0 && (
|
||||
<li className="px-2.5 py-1.5 text-xs text-gray-400">{S.chat.workspaceNoSubdirs}</li>
|
||||
)}
|
||||
{loading && <li className="px-2.5 py-1.5 text-xs text-gray-400">{S.common.loading}</li>}
|
||||
{/* Load failure (e.g. the cached starting directory was deleted): provide "retry" to fall back to the home directory, avoiding getting stuck in an error state. */}
|
||||
{error && (
|
||||
<li className="flex items-center justify-between gap-2 px-2.5 py-1.5 text-xs text-red-500">
|
||||
<span className="min-w-0 flex-1 truncate" title={error}>
|
||||
{error}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadDir("")}
|
||||
className="shrink-0 rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-700 transition-colors duration-150 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
{S.common.retry}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{/* When a directory has been specified, offer a one-click way back to the auto temp directory */}
|
||||
{trimmed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs text-gray-500 underline decoration-gray-300 underline-offset-2 transition-colors duration-150 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
{S.chat.workspaceClear}
|
||||
</button>
|
||||
)}
|
||||
{/* Hint text (bottom of the menu) */}
|
||||
<p className="px-0.5 text-xs leading-5 text-gray-400 dark:text-gray-500">
|
||||
{S.chat.workspaceHint}
|
||||
</p>
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Files panel: on desktop (≥1024px, see isDocked in use-files-panel.ts) it docks to the right
|
||||
* of the chat with a drag-to-resize edge; on narrower viewports it becomes a bottom Sheet
|
||||
* (snaps to half for browsing / full for preview, gesture-draggable) so the vertical layout
|
||||
* keeps the chat transcript above it visible. Content is a single WorkspaceBrowser directory-tree
|
||||
* view; clicking a file chip in a message navigates the tree via openRequest (use-files-panel.ts).
|
||||
*/
|
||||
import type { SessionInfo } from "@prismshadow/penguin-server/api";
|
||||
import { S } from "../../lib/strings";
|
||||
import { Sheet } from "../../components/ui/sheet";
|
||||
import { WorkspaceBrowser } from "./workspace-browser";
|
||||
import type { FilesPanelState } from "./use-files-panel";
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" aria-hidden>
|
||||
<path d="M2 2l10 10M12 2L2 12" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilesPanel({ session, panel }: { session: SessionInfo; panel: FilesPanelState }) {
|
||||
if (!panel.isDocked) {
|
||||
return (
|
||||
<Sheet
|
||||
open={panel.open}
|
||||
snap={panel.sheetSnap}
|
||||
onSnapChange={panel.setSheetSnap}
|
||||
onClose={() => panel.setOpen(false)}
|
||||
title={S.files.title}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1">
|
||||
<WorkspaceBrowser
|
||||
session={session}
|
||||
openRequest={panel.openRequest}
|
||||
active={panel.open}
|
||||
// Entering preview from list view: bump the snap point up to full (preview needs the space)
|
||||
onPreviewOpen={() => panel.setSheetSnap("full")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{panel.open && (
|
||||
<div
|
||||
onMouseDown={panel.startResize}
|
||||
onDoubleClick={panel.resetWidth}
|
||||
title={S.files.resizeHandle}
|
||||
className={`w-1.5 shrink-0 cursor-col-resize transition-colors duration-150 hover:bg-brand-300/50 dark:hover:bg-brand-700/40 ${
|
||||
panel.resizing ? "bg-brand-400/60" : "bg-transparent"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={panel.panelRef}
|
||||
style={{ width: panel.open ? panel.width : 0 }}
|
||||
// Use inert rather than unmounting when closed: the width transition needs the node to
|
||||
// stay mounted, and inert removes content collapsed to 0 width from the tab order and
|
||||
// accessibility tree, so keyboard users can't Tab into a close button that's visually gone.
|
||||
inert={!panel.open}
|
||||
// Freeze the panel's pointer events while dragging to resize: a preview iframe
|
||||
// (HTML/PDF) is a separate document, and mousemove over it gets swallowed instead of
|
||||
// reaching us, so the width stops tracking the cursor; pointer-events-none lets events
|
||||
// pass through.
|
||||
//
|
||||
// relative: the clipping window acts as its own containing block. Content is fixed at
|
||||
// the target width (see below), and when closed the whole block sits outside the
|
||||
// viewport's right edge — if an absolute descendant (e.g. the upload button's sr-only
|
||||
// input) anchored to the nearest initial containing block instead, it would bypass this
|
||||
// overflow-hidden and stretch the **document** wide, making a horizontal scrollbar
|
||||
// appear out of nowhere.
|
||||
className={`relative flex min-h-0 shrink-0 flex-col overflow-hidden border-l border-gray-200 dark:border-gray-800 ${
|
||||
panel.resizing ? "pointer-events-none" : "transition-[width] duration-200"
|
||||
}`}
|
||||
>
|
||||
{/* Content is fixed at the target width; the outer element is only a clipping window:
|
||||
during the open/close animation the outer element passes through intermediate
|
||||
widths, and if the content resized along with it, the text would get squeezed into
|
||||
a column frame-by-frame before expanding back out. With a fixed width, the content
|
||||
behaves as a rigid body that slides in and out past the clipping edge with zero
|
||||
reflow. While dragging to resize, both values stay in sync, so this isn't affected. */}
|
||||
<div style={{ width: panel.width }} className="flex h-full min-h-0 flex-col">
|
||||
{/* Title row for docked state (the Sheet state has its own title bar via Sheet, no duplication needed) */}
|
||||
<div className="flex shrink-0 items-center gap-1 px-3 pt-2">
|
||||
<h4 className="min-w-0 flex-1 truncate text-sm font-semibold">{S.files.title}</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => panel.setOpen(false)}
|
||||
title={S.common.close}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<WorkspaceBrowser
|
||||
session={session}
|
||||
openRequest={panel.openRequest}
|
||||
active={panel.open}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Handoff notice for a chat created via @ delegation: the source block (<handoff_from>) isn't
|
||||
* shown verbatim, it's collapsed into a single line reading "Handed off from <agent>'s chat";
|
||||
* when there's a source Session, the whole line is clickable and jumps back to the original chat
|
||||
* (the source Session's title goes into the title hover tooltip, taking no space in the body).
|
||||
*/
|
||||
import { useNavigate } from "react-router";
|
||||
import { S } from "../../lib/strings";
|
||||
import type { HandoffOrigin } from "./agent-mentions";
|
||||
|
||||
/** Display name of the source agent: `displayName (@id)` when the display name differs from the id, otherwise just `@id`. */
|
||||
function agentLabel(origin: HandoffOrigin): string {
|
||||
return origin.agentName && origin.agentName !== origin.agentId
|
||||
? `${origin.agentName} (@${origin.agentId})`
|
||||
: `@${origin.agentId}`;
|
||||
}
|
||||
|
||||
export function HandoffBanner({ origin }: { origin: HandoffOrigin }) {
|
||||
const navigate = useNavigate();
|
||||
const text = S.chat.handoffFrom(agentLabel(origin));
|
||||
const frame =
|
||||
"anim-msg my-2 flex w-fit items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400";
|
||||
// A handoff initiated from draft state has no source Session: only the origin is shown, with nowhere to jump to.
|
||||
if (!origin.sessionId) return <p className={frame}>{text}</p>;
|
||||
const sessionId = origin.sessionId;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={S.chat.handoffBack(origin.sessionTitle)}
|
||||
onClick={() => navigate(`/chat/${sessionId}`)}
|
||||
className={`${frame} transition-colors hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200`}
|
||||
>
|
||||
{text}
|
||||
<span aria-hidden className="text-gray-400 dark:text-gray-500">
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Live duration (inline display on running thinking/tool cards): ticks every second. sinceMs
|
||||
* comes from the server-side message timestamp and may drift from the local clock; negative
|
||||
* values are shown as 0; a pulsing ellipsis is shown when missing.
|
||||
* `offsetMs` is the already-settled duration of a prior segment (e.g. a tool call's argument
|
||||
* generation phase), added on top of the live segment as it ticks.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { humanizeDuration } from "../../lib/format";
|
||||
|
||||
export function LiveDuration({ sinceMs, offsetMs = 0 }: { sinceMs?: number; offsetMs?: number }) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
if (sinceMs === undefined) return <span className="animate-pulse">…</span>;
|
||||
return <>{humanizeDuration(Math.max(0, offsetMs) + Math.max(0, now - sinceMs))}</>;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Message-level file summary card (visual reference: Codex's "files changed" card): extracts
|
||||
* file paths from inline code in the assistant's text (heuristic via isFilePathLike), normalizes
|
||||
* them to Workspace-relative paths, confirms they actually exist via files/stat, and aggregates
|
||||
* them into a unified card at the end of the message — a light-background single-line header bar
|
||||
* ("N files") + a list of file rows inside the card; each row's path is split into a "faded
|
||||
* directory / bold filename" pair, with a "Preview" label at the end of the row making the action
|
||||
* explicit, and clicking the whole row navigates to the Files panel preview for that relative
|
||||
* path via onOpenFile. Collapses when there are more than 3 rows.
|
||||
* The whole card doesn't render until the stat result comes back (to avoid a flash-then-disappear);
|
||||
* it also doesn't render if none of the candidates exist — the heuristic extraction inevitably
|
||||
* matches error message examples, external paths, and other strings that can't actually be
|
||||
* opened, so this card is only responsible for "if you click it, it really opens".
|
||||
* Doesn't include diff stats — the only built-in tool is exec_command (file writes happen inside
|
||||
* the shell), so the protocol has no structured edit signal; this is just an aggregated view of
|
||||
* text references, hence the neutral "N files" title.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { S } from "../../lib/strings";
|
||||
import { isFilePathLike, toWorkspaceRelative } from "../../lib/file-path";
|
||||
|
||||
const MAX_VISIBLE = 3;
|
||||
|
||||
/** Extracts file paths from inline code in raw Markdown (deduplicated, preserving order of appearance). */
|
||||
export function extractFilePaths(markdown: string): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const m of markdown.matchAll(/`([^`\n]+)`/g)) {
|
||||
const text = m[1]!.trim();
|
||||
if (!isFilePathLike(text) || seen.has(text)) continue;
|
||||
seen.add(text);
|
||||
out.push(text);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Path split into segments: directory prefix faded, filename bold (Codex-style). The directory
|
||||
* segment has shrink-[9999] and collapses first, the filename segment truncates only after —
|
||||
* both segments are truncatable, so the row never overflows its container on a narrow panel. */
|
||||
function PathLabel({ path }: { path: string }) {
|
||||
const slash = path.lastIndexOf("/");
|
||||
const dir = slash >= 0 ? path.slice(0, slash + 1) : "";
|
||||
const name = slash >= 0 ? path.slice(slash + 1) : path;
|
||||
return (
|
||||
<span className="flex min-w-0 items-baseline font-mono text-sm">
|
||||
{dir && (
|
||||
<span className="min-w-0 shrink-[9999] truncate text-gray-400 dark:text-gray-500">
|
||||
{dir}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 truncate font-semibold text-gray-800 dark:text-gray-100">
|
||||
{name}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageFilesCard({
|
||||
text,
|
||||
workspace,
|
||||
statFiles,
|
||||
onOpenFile,
|
||||
}: {
|
||||
/** Raw Markdown text of the assistant message. */
|
||||
text: string;
|
||||
/** Absolute Workspace path of the current Session (used to normalize absolute paths found in the text). */
|
||||
workspace: string | null;
|
||||
/** Batched existence check (provided by chat-page, with a session-level cache): returns the set of relative paths confirmed to exist. */
|
||||
statFiles: (paths: string[]) => Promise<ReadonlySet<string>>;
|
||||
onOpenFile: (path: string) => void;
|
||||
}) {
|
||||
// Candidates: lexical extraction -> normalize to Workspace-relative paths (discard ones that
|
||||
// can't be normalized) -> deduplicate keyed by the normalized result (used for both display and onOpenFile).
|
||||
const candidates = useMemo(() => {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of extractFilePaths(text)) {
|
||||
const rel = toWorkspaceRelative(raw, workspace);
|
||||
if (rel === null || seen.has(rel)) continue;
|
||||
seen.add(rel);
|
||||
out.push(rel);
|
||||
}
|
||||
return out;
|
||||
}, [text, workspace]);
|
||||
|
||||
// null = stat hasn't returned yet (don't render, to avoid a flash-then-disappear); once returned, only list paths confirmed to exist.
|
||||
const [paths, setPaths] = useState<string[] | null>(null);
|
||||
useEffect(() => {
|
||||
setPaths(null);
|
||||
if (candidates.length === 0) return;
|
||||
let cancelled = false;
|
||||
statFiles(candidates)
|
||||
.then((existing) => {
|
||||
if (!cancelled) setPaths(candidates.filter((p) => existing.has(p)));
|
||||
})
|
||||
// Query failure keeps it unrendered: the error has already been cleared by the cache layer, so the next mount will re-query.
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [candidates, statFiles]);
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
if (paths === null || paths.length === 0) return null;
|
||||
|
||||
const visible = expanded ? paths : paths.slice(0, MAX_VISIBLE);
|
||||
const hidden = paths.length - visible.length;
|
||||
|
||||
return (
|
||||
<div className="anim-msg my-3 overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
{/* Header bar: a single line of "icon + N files", light background to distinguish it from
|
||||
the rows (Codex-style). No card-level action entry point — each row already has its own
|
||||
"Preview", adding one to the header would just duplicate the row action. */}
|
||||
<div className="flex items-center gap-2 border-b border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800/60 dark:bg-gray-800/40">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
aria-hidden
|
||||
className="shrink-0 text-gray-400"
|
||||
>
|
||||
<path d="M6 3h8l4 4v14H6zM14 3v4h4" />
|
||||
</svg>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{S.chat.filesInMessage(paths.length)}
|
||||
</span>
|
||||
</div>
|
||||
{/* File row list: thin dividers within the card, clicking a row opens it in the Files panel preview. */}
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-800/60">
|
||||
{visible.map((path) => (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
title={path}
|
||||
onClick={() => onOpenFile(path)}
|
||||
className="group flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
aria-hidden
|
||||
className="shrink-0 text-gray-400"
|
||||
>
|
||||
<path d="M6 3h8l4 4v14H6zM14 3v4h4" />
|
||||
</svg>
|
||||
<PathLabel path={path} />
|
||||
<span className="min-w-0 flex-1" />
|
||||
{/* Right-aligned "click to preview" text: makes the row action explicit (a trailing
|
||||
chevron would read as expand/collapse instead). The whole row is already a
|
||||
<button> (buttons can't nest), so this uses a span, and the click still targets
|
||||
the whole row. */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="shrink-0 text-xs text-gray-400 transition-colors duration-150 group-hover:text-gray-600 dark:text-gray-500 dark:group-hover:text-gray-300"
|
||||
>
|
||||
{S.chat.openPreview}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{(hidden > 0 || expanded) && paths.length > MAX_VISIBLE && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-2 text-left text-xs text-gray-500 transition-colors duration-150 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
{expanded ? S.chat.showLess : S.chat.showMoreFiles(hidden)}
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
className={`transition-transform duration-200 ${expanded ? "rotate-180" : ""}`}
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Rendering dispatch for a single view-model item: user prompts are
|
||||
* right-aligned brand bubbles (including image thumbnails), thinking collapsible blocks, text
|
||||
* streamed as Markdown, tool cards, subagent cards, compaction banners, abort markers, and Task
|
||||
* stats lines. Items have a light entrance animation.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { S } from "../../lib/strings";
|
||||
import { useLocale } from "../../state/locale";
|
||||
import { formatMessageTime } from "../../lib/format";
|
||||
import { STAT_ICONS } from "../../lib/stat-icons";
|
||||
import { splitImageAttachments } from "../../lib/attachments";
|
||||
import type { ChatItem } from "../../lib/omni/stream-model";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import { ZoomableImage } from "../../components/ui/image-zoom";
|
||||
import { MessageFilesCard } from "./message-files-card";
|
||||
import { ThinkingBlock } from "./thinking-block";
|
||||
import { ToolCallCard } from "./tool-call-card";
|
||||
import { SubagentCard } from "./subagent-card";
|
||||
import { CompactionBanner } from "./compaction-banner";
|
||||
import { HandoffBanner } from "./handoff-banner";
|
||||
import { ScheduledBanner } from "./scheduled-banner";
|
||||
import { SkillsBanner } from "./skills-banner";
|
||||
import { parseHandoffMessage, parseScheduledMessage } from "./agent-mentions";
|
||||
import { parseSkillsMessage } from "./skill-use";
|
||||
import { TaskStatsLine } from "./task-stats-line";
|
||||
import type { StreamRenderContext } from "./message-stream";
|
||||
|
||||
/** Duration the "Copied" tooltip stays visible (milliseconds). */
|
||||
const COPIED_MS = 1200;
|
||||
|
||||
/**
|
||||
* Message footer: timestamp + copy. **Invisible but takes up space by default** (`opacity-0`
|
||||
* rather than `hidden`) — it surfaces on hovering the message list, and because the space is
|
||||
* always reserved, surfacing it never pushes content below it down (using `hidden` would cause
|
||||
* every item to jitter). Keyboard users can also reveal it via `focus-within` (otherwise the copy
|
||||
* button would be focusable but never visible).
|
||||
*
|
||||
* When `text` is omitted, only the timestamp is shown, no copy button — there's no clear meaning
|
||||
* to copying an image message.
|
||||
*/
|
||||
function MessageMeta({
|
||||
atMs,
|
||||
text,
|
||||
align = "left",
|
||||
}: {
|
||||
atMs?: number;
|
||||
text?: string;
|
||||
align?: "left" | "right";
|
||||
}) {
|
||||
const { locale } = useLocale();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = () => {
|
||||
if (text === undefined) return;
|
||||
void navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), COPIED_MS);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`flex h-5 items-center gap-2 opacity-0 transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100 ${
|
||||
align === "right" ? "justify-end" : "justify-start"
|
||||
}`}
|
||||
>
|
||||
{atMs !== undefined && (
|
||||
<span className="text-[11px] text-gray-400">{formatMessageTime(atMs, locale)}</span>
|
||||
)}
|
||||
{text !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
title={copied ? S.chat.copied : S.chat.copyMessage}
|
||||
aria-label={S.chat.copyMessage}
|
||||
onClick={copy}
|
||||
className="rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
|
||||
>
|
||||
<GlyphIcon d={copied ? STAT_ICONS.check : STAT_ICONS.copy} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderContext }) {
|
||||
switch (item.kind) {
|
||||
case "user_text": {
|
||||
// Source block for a chat created via @ handoff: collapsed into a single-line handoff notice (the raw text isn't shown), clickable to jump back to the original chat.
|
||||
const handoff = parseHandoffMessage(item.text);
|
||||
if (handoff) return <HandoffBanner origin={handoff} />;
|
||||
// Source block for a scheduled-task trigger: collapsed into a single-line notice, with the task's prompt body rendered as usual (verbatim on the Trace page).
|
||||
const scheduled = parseScheduledMessage(item.text);
|
||||
// Source block for a skill invocation: parsing continues on scheduled's remaining body
|
||||
// (handoff -> scheduled -> skills, blocks stripped in a chain); a match collapses into a
|
||||
// "using skill" banner, with the body rendered as usual.
|
||||
const afterScheduled = scheduled ? scheduled.rest : item.text;
|
||||
const skills = parseSkillsMessage(afterScheduled);
|
||||
// Attachment row restoration: for models that don't support images, input images are
|
||||
// written to disk as a path row; this pulls that out at render time and shows the actual
|
||||
// image. Mirrors the vision-model path (user_text + user_image as separate messages) in
|
||||
// shape: one bubble for the text, one bubble per image, styled the same as user_image.
|
||||
const { text, images } = splitImageAttachments(skills ? skills.rest : afterScheduled);
|
||||
return (
|
||||
<>
|
||||
{scheduled && <ScheduledBanner origin={scheduled.origin} />}
|
||||
{skills && <SkillsBanner names={skills.skills} />}
|
||||
{text && (
|
||||
<div className="anim-msg group my-4 flex flex-col items-end">
|
||||
<div className="max-w-[88%] rounded-lg bg-gray-100 px-4 py-2.5 md:max-w-[75%] dark:bg-gray-800">
|
||||
{/* break-words: long unbroken strings like attachment paths/long URLs wrap within the bubble on narrow (mobile) screens instead of overflowing. */}
|
||||
<p className="whitespace-pre-wrap break-words text-base leading-relaxed text-gray-900 dark:text-gray-100">
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
<MessageMeta
|
||||
{...(item.atMs !== undefined ? { atMs: item.atMs } : {})}
|
||||
text={text}
|
||||
align="right"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{images.map((src, i) => (
|
||||
<div key={i} className="anim-msg group my-4 flex flex-col items-end">
|
||||
<div className="max-w-[88%] rounded-lg bg-gray-100 p-1.5 md:max-w-[75%] dark:bg-gray-800">
|
||||
<ZoomableImage
|
||||
src={src}
|
||||
alt={S.chat.imageAlt}
|
||||
className="max-h-48 max-w-full rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<MessageMeta
|
||||
{...(item.atMs !== undefined ? { atMs: item.atMs } : {})}
|
||||
align="right"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "user_image":
|
||||
return (
|
||||
<div className="anim-msg group my-4 flex flex-col items-end">
|
||||
<div className="max-w-[88%] rounded-lg bg-gray-100 p-1.5 md:max-w-[75%] dark:bg-gray-800">
|
||||
<ZoomableImage
|
||||
src={item.imageUrl}
|
||||
alt={S.chat.imageAlt}
|
||||
className="max-h-48 max-w-full rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<MessageMeta {...(item.atMs !== undefined ? { atMs: item.atMs } : {})} align="right" />
|
||||
</div>
|
||||
);
|
||||
case "assistant_text":
|
||||
// Doesn't attach MessageMeta: this turn's reply timestamp and copy both belong to the
|
||||
// stats line right below it (TaskStatsLine) — that line already serves as this reply's
|
||||
// footer, and rendering both would pop up two copy buttons in the same spot. The stats
|
||||
// line's copy grabs **all** of this turn's assistant text (see collectTaskAssistant),
|
||||
// which is more useful than copying segment by segment.
|
||||
return (
|
||||
<div className="md-body anim-msg my-3 text-base leading-relaxed text-gray-800 dark:text-gray-100">
|
||||
{/* Re-renders the accumulated text directly while streaming (a key point of the contract implementation). */}
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{item.text}</ReactMarkdown>
|
||||
{item.streaming && <span className="animate-pulse text-gray-400">▌</span>}
|
||||
{item.stopReason && item.stopReason !== "completed" && (
|
||||
<span className="ml-1 font-mono text-xs text-gray-400">[{item.stopReason}]</span>
|
||||
)}
|
||||
{/* File summary card (Codex-style): aggregates file references in the text once streaming ends (lists only ones confirmed to exist). */}
|
||||
{!item.streaming && ctx.onOpenFile && ctx.statFiles && (
|
||||
<MessageFilesCard
|
||||
text={item.text}
|
||||
workspace={ctx.workspace ?? null}
|
||||
statFiles={ctx.statFiles}
|
||||
onOpenFile={ctx.onOpenFile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "thinking":
|
||||
return <ThinkingBlock item={item} />;
|
||||
case "tool_call":
|
||||
return <ToolCallCard item={item} ctx={ctx} />;
|
||||
case "subagent":
|
||||
return (
|
||||
<div className="anim-msg my-2">
|
||||
<SubagentCard sessionId={item.sessionId} model={item.model} running={false} ctx={ctx} />
|
||||
</div>
|
||||
);
|
||||
case "abort":
|
||||
return (
|
||||
<p className="anim-msg my-1 font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
{S.chat.aborted(item.reason)}
|
||||
</p>
|
||||
);
|
||||
case "reconnect":
|
||||
return (
|
||||
<p className="anim-msg my-1 font-mono text-xs text-amber-600 dark:text-amber-500">
|
||||
{S.chat.reconnect(
|
||||
item.status,
|
||||
item.gaveUp ? "gaveUp" : item.retrying ? "retried" : "waiting",
|
||||
item.attempt,
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
case "compaction":
|
||||
return <CompactionBanner item={item} />;
|
||||
case "task_stats":
|
||||
return (
|
||||
<TaskStatsLine
|
||||
stats={item.stats}
|
||||
assistantText={item.assistantText}
|
||||
cost={item.stats ? (ctx.taskCost?.(item.stats) ?? null) : null}
|
||||
{...(item.atMs !== undefined ? { atMs: item.atMs } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Message stream container: renders the ChatItem list; auto-sticks to the
|
||||
* bottom while streaming — an upward swipe immediately pauses follow, and scrolling back near
|
||||
* the bottom resumes it (see stream-follow.ts for the exact rule).
|
||||
* StreamRenderContext threads the pending-approval map and approval callback down to tool
|
||||
* cards at any nesting depth.
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { S } from "../../lib/strings";
|
||||
import type { ChatItem } from "../../lib/omni/stream-model";
|
||||
import type { TaskStats } from "../../lib/omni/task-stats";
|
||||
import type { PendingApproval } from "./use-session-stream";
|
||||
import { EmptyState } from "../../components/ui/empty-state";
|
||||
import { MessageItem } from "./message-item";
|
||||
import { WorkGroup, isWorkItem } from "./work-group";
|
||||
import { createStreamFollow } from "./stream-follow";
|
||||
import type { StreamFollow } from "./stream-follow";
|
||||
|
||||
/** Context passed down to nested rendering (pending approvals + approval submit callback + current origin chain). */
|
||||
export interface StreamRenderContext {
|
||||
/** approvalKey(origin, toolCallId) → pending approval (disambiguates by origin when parent/child session tool_call_ids collide). */
|
||||
pendingApprovals: ReadonlyMap<string, PendingApproval>;
|
||||
onApprove: (toolCallId: string, decision: "allow" | "deny", origin: string[]) => Promise<void>;
|
||||
/** Origin chain at the current render level (empty array for the main session; subagent cards append one level each). */
|
||||
origin: string[];
|
||||
/**
|
||||
* Whether the Task at this level is still running (taskState for the main session, its own
|
||||
* running state for a subagent card). The "Reasoning & Tools" group uses this to decide: as
|
||||
* long as the model might still call another tool, the trailing group always shows "Running".
|
||||
*/
|
||||
taskRunning: boolean;
|
||||
/** Converts this turn's stats into cost (USD) using the current Model pricing; returns null when no price is configured (cost is hidden). */
|
||||
taskCost?: (stats: TaskStats) => number | null;
|
||||
/** Opens the Files panel and navigates to this file (triggered by clicking the file-summary card at the end of a message; takes a Workspace-relative path); the card doesn't render if this isn't wired up. */
|
||||
onOpenFile?: (path: string) => void;
|
||||
/** Absolute Workspace path of the current Session (used by the file-summary card to normalize body paths). */
|
||||
workspace?: string | null;
|
||||
/** Batch file-existence check (with session-level caching); the card doesn't render if this isn't wired up. */
|
||||
statFiles?: (paths: string[]) => Promise<ReadonlySet<string>>;
|
||||
}
|
||||
|
||||
/** Pure list rendering (reused recursively inside subagent cards): consecutive thinking + tool-call items are aggregated into one "Reasoning & Tools" group. */
|
||||
export function MessageItems({ items, ctx }: { items: ChatItem[]; ctx: StreamRenderContext }) {
|
||||
// Split into segments first — group (consecutive thinking + tool calls) or single (everything
|
||||
// else) — then render. WorkGroup needs to know whether it's the last segment (current turn
|
||||
// still in progress) to decide its default expanded/collapsed state.
|
||||
type Seg = { type: "group"; items: ChatItem[] } | { type: "single"; item: ChatItem };
|
||||
const segs: Seg[] = [];
|
||||
let run: ChatItem[] = [];
|
||||
const flushRun = () => {
|
||||
if (run.length > 0) {
|
||||
segs.push({ type: "group", items: run });
|
||||
run = [];
|
||||
}
|
||||
};
|
||||
for (const item of items) {
|
||||
if (isWorkItem(item)) run.push(item);
|
||||
else {
|
||||
flushRun();
|
||||
segs.push({ type: "single", item });
|
||||
}
|
||||
}
|
||||
flushRun();
|
||||
|
||||
const renderSeg = (seg: Seg, i: number): ReactNode =>
|
||||
seg.type === "group" ? (
|
||||
<WorkGroup
|
||||
key={`wg-${seg.items[0]!.id}`}
|
||||
items={seg.items}
|
||||
ctx={ctx}
|
||||
isLast={i === segs.length - 1}
|
||||
/>
|
||||
) : (
|
||||
<MessageItem key={seg.item.id} item={seg.item} ctx={ctx} />
|
||||
);
|
||||
|
||||
/**
|
||||
* Each turn's AI-side content (reply, reasoning-and-tools group, compaction banner, ...) plus
|
||||
* its trailing stats row shares a single group container. The stats row is that turn's footer
|
||||
* (timestamp and copy button live there, and the whole row is transparent by default), so
|
||||
* hovering **any** content within the turn must be able to reveal it.
|
||||
*
|
||||
* The stats row can't simply be paired with the adjacent assistant reply — if compaction
|
||||
* happens mid-turn, the compaction banner gets inserted between them (items:
|
||||
* assistant_text → compaction → task_stats), breaking the pairing instantly. The stats row
|
||||
* would then become a strip that's both invisible and unhoverable (the element's own `group`
|
||||
* class doesn't apply to itself: group-hover is a descendant selector).
|
||||
*
|
||||
* The container is created as soon as the turn's **first** segment appears, keyed by that
|
||||
* segment's id, and the key never changes afterward. If we waited for the stats row to arrive
|
||||
* before moving already-rendered groups into a new container, React would treat it as a
|
||||
* position change — unmount and remount — and the WorkGroup and tool-card expanded states
|
||||
* (each backed by its own internal useState) would reset instantly: any tool details the user
|
||||
* had manually expanded would collapse the moment the reply finishes.
|
||||
*
|
||||
* User messages never enter this container: they have their own footer, and including them
|
||||
* would make hovering a user message also light up the AI's stats row.
|
||||
*/
|
||||
const nodes: ReactNode[] = [];
|
||||
let turn: { seg: Seg; i: number }[] = [];
|
||||
const flushTurn = () => {
|
||||
if (turn.length === 0) return;
|
||||
const first = turn[0]!.seg;
|
||||
const key = first.type === "group" ? first.items[0]!.id : first.item.id;
|
||||
const body = turn;
|
||||
turn = [];
|
||||
nodes.push(
|
||||
<div key={`turn-${key}`} className="group">
|
||||
{body.map((t) => renderSeg(t.seg, t.i))}
|
||||
</div>,
|
||||
);
|
||||
};
|
||||
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
const seg = segs[i]!;
|
||||
const isUserMsg =
|
||||
seg.type === "single" && (seg.item.kind === "user_text" || seg.item.kind === "user_image");
|
||||
if (isUserMsg) {
|
||||
flushTurn();
|
||||
nodes.push(renderSeg(seg, i));
|
||||
continue;
|
||||
}
|
||||
turn.push({ seg, i });
|
||||
// Stats row = end of this turn; seal the container here. Anything after belongs to the next turn.
|
||||
if (seg.type === "single" && seg.item.kind === "task_stats") flushTurn();
|
||||
}
|
||||
flushTurn(); // Turn not yet finished (stats row hasn't arrived): container already exists, subsequent content is appended directly
|
||||
|
||||
return <>{nodes}</>;
|
||||
}
|
||||
|
||||
export function MessageStream({
|
||||
items,
|
||||
version,
|
||||
ctx,
|
||||
}: {
|
||||
items: ChatItem[];
|
||||
/** View-model version number (a repaint signal for in-place updates that also drives auto-scroll). */
|
||||
version: number;
|
||||
ctx: StreamRenderContext;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// An upward-swipe intent immediately exits auto-follow; scrolling back near the bottom resumes it — see stream-follow.ts (#75) for the exact rule.
|
||||
const followRef = useRef<StreamFollow | null>(null);
|
||||
const follow = (followRef.current ??= createStreamFollow());
|
||||
|
||||
const onScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
follow.scrolled({
|
||||
scrollTop: el.scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el && follow.stick) el.scrollTop = el.scrollHeight;
|
||||
}, [version, follow]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
onWheel={(e) => follow.wheel(e.deltaY)}
|
||||
onTouchStart={(e) => {
|
||||
const t = e.touches[0];
|
||||
if (t) follow.touchStart(t.clientY);
|
||||
}}
|
||||
onTouchMove={(e) => {
|
||||
const t = e.touches[0];
|
||||
if (t) follow.touchMove(t.clientY);
|
||||
}}
|
||||
onTouchEnd={() => follow.touchEnd()}
|
||||
className="anim-fade h-full overflow-y-auto px-4 py-4 md:px-6"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{items.length === 0 ? (
|
||||
<EmptyState title={S.chat.emptyStream} />
|
||||
) : (
|
||||
<MessageItems items={items} ctx={ctx} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Origin hint for a scheduled-task-triggered message: the origin block (<scheduled_task>) is
|
||||
* not rendered verbatim on screen; it collapses into one line reading "Triggered by scheduled
|
||||
* task '<name>'" plus a localized trigger timestamp (a static display with no navigation — task
|
||||
* management lives on the Agent settings page's "Scheduled Tasks" tab). The task prompt body
|
||||
* itself is rendered as usual by the caller.
|
||||
*/
|
||||
import { S } from "../../lib/strings";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import type { ScheduledOrigin } from "./agent-mentions";
|
||||
|
||||
export function ScheduledBanner({ origin }: { origin: ScheduledOrigin }) {
|
||||
return (
|
||||
<p className="anim-msg my-2 flex w-fit items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400">
|
||||
{S.chat.scheduledFrom(origin.name)}
|
||||
{origin.firedAt && (
|
||||
<span className="text-gray-400 dark:text-gray-500">{formatDateTime(origin.firedAt)}</span>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Skill invocation for the chat input area (pure logic, shared by chat-input / message-item /
|
||||
* the Skill library page, and unit tests).
|
||||
* The `<use_skills>` block is a globally agreed-upon format (shared by frontend, backend, and the
|
||||
* core prompt template):
|
||||
*
|
||||
* <use_skills>
|
||||
* skills: name1, name2
|
||||
* </use_skills>
|
||||
* (blank line) body text…
|
||||
*
|
||||
* - `buildSkillsMessage`: prepends the source block to the body when selected skills are
|
||||
* non-empty; returns the body unchanged for an empty list;
|
||||
* - `parseSkillsMessage`: only recognizes a block at **the start of the message**, returning the
|
||||
* skill names and the remaining body (the message stream collapses the raw block into a
|
||||
* "Skill used" banner and renders the body normally; the Trace page shows it as-is);
|
||||
* - `localizedText` / `skillSlashItems`: pure assembly of UI-language text lookup and slash
|
||||
* skill command items.
|
||||
*/
|
||||
import type { SkillMetadataItem } from "@prismshadow/penguin-server/api";
|
||||
|
||||
/** Book icon (24×24 line path): shared across skill-related UI (nav items are inlined separately in sidebar / app-layout). */
|
||||
export const BOOK_ICON =
|
||||
"M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z";
|
||||
|
||||
/** Generates a message body with a `<use_skills>` block: an empty list omits the block; when there's no body text, only the block is returned (no trailing blank line). */
|
||||
export function buildSkillsMessage(names: string[], text: string): string {
|
||||
if (names.length === 0) return text;
|
||||
const block = `<use_skills>\nskills: ${names.join(", ")}\n</use_skills>`;
|
||||
return text ? `${block}\n\n${text}` : block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse parse of `buildSkillsMessage`: when the message **starts with** a `<use_skills>`
|
||||
* block, returns the skill names and the remaining body; otherwise returns null (a block
|
||||
* appearing mid-body is treated as plain text and not parsed). The `skills:` line is split by
|
||||
* comma and whitespace-trimmed; an empty list is treated as not a source block.
|
||||
*/
|
||||
export function parseSkillsMessage(text: string): { skills: string[]; rest: string } | null {
|
||||
const m = /^<use_skills>\nskills: ([^\n]+)\n<\/use_skills>/.exec(text);
|
||||
if (!m) return null;
|
||||
const skills = m[1]!
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
if (skills.length === 0) return null;
|
||||
return { skills, rest: text.slice(m[0].length).replace(/^\n+/, "") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks copy based on the UI language: uses the Chinese value when locale is zh and one is
|
||||
* provided, otherwise falls back to English. Shared by the skill library page's group
|
||||
* name/group description/skill description, the input area's chip hint, and the slash skill
|
||||
* item's description (the library metadata's Chinese fields are all optional).
|
||||
*/
|
||||
export function localizedText(locale: "zh" | "en", enText: string, zhText?: string): string {
|
||||
return locale === "zh" && zhText ? zhText : enText;
|
||||
}
|
||||
|
||||
/** Minimal shape needed to pick a short description's copy (SkillMetadataItem is a superset). */
|
||||
export interface SkillDescLike {
|
||||
description: string;
|
||||
shortDescription?: string;
|
||||
shortDescriptionZh?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the **short description** for the UI language (falls back to the full description if
|
||||
* missing): language takes priority over length — zh tries shortDescriptionZh ->
|
||||
* shortDescription -> description in order; en tries shortDescription -> description. Shared by
|
||||
* the skill library card, the composer's skills dropdown, and the slash description.
|
||||
*/
|
||||
export function localizedShortText(locale: "zh" | "en", s: SkillDescLike): string {
|
||||
if (locale === "zh") {
|
||||
return s.shortDescriptionZh || s.shortDescription || s.description;
|
||||
}
|
||||
return s.shortDescription || s.description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search filter for the skills dropdown (pure function, shared by chat-input's SkillSelect and
|
||||
* unit tests): case-insensitive substring match against the skill name and localized
|
||||
* description; an empty query (including whitespace-only) returns the full list.
|
||||
*/
|
||||
export function filterSkills(
|
||||
skills: SkillMetadataItem[],
|
||||
locale: "zh" | "en",
|
||||
query: string,
|
||||
): SkillMetadataItem[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return skills;
|
||||
// Match target matches what's displayed: name + localized short text (zh can match the Chinese short description, en is always English).
|
||||
return skills.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(q) || localizedShortText(locale, s).toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
/** Skill command item for the slash menu (`/<skill_name>` toggles that skill's selection). */
|
||||
export interface SkillSlashItem {
|
||||
/** Skill name (the run action toggles selection by name). */
|
||||
name: string;
|
||||
/** Menu command: `/<skill_name>` (slash filtering matches on this prefix). */
|
||||
cmd: string;
|
||||
/** Menu description: the skill's short description first, falling back to the full description if missing (per the UI language; truncated by the menu's own styling if too long). */
|
||||
desc: string;
|
||||
}
|
||||
|
||||
/** Assembles installed skills into slash command items (pure function, shared by chat-input's commands and unit tests). */
|
||||
export function skillSlashItems(
|
||||
skills: SkillMetadataItem[],
|
||||
locale: "zh" | "en",
|
||||
): SkillSlashItem[] {
|
||||
return skills.map((s) => ({
|
||||
name: s.name,
|
||||
cmd: `/${s.name}`,
|
||||
desc: localizedShortText(locale, s),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Source notice for a skill invocation: the `<use_skills>` block at the start of a message isn't
|
||||
* shown verbatim, it's collapsed into a single line reading "Using skills: <names>" (book icon +
|
||||
* static text, no navigation — skill management lives on the skill library page); the body text
|
||||
* after the block is rendered as usual by the caller.
|
||||
*/
|
||||
import { S } from "../../lib/strings";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import { BOOK_ICON } from "./skill-use";
|
||||
|
||||
export function SkillsBanner({ names }: { names: string[] }) {
|
||||
return (
|
||||
<p className="anim-msg my-2 flex w-fit items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400">
|
||||
<GlyphIcon d={BOOK_ICON} className="text-gray-400 dark:text-gray-500" />
|
||||
{S.chat.skillsBanner(names)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Follow-decision logic for auto-stick-to-bottom in the message stream (pure logic,
|
||||
* unit-testable; issue #75).
|
||||
*
|
||||
* "Exiting follow" and "resuming follow" are two independent judgments:
|
||||
* - Exit: any user intent to scroll up takes effect immediately — detected from the input event
|
||||
* itself for wheel-up / touch-drag-down (even if position doesn't change, e.g. already at the
|
||||
* top), and from a scrollTop regression for scrollbar-drag-up / keyboard. This doesn't rely on
|
||||
* an "80px from bottom" threshold — otherwise a short scroll area with less than 80px of
|
||||
* scrollable slack could never exit, and streaming updates would keep fighting the upward
|
||||
* gesture back and forth.
|
||||
* - Resume: only resumes once the user brings the viewport back near the bottom (within 80px).
|
||||
* Programmatic stick-to-bottom only happens while following (idempotent); content shrinking
|
||||
* (e.g. a group collapsing) that clamps scrollTop downward while still touching the bottom
|
||||
* (≤1px) doesn't count as scrolling up and doesn't change intent.
|
||||
* - The first scroll event has no direction to judge from, so it initializes from position
|
||||
* (≥80px from bottom is treated as being at a historical position, i.e. not following) — this
|
||||
* doesn't depend on the call-ordering guarantee of "must stick to bottom programmatically right
|
||||
* after mount."
|
||||
*/
|
||||
export interface ScrollMetrics {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
}
|
||||
|
||||
export interface StreamFollow {
|
||||
/** Whether it should currently auto-stick to bottom on streaming updates. */
|
||||
readonly stick: boolean;
|
||||
/** wheel: deltaY < 0 is scroll-up intent, exits follow immediately. */
|
||||
wheel(deltaY: number): void;
|
||||
touchStart(clientY: number): void;
|
||||
/** Touch drag: finger moving down = content scrolling up, exits follow. */
|
||||
touchMove(clientY: number): void;
|
||||
touchEnd(): void;
|
||||
/** scroll event (user scrolling and programmatic stick-to-bottom share this path): moving up exits; otherwise nearing the bottom resumes; the first event initializes from position. */
|
||||
scrolled(m: ScrollMetrics): void;
|
||||
}
|
||||
|
||||
export function createStreamFollow(): StreamFollow {
|
||||
let stick = true;
|
||||
let lastTop: number | null = null;
|
||||
let touchY: number | null = null;
|
||||
return {
|
||||
get stick() {
|
||||
return stick;
|
||||
},
|
||||
wheel(deltaY) {
|
||||
if (deltaY < 0) stick = false;
|
||||
},
|
||||
touchStart(clientY) {
|
||||
touchY = clientY;
|
||||
},
|
||||
touchMove(clientY) {
|
||||
if (touchY !== null && clientY > touchY) stick = false;
|
||||
touchY = clientY;
|
||||
},
|
||||
touchEnd() {
|
||||
touchY = null;
|
||||
},
|
||||
scrolled(m) {
|
||||
const dist = m.scrollHeight - m.scrollTop - m.clientHeight;
|
||||
const prev = lastTop;
|
||||
lastTop = m.scrollTop;
|
||||
if (prev === null) {
|
||||
stick = dist < 80;
|
||||
return;
|
||||
}
|
||||
if (m.scrollTop < prev && dist > 1) {
|
||||
stick = false;
|
||||
return;
|
||||
}
|
||||
if (dist < 80) stick = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Subagent card: nested inside a run_subagent card (or as a
|
||||
* standalone card), renders the subsession's full streaming process by following the origin
|
||||
* chain — expanded by default while running, collapsed once finished; nesting inside the card
|
||||
* uses the same rendering recursively.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { S } from "../../lib/strings";
|
||||
import type { StreamModel } from "../../lib/omni/stream-model";
|
||||
import { Chevron } from "../../components/ui/chevron";
|
||||
import { MessageItems } from "./message-stream";
|
||||
import type { StreamRenderContext } from "./message-stream";
|
||||
|
||||
export function SubagentCard({
|
||||
sessionId,
|
||||
model,
|
||||
running,
|
||||
ctx,
|
||||
}: {
|
||||
sessionId: string;
|
||||
model: StreamModel;
|
||||
running: boolean;
|
||||
ctx: StreamRenderContext;
|
||||
}) {
|
||||
const [open, setOpen] = useState(running);
|
||||
const userToggled = useRef(false);
|
||||
const wasRunning = useRef(running);
|
||||
|
||||
// Append this card's subsession id to the nesting level: tool cards within the card match
|
||||
// pending approvals by the full origin chain; the running state inside the subsession follows
|
||||
// this card's own running prop, not the parent session's taskRunning.
|
||||
const nestedCtx: StreamRenderContext = {
|
||||
...ctx,
|
||||
...(sessionId ? { origin: [...ctx.origin, sessionId] } : {}),
|
||||
taskRunning: running,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-collapse once finished (respects the user's choice if they manually toggled it).
|
||||
if (wasRunning.current && !running && !userToggled.current) setOpen(false);
|
||||
wasRunning.current = running;
|
||||
}, [running]);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-gray-300 bg-gray-50/60 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
userToggled.current = true;
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-t-md px-3 py-1.5 text-left text-xs text-gray-500 transition-colors duration-200 hover:bg-gray-100/70 dark:text-gray-400 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<Chevron open={open} />
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">{S.chat.subagent}</span>
|
||||
{sessionId && <span className="truncate font-mono text-gray-400">{sessionId}</span>}
|
||||
{running && (
|
||||
<span className="ml-auto flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||
<span className="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-gray-400 border-t-transparent" />
|
||||
{S.chat.subagentRunning}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="anim-fade border-t border-dashed border-gray-200 px-3 py-1 dark:border-gray-800">
|
||||
<MessageItems items={model.items} ctx={nestedCtx} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Task stats line: after each Task finishes, shows "this turn's usage" at the
|
||||
* **bottom-left** of the AI reply — input, output, output TPS, elapsed time, and cost (converted
|
||||
* in real time from the current Model's pricing, hidden if no pricing is configured); all five
|
||||
* share the same basis (this turn's usage), each expressed uniformly as icon + value (no text
|
||||
* labels); the reply timestamp and a copy button sit at the end (copies this turn's assistant
|
||||
* text, falling back to the stats themselves when there's no text).
|
||||
* The whole line is invisible but takes up space by default, surfacing only on hovering the
|
||||
* reply or the line itself — matching the same convention, font size, and color as the user
|
||||
* message footer: the AI's footer sits bottom-left, the user's sits bottom-right, symmetric on
|
||||
* both sides.
|
||||
* Arrow direction reads as "where the tokens go": **up arrow = input** (sent up to the model),
|
||||
* **down arrow = output** (returned by the model).
|
||||
* This line only answers "how much did this turn cost", **it doesn't break down the cache
|
||||
* composition** — how much of the input hit cache is a debugging-level detail that belongs to the
|
||||
* Trace page's turn card (which shows cache hits and hit rate); the current **context usage** is
|
||||
* likewise not repeated here — it's expressed by the ring below the input box (see ContextGauge
|
||||
* in chat-input).
|
||||
* This line sits right below this turn's AI reply and simultaneously **serves as that reply's
|
||||
* footer**: it provides the reply timestamp and copy button at the end, so the assistant message
|
||||
* itself doesn't render a separate one (otherwise two copy buttons would pop up in the same spot).
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { formatTaskStats } from "../../lib/omni/task-stats";
|
||||
import type { TaskStats } from "../../lib/omni/task-stats";
|
||||
import {
|
||||
formatMessageTime,
|
||||
formatMoney,
|
||||
formatTps,
|
||||
humanizeDuration,
|
||||
humanizeTokens,
|
||||
} from "../../lib/format";
|
||||
import { STAT_ICONS } from "../../lib/stat-icons";
|
||||
import { S } from "../../lib/strings";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import { useTheme } from "../../state/theme";
|
||||
import { useLocale } from "../../state/locale";
|
||||
|
||||
/** Icon + value; hover explains what this item is (the icon alone doesn't convey the exact meaning). */
|
||||
function StatChip({ icon, value, label }: { icon: string; value: string; label: string }) {
|
||||
return (
|
||||
<span title={label} aria-label={label} className="flex items-center gap-1">
|
||||
<GlyphIcon d={icon} />
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskStatsLine({
|
||||
stats,
|
||||
assistantText,
|
||||
cost,
|
||||
atMs,
|
||||
}: {
|
||||
/** This turn's stats; `null` = no token_usage for this turn (reply was aborted) -> only the timestamp and copy are shown, no stat numbers drawn. */
|
||||
stats: TaskStats | null;
|
||||
/** This turn's assistant text (the copy target); falls back to the stats when empty. */
|
||||
assistantText?: string;
|
||||
/** This turn's cost (USD); null = no pricing configured, cost isn't shown. */
|
||||
cost?: number | null;
|
||||
/** Timestamp of this turn's AI reply (this line is that reply's footer). */
|
||||
atMs?: number;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { currency } = useTheme();
|
||||
const { locale } = useLocale();
|
||||
|
||||
// This turn's usage (not a context snapshot): input = this Task's cached + uncached input.
|
||||
const b = stats?.tokensByBucket;
|
||||
const input = b ? b.cacheRead + b.cacheWrite : 0;
|
||||
|
||||
const copy = () => {
|
||||
const text =
|
||||
assistantText?.trim() || stats === null ? (assistantText ?? "") : formatTaskStats(stats);
|
||||
void navigator.clipboard?.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
};
|
||||
|
||||
// The whole line is invisible but **takes up space** by default (opacity-0, not hidden) —
|
||||
// matching the user message footer's convention: only appears on hover, and because the space
|
||||
// is always reserved, appearing never pushes content below it down. Font size/color also match
|
||||
// that footer; the AI's footer sits bottom-left, the user's sits bottom-right, symmetric.
|
||||
return (
|
||||
<div className="-mt-2 flex h-5 flex-wrap items-center justify-start gap-x-3 text-[11px] text-gray-400 opacity-0 transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100 dark:text-gray-500">
|
||||
{/* Timestamp leads: it's this reply's identity (when it was said), the stat numbers are an
|
||||
annotation. When this turn has no token_usage (reply was aborted), only the timestamp
|
||||
and copy remain — nothing is fabricated for what wasn't measured. */}
|
||||
{atMs !== undefined && <span>{formatMessageTime(atMs, locale)}</span>}
|
||||
{stats && b && (
|
||||
<>
|
||||
<StatChip
|
||||
icon={STAT_ICONS.input}
|
||||
value={humanizeTokens(input)}
|
||||
label={S.chat.statInput}
|
||||
/>
|
||||
<StatChip
|
||||
icon={STAT_ICONS.output}
|
||||
value={humanizeTokens(b.output)}
|
||||
label={S.chat.statOutput}
|
||||
/>
|
||||
<StatChip
|
||||
icon={STAT_ICONS.tps}
|
||||
value={formatTps(stats.outputTps)}
|
||||
label={S.chat.statTps}
|
||||
/>
|
||||
{cost != null && (
|
||||
<StatChip
|
||||
icon={STAT_ICONS.cost}
|
||||
value={formatMoney(cost, currency)}
|
||||
label={`${S.chat.statCost}(${currency})`}
|
||||
/>
|
||||
)}
|
||||
<StatChip
|
||||
icon={STAT_ICONS.elapsed}
|
||||
value={humanizeDuration(stats.elapsedDeltaMs)}
|
||||
label={S.chat.statElapsed}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
title={copied ? S.chat.copied : S.chat.copyReply}
|
||||
aria-label={S.chat.copyReply}
|
||||
onClick={copy}
|
||||
className="flex h-5 w-5 items-center justify-center rounded transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
|
||||
>
|
||||
<GlyphIcon d={copied ? STAT_ICONS.check : STAT_ICONS.copy} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Thinking block: one row within the "Reasoning & Tools" group — status
|
||||
* icon + "Thinking" + elapsed time, click to expand the full thinking text. Shares the same row
|
||||
* style and set of running-state icons (in progress / done / failed) as tool cards.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { S } from "../../lib/strings";
|
||||
import { humanizeDuration } from "../../lib/format";
|
||||
import type { ThinkingItem } from "../../lib/omni/stream-model";
|
||||
import { Chevron } from "../../components/ui/chevron";
|
||||
import { StatusIcon } from "../../components/ui/status-icon";
|
||||
import type { RunState } from "../../components/ui/status-icon";
|
||||
import { LiveDuration } from "./live-duration";
|
||||
|
||||
export function ThinkingBlock({ item }: { item: ThinkingItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const failed = item.stopReason !== undefined && item.stopReason !== "completed";
|
||||
const state: RunState = item.streaming ? "running" : failed ? "failed" : "done";
|
||||
const stateLabel = item.streaming
|
||||
? S.chat.workRunning
|
||||
: failed
|
||||
? item.stopReason
|
||||
: S.chat.workDone;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<StatusIcon state={state} label={stateLabel} />
|
||||
<span className="shrink-0 text-xs text-gray-500 dark:text-gray-400">{S.chat.thinking}</span>
|
||||
<span className="shrink-0 font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
{item.streaming ? (
|
||||
<LiveDuration sinceMs={item.startedAtMs} />
|
||||
) : item.durationMs !== undefined ? (
|
||||
humanizeDuration(item.durationMs)
|
||||
) : null}
|
||||
</span>
|
||||
{failed && <span className="font-mono text-xs text-gray-400">[{item.stopReason}]</span>}
|
||||
<span className="min-w-0 flex-1" />
|
||||
<Chevron open={open} className="text-gray-400" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="md-body anim-fade mx-3 mb-2 rounded-md bg-gray-50 px-3 py-2 text-sm leading-relaxed text-gray-600 dark:bg-gray-900/60 dark:text-gray-400">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{item.thinking}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Tool call card: collapses to a single line by
|
||||
* default — status icon + tool name + duration (a live-ticking timer while running) + status
|
||||
* badge; clicking expands full arguments, output, and any nested subagent.
|
||||
* The pending-approval row is always visible regardless of collapsed state; when a pending
|
||||
* approval appears anywhere in a nested subagent chain, the card auto-expands once (respecting
|
||||
* the user's choice if they've manually collapsed it since).
|
||||
*
|
||||
* Duration accounting = **argument-generation segment + execution segment** (excludes time
|
||||
* spent waiting on human approval): the model streaming out arguments token by token is often
|
||||
* slower than the tool call itself, so reporting only the execution segment would badly
|
||||
* understate this step's cost. While waiting on approval, the already-settled generation
|
||||
* segment is shown, with a separate "Waiting for approval" badge attached.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { S } from "../../lib/strings";
|
||||
import { humanizeDuration } from "../../lib/format";
|
||||
import { approvalKey } from "../../lib/omni/stream-model";
|
||||
import type { ToolCallItem } from "../../lib/omni/stream-model";
|
||||
import { Badge, stopReasonTone } from "../../components/ui/badge";
|
||||
import { Chevron } from "../../components/ui/chevron";
|
||||
import { ZoomableImage } from "../../components/ui/image-zoom";
|
||||
import { StatusIcon } from "../../components/ui/status-icon";
|
||||
import type { RunState } from "../../components/ui/status-icon";
|
||||
import { ApprovalButtons } from "./approval-buttons";
|
||||
import { LiveDuration } from "./live-duration";
|
||||
import { SubagentCard } from "./subagent-card";
|
||||
import type { StreamRenderContext } from "./message-stream";
|
||||
|
||||
/**
|
||||
* Argument preview (same approach as the CLI's tool-render): exec_command shows `$ <cmd>`,
|
||||
* other tools show a single-line `name(args)` prefix. Arguments may be incomplete JSON
|
||||
* (mid-stream), so extraction is done leniently.
|
||||
*/
|
||||
function previewArguments(name: string, argsJson: string): string {
|
||||
if (name === "exec_command") {
|
||||
const cmd = extractStringField(argsJson, "cmd");
|
||||
if (cmd !== null) return `$ ${cmd.replace(/\s+/g, " ").trim()}`;
|
||||
}
|
||||
return argsJson.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Extracts the current value of a string field from a possibly-incomplete JSON object string (a simplified version, good enough for preview purposes). */
|
||||
function extractStringField(argsJson: string, field: string): string | null {
|
||||
const key = `"${field}"`;
|
||||
const keyIndex = argsJson.indexOf(key);
|
||||
if (keyIndex === -1) return null;
|
||||
let i = keyIndex + key.length;
|
||||
while (/\s/.test(argsJson[i] ?? "")) i += 1;
|
||||
if (argsJson[i] !== ":") return null;
|
||||
i += 1;
|
||||
while (/\s/.test(argsJson[i] ?? "")) i += 1;
|
||||
if (argsJson[i] !== '"') return null;
|
||||
i += 1;
|
||||
let out = "";
|
||||
let escaped = false;
|
||||
for (; i < argsJson.length; i += 1) {
|
||||
const ch = argsJson[i]!;
|
||||
if (escaped) {
|
||||
out += ch === "n" ? "\n" : ch === "t" ? "\t" : ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') return out;
|
||||
out += ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRenderContext }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const userToggled = useRef(false);
|
||||
// Matched by the current origin chain + toolCallId: prevents parent/child session tool_call_id collisions from lighting each other up.
|
||||
const pending = ctx.pendingApprovals.get(approvalKey(ctx.origin, item.toolCallId));
|
||||
|
||||
// Whether there's a pending approval anywhere in the nested subagent chain (keys are `origin-chain toolCallId`, matched by prefix at any depth).
|
||||
const nestedPrefix = item.subagentSessionId
|
||||
? [...ctx.origin, item.subagentSessionId].join("/")
|
||||
: null;
|
||||
const hasNestedPending =
|
||||
nestedPrefix !== null &&
|
||||
[...ctx.pendingApprovals.keys()].some(
|
||||
(k) => k.startsWith(`${nestedPrefix} `) || k.startsWith(`${nestedPrefix}/`),
|
||||
);
|
||||
|
||||
// A nested pending approval needs the user's action: auto-expand once (respected if the user manually collapses it afterward).
|
||||
useEffect(() => {
|
||||
if (hasNestedPending && !userToggled.current) setOpen(true);
|
||||
}, [hasNestedPending]);
|
||||
|
||||
const preview = previewArguments(item.name, item.argumentsText);
|
||||
// Executing = the call has finished streaming, output hasn't arrived yet, and it's not waiting on approval (approval wait time doesn't count toward execution).
|
||||
const executing = item.callComplete && !item.outputComplete && !pending;
|
||||
// Argument-generation segment (settled): the live execution timer accumulates on top of this as a baseline, so the displayed duration doesn't shrink back once output arrives.
|
||||
const genMs =
|
||||
item.argStartedAtMs !== undefined && item.callStartedAtMs !== undefined
|
||||
? Math.max(0, item.callStartedAtMs - item.argStartedAtMs)
|
||||
: 0;
|
||||
const failed =
|
||||
(item.callStopReason !== undefined && item.callStopReason !== "completed") ||
|
||||
(item.outputStopReason !== undefined && item.outputStopReason !== "completed");
|
||||
const state: RunState = pending
|
||||
? "waiting"
|
||||
: executing || item.callStreaming
|
||||
? "running"
|
||||
: failed
|
||||
? "failed"
|
||||
: "done";
|
||||
const stateLabel = pending
|
||||
? S.chat.approvalWaiting
|
||||
: state === "running"
|
||||
? S.chat.workRunning
|
||||
: state === "done"
|
||||
? S.chat.workDone
|
||||
: (item.outputStopReason ?? item.callStopReason);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Collapsed row: status icon + tool name + total duration (generation + execution, excluding approval wait). Expand chevron on the right. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => {
|
||||
userToggled.current = true;
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<StatusIcon state={state} label={stateLabel} />
|
||||
<span className="shrink-0 truncate font-mono text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
{item.name || S.chat.unknownTool}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
{item.durationMs !== undefined ? (
|
||||
humanizeDuration(item.durationMs)
|
||||
) : executing ? (
|
||||
// Execution timer: argument-generation baseline + a live segment starting from approval grant (or from call completion if no approval was needed).
|
||||
<LiveDuration sinceMs={item.approvalAtMs ?? item.callStartedAtMs} offsetMs={genMs} />
|
||||
) : pending ? (
|
||||
// No ticking while waiting on approval: frozen at the settled argument-generation segment.
|
||||
genMs > 0 ? (
|
||||
humanizeDuration(genMs)
|
||||
) : null
|
||||
) : item.callStreaming ? (
|
||||
// Generating arguments: live-ticking timer (falls back to a pulsing ellipsis when no start time is known).
|
||||
item.argStartedAtMs !== undefined ? (
|
||||
<LiveDuration sinceMs={item.argStartedAtMs} />
|
||||
) : (
|
||||
<span className="animate-pulse">…</span>
|
||||
)
|
||||
) : null}
|
||||
</span>
|
||||
{pending && (
|
||||
<span className="shrink-0 font-mono text-xs text-amber-600 dark:text-amber-400">
|
||||
{S.chat.approvalWaiting}
|
||||
</span>
|
||||
)}
|
||||
{item.callStopReason && item.callStopReason !== "completed" && (
|
||||
<Badge tone={stopReasonTone(item.callStopReason)}>{item.callStopReason}</Badge>
|
||||
)}
|
||||
{item.outputStopReason && item.outputStopReason !== "completed" && (
|
||||
<Badge tone={stopReasonTone(item.outputStopReason)}>{item.outputStopReason}</Badge>
|
||||
)}
|
||||
{item.decision && (
|
||||
<Badge tone={item.decision === "allow" ? "green" : "red"}>
|
||||
{item.decision === "allow" ? S.chat.decisionAllow : S.chat.decisionDeny}
|
||||
{" · "}
|
||||
{item.decisionSource === "manual" ? S.chat.decisionManual : S.chat.decisionAuto}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="min-w-0 flex-1" />
|
||||
{/* Expand indicator on the right */}
|
||||
<Chevron open={open} className="text-gray-400" />
|
||||
</button>
|
||||
|
||||
{/* Pending approval: always visible regardless of collapsed state — shows the tool name and arguments so the user knows what they're approving. */}
|
||||
{pending && (
|
||||
<div className="border-t border-gray-100 bg-amber-50 px-3 py-2 dark:border-gray-800 dark:bg-amber-950/30">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="shrink-0 rounded-md bg-white px-1.5 py-0.5 font-mono text-xs font-semibold text-gray-700 dark:bg-gray-900 dark:text-gray-300">
|
||||
{item.name || S.chat.unknownTool}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs text-gray-600 dark:text-gray-400">
|
||||
{preview}
|
||||
</span>
|
||||
</div>
|
||||
<ApprovalButtons
|
||||
onDecide={(decision) => ctx.onApprove(item.toolCallId, decision, ctx.origin)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subagent card: always visible (not hidden after completion, only collapsed by default), unaffected by the tool card's collapsed state. */}
|
||||
{item.subagent && (
|
||||
<div className="px-3 pb-2">
|
||||
<SubagentCard
|
||||
sessionId={item.subagentSessionId ?? ""}
|
||||
model={item.subagent}
|
||||
running={!item.outputComplete}
|
||||
ctx={ctx}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded details: full arguments / output */}
|
||||
{open && (
|
||||
<div className="anim-fade">
|
||||
{item.argumentsText && (
|
||||
// Arguments are shown as a fully wrapped block (no height cap, no scrollbar): the
|
||||
// arguments are key to understanding this call, and tucking them into an inner
|
||||
// scroll area would make them hard to read and fight with the message stream's own scroll.
|
||||
<pre className="whitespace-pre-wrap break-all border-t border-gray-100 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-400">
|
||||
{item.argumentsText}
|
||||
</pre>
|
||||
)}
|
||||
{(item.output || item.outputStreaming) && (
|
||||
<pre className="max-h-72 overflow-auto whitespace-pre-wrap border-t border-gray-100 px-3 py-2 text-xs leading-5 text-gray-600 dark:border-gray-800 dark:text-gray-300">
|
||||
{item.output}
|
||||
{item.outputStreaming && <span className="animate-pulse">▌</span>}
|
||||
</pre>
|
||||
)}
|
||||
{/* Tool output images (e.g. read_image): shown as thumbnails, click to zoom (ZoomableImage). */}
|
||||
{item.images && item.images.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 border-t border-gray-100 px-3 py-2 dark:border-gray-800">
|
||||
{item.images.map((src, i) => (
|
||||
<ZoomableImage
|
||||
key={i}
|
||||
src={src}
|
||||
alt={S.chat.toolImageAlt}
|
||||
className="max-h-40 max-w-full rounded-md border border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Files panel state machine: panel open/close, drag-to-resize, the responsive breakpoint for
|
||||
* desktop docking vs. falling back to a mobile drawer, and the "locate a file in the directory
|
||||
* tree" navigation command (driven by clicking a file chip inside a message).
|
||||
*
|
||||
* The panel's content is just WorkspaceBrowser's single directory-tree view — the protocol has no
|
||||
* structured file-write signal at all (the only built-in tool is the opaque exec_command shell),
|
||||
* so there's no "Agent output" list to maintain; a file clicked in a message jumps straight to
|
||||
* locating it in the tree.
|
||||
*
|
||||
* Only the navigation command resets when sessionId changes; the open/closed state persists
|
||||
* across sessions (once opened, it stays open); width is a layout preference, not session data,
|
||||
* so it isn't reset, and it's persisted to localStorage (same as app-layout.tsx's
|
||||
* sidebarCollapsed). Both the default width and the cap scale proportionally with the window
|
||||
* width (matching the ~1/3-of-window feel of Codex's Review panel); a dragged preference takes
|
||||
* priority over the proportional default; double-clicking the handle clears the preference and
|
||||
* reverts to the proportional default.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
|
||||
import type { SheetSnap } from "../../components/ui/sheet";
|
||||
|
||||
export interface FilesPanelState {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
/** Snap point for the mobile bottom Sheet (half = browsing / full = preview); unused in the desktop docked state. */
|
||||
sheetSnap: SheetSnap;
|
||||
setSheetSnap: (snap: SheetSnap) => void;
|
||||
/** Clicking a file chip in a message: commands WorkspaceBrowser to navigate to and locate that file. */
|
||||
browsePath: (path: string) => void;
|
||||
/** The external navigation command produced by browsePath; each call creates a new object
|
||||
* reference, ensuring that clicking the same file again still re-triggers WorkspaceBrowser's
|
||||
* locate effect (compared by object identity, not by path value). */
|
||||
openRequest: { path: string } | null;
|
||||
/** Docked at >=1024px (lg); otherwise falls back to a Drawer overlay. Responsive, updates live
|
||||
* as the window width changes — docked and Drawer are mounted mutually exclusively; mounting
|
||||
* both at once would cause WorkspaceBrowser's own data requests to fire twice. */
|
||||
isDocked: boolean;
|
||||
width: number;
|
||||
resizing: boolean;
|
||||
startResize: (e: ReactMouseEvent<HTMLDivElement>) => void;
|
||||
/** Double-clicking the drag handle: width reverts to the window-proportional default, and the stored preference is cleared. */
|
||||
resetWidth: () => void;
|
||||
/** Ref to the docked panel's root node: drag-to-resize uses its right edge to compute the target width. */
|
||||
panelRef: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 320;
|
||||
const DOCK_QUERY = "(min-width: 1024px)";
|
||||
const WIDTH_STORAGE_KEY = "penguin.filesPanelWidth";
|
||||
|
||||
/** Width cap: at most half the window (keeping the chat column usable), plus a hard 720px readability ceiling. */
|
||||
function maxWidthFor(windowWidth: number): number {
|
||||
return Math.max(MIN_WIDTH, Math.min(720, Math.round(windowWidth * 0.5)));
|
||||
}
|
||||
|
||||
/** Default width ≈ 1/3 of the window (matching Codex's Review panel proportion), clamped within the min/max bounds. */
|
||||
function defaultWidthFor(windowWidth: number): number {
|
||||
return Math.min(maxWidthFor(windowWidth), Math.max(MIN_WIDTH, Math.round(windowWidth * 0.34)));
|
||||
}
|
||||
|
||||
/** Initial width: stored preference (clamped back within the current window's bounds, to prevent an oversized value carried over from another device) takes priority over the proportional default. */
|
||||
function initialWidth(): number {
|
||||
const stored = Number(localStorage.getItem(WIDTH_STORAGE_KEY));
|
||||
if (!Number.isFinite(stored) || stored <= 0) return defaultWidthFor(window.innerWidth);
|
||||
return Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, Math.round(stored)));
|
||||
}
|
||||
|
||||
export function useFilesPanel(sessionId: string | null): FilesPanelState {
|
||||
const [open, setOpenRaw] = useState(false);
|
||||
const [sheetSnap, setSheetSnap] = useState<SheetSnap>("half");
|
||||
const [openRequest, setOpenRequest] = useState<{ path: string } | null>(null);
|
||||
|
||||
/** Opening the panel defaults to browsing intent (Sheet snaps to half); browsePath's preview
|
||||
* intent overrides it to full within the same batch, without flickering. Closing doesn't
|
||||
* change the snap point. In the desktop docked state, sheetSnap is unused by anything, so this has no side effect there. */
|
||||
const setOpen = useCallback((next: boolean) => {
|
||||
if (next) setSheetSnap("half");
|
||||
setOpenRaw(next);
|
||||
}, []);
|
||||
const [width, setWidth] = useState(initialWidth);
|
||||
/** A synchronous mirror of width: read by the mouseup persist step, sidestepping the stale state captured in the event closure. */
|
||||
const widthRef = useRef(width);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const [isDocked, setIsDocked] = useState(() => window.matchMedia(DOCK_QUERY).matches);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Switching Session/Agent only resets the navigation command (which pointed at a file in the
|
||||
// old session); the open/closed state persists across sessions — a workspace panel the user
|
||||
// opened is part of their browsing environment, and switching sessions shouldn't collapse it.
|
||||
useEffect(() => {
|
||||
setOpenRequest(null);
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(DOCK_QUERY);
|
||||
const onChange = (e: MediaQueryListEvent) => setIsDocked(e.matches);
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
const browsePath = useCallback((path: string) => {
|
||||
setSheetSnap("full"); // Preview intent: opens the mobile Sheet fully
|
||||
setOpenRequest({ path });
|
||||
}, []);
|
||||
|
||||
// Drag-to-resize: during mousemove, computes the width from the panel's right edge and clamps
|
||||
// it within the min/max bounds; locks the cursor/selection during the drag to avoid
|
||||
// accidentally selecting page text on a fast drag (standard, necessary handling for
|
||||
// drag-to-resize, not something to skip just because there's no prior precedent here).
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = panelRef.current?.getBoundingClientRect();
|
||||
const right = rect ? rect.right : window.innerWidth;
|
||||
const next = Math.min(maxWidthFor(window.innerWidth), Math.max(MIN_WIDTH, right - e.clientX));
|
||||
widthRef.current = next;
|
||||
setWidth(next);
|
||||
};
|
||||
// Only persist the preference once the drag ends: mousemove fires every frame, and it's not worth writing to localStorage on every frame.
|
||||
const onUp = () => {
|
||||
setResizing(false);
|
||||
localStorage.setItem(WIDTH_STORAGE_KEY, String(Math.round(widthRef.current)));
|
||||
};
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [resizing]);
|
||||
|
||||
const startResize = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setResizing(true);
|
||||
}, []);
|
||||
|
||||
const resetWidth = useCallback(() => {
|
||||
const next = defaultWidthFor(window.innerWidth);
|
||||
widthRef.current = next;
|
||||
setWidth(next);
|
||||
// Clears rather than writing the default value: this way the default keeps following the window's proportion going forward, instead of being frozen at the current pixel value.
|
||||
localStorage.removeItem(WIDTH_STORAGE_KEY);
|
||||
}, []);
|
||||
|
||||
// When the window shrinks, clamp the panel back within the cap to prevent the docked panel
|
||||
// from crowding out the chat column. Only shrinks, never grows back: this doesn't overwrite
|
||||
// the stored preference; enlarging the window again relies on a refresh or double-clicking the handle to restore it.
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
setWidth((w) => {
|
||||
const clamped = Math.min(w, maxWidthFor(window.innerWidth));
|
||||
if (clamped !== w) widthRef.current = clamped;
|
||||
return clamped;
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
open,
|
||||
setOpen,
|
||||
sheetSnap,
|
||||
setSheetSnap,
|
||||
browsePath,
|
||||
openRequest,
|
||||
isDocked,
|
||||
width,
|
||||
resizing,
|
||||
startResize,
|
||||
resetWidth,
|
||||
panelRef,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Draft auto-cache for an existing session's input area: text +
|
||||
* @-handoff target + selected skills are cached to localStorage keyed by "user x Session"
|
||||
* (see draft-cache's sessionDraftKey; the user dimension prevents cross-account leakage on
|
||||
* the same browser, #68), restored after navigating away/reloading. Model / Workspace /
|
||||
* approval mode are locked to the Session and need no caching.
|
||||
*
|
||||
* Write strategy matches the draft page: text is debounced and merge-written (an unflushed
|
||||
* edit gets one extra flush before switching sessions/unmounting); @ target and skill
|
||||
* selection write immediately; **clearing content deletes the key** (leaving an empty shell
|
||||
* per session would bloat localStorage); discard on a successful send cancels the pending
|
||||
* timer first, otherwise it would write the just-cleared draft back.
|
||||
*
|
||||
* ChatPage keys session content blocks by sessionId, so ChatInput remounts accordingly, but
|
||||
* this hook is mounted on ChatPage itself and does not remount — switching sessions is
|
||||
* handled by the [key] effect: cleanup flushes the old session via its stale closure, setup
|
||||
* resets the refs to the new session's initial values.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useAuth } from "../../state/auth";
|
||||
import { clearDraft, loadDraft, saveDraft, sessionDraftKey } from "./draft-cache";
|
||||
import type { DraftCache } from "./draft-cache";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 300;
|
||||
|
||||
export function useSessionDraft(sessionId: string | null): {
|
||||
/** Initial draft value for the current session (recomputed with sessionId; consumed by ChatInput after it remounts on sessionId). */
|
||||
initial: DraftCache;
|
||||
onTextChange: (text: string) => void;
|
||||
onHandoffTargetChange: (agentId: string | null) => void;
|
||||
/** Selected-skills change (wired directly to ChatInput's onSkillsChange; a discrete action writes immediately). */
|
||||
onSkillsChange: (names: string[]) => void;
|
||||
/** Discard the current session's draft after a successful send. */
|
||||
discard: () => void;
|
||||
} {
|
||||
// No user (shouldn't happen under RequireAuth) disables caching entirely: must not read/write account-agnostic keys (#68).
|
||||
const userId = useAuth().user?.userId ?? null;
|
||||
const key = userId && sessionId ? sessionDraftKey(userId, sessionId) : null;
|
||||
const initial = useMemo<DraftCache>(() => (key ? loadDraft(key) : {}), [key]);
|
||||
|
||||
const textRef = useRef(initial.text ?? "");
|
||||
const handoffRef = useRef<string | null>(initial.handoffAgentId ?? null);
|
||||
const skillsRef = useRef<string[]>(initial.skills ?? []);
|
||||
const timer = useRef<number | null>(null);
|
||||
|
||||
const cancelPending = useCallback(() => {
|
||||
if (timer.current !== null) {
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persistNow = useCallback(() => {
|
||||
cancelPending();
|
||||
if (!key) return;
|
||||
const text = textRef.current;
|
||||
const handoffAgentId = handoffRef.current;
|
||||
const skills = skillsRef.current;
|
||||
if (!text && !handoffAgentId && skills.length === 0) {
|
||||
clearDraft(key);
|
||||
return;
|
||||
}
|
||||
const data: DraftCache = { text };
|
||||
if (handoffAgentId) data.handoffAgentId = handoffAgentId;
|
||||
if (skills.length > 0) data.skills = skills;
|
||||
saveDraft(key, data);
|
||||
}, [cancelPending, key]);
|
||||
|
||||
// Switching session / unmounting: cleanup first flushes the old session's unsaved text
|
||||
// (persistNow's closure still holds the old key), then setup resets the refs to the new
|
||||
// session's initial values.
|
||||
useEffect(() => {
|
||||
textRef.current = initial.text ?? "";
|
||||
handoffRef.current = initial.handoffAgentId ?? null;
|
||||
skillsRef.current = initial.skills ?? [];
|
||||
return () => {
|
||||
if (timer.current !== null) {
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
persistNow();
|
||||
}
|
||||
};
|
||||
}, [initial, persistNow]);
|
||||
|
||||
const onTextChange = useCallback(
|
||||
(text: string) => {
|
||||
textRef.current = text;
|
||||
cancelPending();
|
||||
timer.current = window.setTimeout(() => {
|
||||
timer.current = null;
|
||||
persistNow();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
},
|
||||
[cancelPending, persistNow],
|
||||
);
|
||||
|
||||
const onHandoffTargetChange = useCallback(
|
||||
(agentId: string | null) => {
|
||||
handoffRef.current = agentId;
|
||||
// Discrete action writes immediately (text is carried along via textRef).
|
||||
persistNow();
|
||||
},
|
||||
[persistNow],
|
||||
);
|
||||
|
||||
const onSkillsChange = useCallback(
|
||||
(names: string[]) => {
|
||||
skillsRef.current = names;
|
||||
// Same as @ target: discrete action writes immediately.
|
||||
persistNow();
|
||||
},
|
||||
[persistNow],
|
||||
);
|
||||
|
||||
const discard = useCallback(() => {
|
||||
cancelPending();
|
||||
// Also clear selected skills: ChatInput's clear after a successful send doesn't fire a
|
||||
// callback (same convention as onTextChange); without this, a later text flush would
|
||||
// resurrect the already-sent selection.
|
||||
skillsRef.current = [];
|
||||
if (key) clearDraft(key);
|
||||
}, [cancelPending, key]);
|
||||
|
||||
return { initial, onTextChange, onHandoffTargetChange, onSkillsChange, discard };
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Session stream hook (connect-first + dedupe on use): React adapter layer — the protocol state machine lives in
|
||||
* lib/omni/stream-controller.ts (pure logic, unit-testable).
|
||||
*
|
||||
* 1. On entering a Session, connect SSE first; events are handed to the controller (buffered if
|
||||
* history isn't ready yet);
|
||||
* 2. GET messages renders history, then replays the buffer (overlap deduped); on load failure,
|
||||
* expose error and a retry entry point;
|
||||
* 3. Disconnects are auto-reconnected by the browser (Last-Event-ID built in; server replays from
|
||||
* its buffer); resync_required → controller rebuilds the model and keeps consuming the same
|
||||
* connection;
|
||||
* 4. task_state in the stream is the authoritative run state (server pushes the current snapshot
|
||||
* on subscribe); initialStatus only serves as the first-frame placeholder, driving the input
|
||||
* area state and the Session list badge;
|
||||
* 5. The pending-approval table is keyed by `origin + toolCallId` (approvalKey); on reconnect the
|
||||
* server re-sends still-pending requests.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { SessionStatus } from "@prismshadow/penguin-server/api";
|
||||
import { getMe, getMessages } from "../../api/endpoints";
|
||||
import { openSessionStream } from "../../api/sse";
|
||||
import { createStreamController } from "../../lib/omni/stream-controller";
|
||||
import type { PendingApproval, StreamController } from "../../lib/omni/stream-controller";
|
||||
import { createStreamModel } from "../../lib/omni/stream-model";
|
||||
import type { StreamModel } from "../../lib/omni/stream-model";
|
||||
|
||||
export type { PendingApproval } from "../../lib/omni/stream-controller";
|
||||
|
||||
export interface SessionStreamState {
|
||||
/** View model (updated in place; version bump triggers re-render). */
|
||||
model: StreamModel;
|
||||
version: number;
|
||||
/** True until history finishes loading. */
|
||||
loading: boolean;
|
||||
taskState: SessionStatus;
|
||||
/** approvalKey(origin, toolCallId) → pending approval. */
|
||||
pendingApprovals: ReadonlyMap<string, PendingApproval>;
|
||||
/** Recorded when this client clicks an approval decision (marks it as "manual"). */
|
||||
markLocalDecision: (toolCallId: string) => void;
|
||||
/** Removed from the pending table immediately after this client submits a decision (optimistic update; keyed by approvalKey). */
|
||||
resolveApproval: (key: string) => void;
|
||||
/** History load failure message (paired with retry to show a retry entry point). */
|
||||
error: string | null;
|
||||
/** Re-fetch history (only meaningful after a load failure). */
|
||||
retry: () => void;
|
||||
}
|
||||
|
||||
const EMPTY_PENDING: ReadonlyMap<string, PendingApproval> = new Map();
|
||||
|
||||
export function useSessionStream(
|
||||
sessionId: string | null,
|
||||
initialStatus: SessionStatus,
|
||||
/** Notification of a server auto-generated title (for updating the list in place); held in a ref, doesn't trigger a reconnect. */
|
||||
onSessionTitle?: (sessionId: string, title: string) => void,
|
||||
/** New session has been registered (sub-sessions are pushed over the parent session's channel); held in a ref, doesn't trigger a reconnect. */
|
||||
onSessionCreated?: () => void,
|
||||
): SessionStreamState {
|
||||
const [version, setVersion] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [taskState, setTaskState] = useState<SessionStatus>(initialStatus);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingTick, setPendingTick] = useState(0);
|
||||
const onTitleRef = useRef(onSessionTitle);
|
||||
onTitleRef.current = onSessionTitle;
|
||||
const onCreatedRef = useRef(onSessionCreated);
|
||||
onCreatedRef.current = onSessionCreated;
|
||||
|
||||
const controllerRef = useRef<StreamController | null>(null);
|
||||
// Empty model placeholder before the controller is established (first frame).
|
||||
const placeholderRef = useRef<StreamModel | null>(null);
|
||||
if (placeholderRef.current === null) placeholderRef.current = createStreamModel();
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
// Coalesce high-frequency deltas: multiple pushes within one frame trigger only a single re-render.
|
||||
const bump = useCallback(() => {
|
||||
if (rafRef.current !== null) return;
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
setVersion((v) => v + 1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
// Draft state (no session to connect to): reset to idle. If the previous session's running
|
||||
// state lingers, the draft input area would misjudge "still running" and disable sending —
|
||||
// so "A is still running" would block sending in the new draft B after switching.
|
||||
controllerRef.current?.dispose();
|
||||
controllerRef.current = null;
|
||||
setTaskState("idle");
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setPendingTick((t) => t + 1);
|
||||
setVersion((v) => v + 1);
|
||||
return;
|
||||
}
|
||||
setVersion((v) => v + 1);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// First-frame placeholder: the task_state snapshot from the stream (pushed on subscribe)
|
||||
// subsequently overrides it as the authoritative state.
|
||||
setTaskState(initialStatus);
|
||||
setPendingTick((t) => t + 1);
|
||||
|
||||
const controller = createStreamController({
|
||||
loadMessages: async () => (await getMessages(sessionId)).messages,
|
||||
onTaskState: setTaskState,
|
||||
onLoading: setLoading,
|
||||
onError: setError,
|
||||
onModelChange: bump,
|
||||
onPendingChange: () => setPendingTick((t) => t + 1),
|
||||
onSessionTitle: (sid, title) => onTitleRef.current?.(sid, title),
|
||||
onSessionCreated: () => onCreatedRef.current?.(),
|
||||
});
|
||||
controllerRef.current = controller;
|
||||
|
||||
// Connect-first: subscribe to the stream before fetching history.
|
||||
const conn = openSessionStream(sessionId, {
|
||||
onOmniMessage: controller.handleOmni,
|
||||
onServerEvent: controller.handleServer,
|
||||
// EventSource can't read the status code: when the connection is judged a fatal error and
|
||||
// closes, probe once with GET /api/me; if the session has expired (401), the client's
|
||||
// global handler clears the user and redirects to the login page.
|
||||
onError: (closed) => {
|
||||
if (closed) void getMe().catch(() => undefined);
|
||||
},
|
||||
});
|
||||
void controller.load();
|
||||
|
||||
return () => {
|
||||
controller.dispose();
|
||||
conn.close();
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
// initialStatus only serves as the first-frame placeholder; it doesn't rebuild the connection
|
||||
// on parent re-renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId]);
|
||||
|
||||
const markLocalDecision = useCallback((toolCallId: string) => {
|
||||
controllerRef.current?.markLocalDecision(toolCallId);
|
||||
}, []);
|
||||
|
||||
const resolveApproval = useCallback((key: string) => {
|
||||
controllerRef.current?.resolveApproval(key);
|
||||
}, []);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
void controllerRef.current?.retry();
|
||||
}, []);
|
||||
|
||||
// pendingTick participates in the render dependencies, ensuring pending-table changes trigger a re-render.
|
||||
void pendingTick;
|
||||
|
||||
return {
|
||||
model: controllerRef.current?.model ?? placeholderRef.current,
|
||||
version,
|
||||
loading,
|
||||
taskState,
|
||||
pendingApprovals: controllerRef.current?.pendingApprovals ?? EMPTY_PENDING,
|
||||
markLocalDecision,
|
||||
resolveApproval,
|
||||
error,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* "Reasoning & Tools" group: collapses a run of consecutive thinking +
|
||||
* tool-call items into one aggregated group.
|
||||
* Expand policy: the group defaults to expanded while it's the last segment of the message
|
||||
* stream (the current turn still in progress), and defaults to collapsed once later messages
|
||||
* push it away from the end (turn finished); a manual toggle by the user is respected afterward.
|
||||
* A pending approval **forces it open** — otherwise the approval buttons would be unreachable.
|
||||
*
|
||||
* Hierarchy: the group header is **a distinct title bar** (solid light-gray background + small
|
||||
* uppercase status text), and the thinking/tool-call rows inside the group sit on the white
|
||||
* area below it — the two are deliberately different layers, otherwise "Running"/"Done" would
|
||||
* blend visually with the step rows and the parent-child relationship would be unreadable.
|
||||
*
|
||||
* Status semantics: the group only counts as "Done" once the model stops calling tools. As long
|
||||
* as this is still the last segment and the Task is running, the model could add another step
|
||||
* at any moment (there can be a brief gap with no active item between two steps), so it always
|
||||
* shows "Running"; it flips to "Done" only once a later message (e.g. body text) pushes the
|
||||
* group away from the end, or the Task has actually finished.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { S } from "../../lib/strings";
|
||||
import { humanizeDuration } from "../../lib/format";
|
||||
import { Chevron } from "../../components/ui/chevron";
|
||||
import { StatusIcon } from "../../components/ui/status-icon";
|
||||
import { approvalKey } from "../../lib/omni/stream-model";
|
||||
import type { ChatItem } from "../../lib/omni/stream-model";
|
||||
import { MessageItem } from "./message-item";
|
||||
import type { StreamRenderContext } from "./message-stream";
|
||||
import { summarizeWork } from "./work-summary";
|
||||
|
||||
/** Item kinds that belong in the group: thinking and tool calls (subagent cards are nested inside the run_subagent tool card, not listed separately). */
|
||||
export function isWorkItem(item: ChatItem): boolean {
|
||||
return item.kind === "thinking" || item.kind === "tool_call";
|
||||
}
|
||||
|
||||
/** Whether an item is still in progress (drives spinner display): streaming, executing, or has a pending approval. */
|
||||
function itemActive(item: ChatItem, ctx: StreamRenderContext): boolean {
|
||||
if (item.kind === "thinking") return item.streaming;
|
||||
if (item.kind === "tool_call") {
|
||||
if (item.callStreaming || item.outputStreaming) return true;
|
||||
if (item.callComplete && !item.outputComplete) return true;
|
||||
return ctx.pendingApprovals.has(approvalKey(ctx.origin, item.toolCallId));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether the group contains a pending approval (used to force it open, ensuring the approval buttons stay reachable). */
|
||||
function hasPendingApproval(items: ChatItem[], ctx: StreamRenderContext): boolean {
|
||||
return items.some(
|
||||
(it) =>
|
||||
it.kind === "tool_call" && ctx.pendingApprovals.has(approvalKey(ctx.origin, it.toolCallId)),
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkGroup({
|
||||
items,
|
||||
ctx,
|
||||
isLast,
|
||||
}: {
|
||||
items: ChatItem[];
|
||||
ctx: StreamRenderContext;
|
||||
/** Whether this group is the last segment of the message stream (current turn still in progress): decides the default expanded/collapsed state. */
|
||||
isLast: boolean;
|
||||
}) {
|
||||
// Last segment + Task running = the model might still call another tool → show Running (even if there's no active item right now).
|
||||
const active = (isLast && ctx.taskRunning) || items.some((it) => itemActive(it, ctx));
|
||||
const pending = hasPendingApproval(items, ctx);
|
||||
const [open, setOpen] = useState(isLast);
|
||||
const userToggled = useRef(false);
|
||||
|
||||
// Before any manual toggle, follow "is last segment": expanded while in progress (last
|
||||
// segment), collapsed once pushed away from the end by later messages (turn finished).
|
||||
// Deliberately not driven by per-item active — there can be a brief gap with no active item
|
||||
// between two steps within a turn, and collapsing on that basis would flicker on every step
|
||||
// and lose the internal expanded state.
|
||||
useEffect(() => {
|
||||
if (!userToggled.current) setOpen(isLast);
|
||||
}, [isLast]);
|
||||
|
||||
// A pending approval must stay actionable: expand the group body regardless of collapsed state (the approval row lives inside it).
|
||||
const shown = open || pending;
|
||||
const { steps, durationMs } = summarizeWork(items);
|
||||
|
||||
return (
|
||||
<div className="anim-msg my-2 overflow-hidden rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
{/* Group header: a distinct title bar (solid background), on a separate layer from the step rows below it */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={shown}
|
||||
onClick={() => {
|
||||
userToggled.current = true;
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 bg-gray-50 px-3 py-2 text-left transition-colors duration-150 hover:bg-gray-100 dark:bg-gray-900/60 dark:hover:bg-gray-800/60"
|
||||
>
|
||||
<StatusIcon state={active ? "running" : "done"} size={12} />
|
||||
{/* The title doubles as status: "Running" while in progress, "Done" when finished. */}
|
||||
<span
|
||||
className={`shrink-0 text-[11px] font-semibold uppercase tracking-wide ${active ? "text-emerald-600 dark:text-emerald-400" : "text-gray-500 dark:text-gray-400"}`}
|
||||
>
|
||||
{active ? S.chat.workRunning : S.chat.workDone}
|
||||
</span>
|
||||
{/* A pure-thinking group (no tool calls) doesn't show "0 steps". */}
|
||||
{steps > 0 && (
|
||||
<span className="shrink-0 font-mono text-xs text-gray-400">
|
||||
{S.chat.workGroupSteps(steps)}
|
||||
</span>
|
||||
)}
|
||||
{durationMs > 0 && (
|
||||
<span className="shrink-0 font-mono text-xs text-gray-400">
|
||||
{humanizeDuration(durationMs)}
|
||||
</span>
|
||||
)}
|
||||
{pending && !shown && (
|
||||
<span className="shrink-0 rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-700 dark:bg-amber-950/50 dark:text-amber-300">
|
||||
{S.chat.approvalWaiting}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1" />
|
||||
<Chevron open={shown} className="text-gray-400" />
|
||||
</button>
|
||||
{shown && (
|
||||
<div className="anim-fade divide-y divide-gray-100 border-t border-gray-200 dark:divide-gray-800/60 dark:border-gray-800">
|
||||
{items.map((item) => (
|
||||
<MessageItem key={item.id} item={item} ctx={ctx} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Summary for the "Reasoning & Tools" group header (pure logic, unit-testable): step count only
|
||||
* counts **tool calls** (thinking doesn't count as a step).
|
||||
*
|
||||
* Duration is computed as the **union of time intervals**: overlapping time from parallel tool
|
||||
* calls is counted only once, presenting the group's wall-clock working time rather than the sum
|
||||
* of each item's duration (naive per-item summation would report 15-way parallel work spanning
|
||||
* 9 minutes as 99 minutes). Intervals follow the same settlement convention as each item's
|
||||
* durationMs (see settleToolDuration in stream-model.ts): thinking is [startedAtMs, +durationMs];
|
||||
* a tool's durationMs = argument-generation segment + execution segment (approval wait time is
|
||||
* excluded), and the two segments are not adjacent on the timeline, so they must be split back
|
||||
* into two intervals using the same formula: [argStartedAtMs, +generation segment] and
|
||||
* [approvalAtMs ?? callStartedAtMs, +execution segment] — computing the whole span from the
|
||||
* execution start point would shift the generation segment into the execution period, producing
|
||||
* false overlap with parallel tools. Gaps between intervals (waiting for the model's next step)
|
||||
* are not counted; a segment missing a start point can't be checked for overlap and falls back to
|
||||
* plain summation.
|
||||
*/
|
||||
import type { ChatItem } from "../../lib/omni/stream-model";
|
||||
|
||||
export function summarizeWork(items: ChatItem[]): { steps: number; durationMs: number } {
|
||||
let steps = 0;
|
||||
let durationMs = 0;
|
||||
const intervals: [number, number][] = [];
|
||||
const add = (startMs: number | undefined, spanMs: number) => {
|
||||
if (spanMs <= 0) return;
|
||||
if (startMs === undefined) durationMs += spanMs;
|
||||
else intervals.push([startMs, startMs + spanMs]);
|
||||
};
|
||||
for (const it of items) {
|
||||
if (it.kind === "thinking") {
|
||||
if (it.durationMs !== undefined) add(it.startedAtMs, it.durationMs);
|
||||
continue;
|
||||
}
|
||||
if (it.kind !== "tool_call") continue;
|
||||
steps += 1;
|
||||
if (it.durationMs === undefined) continue;
|
||||
const genMs =
|
||||
it.argStartedAtMs !== undefined && it.callStartedAtMs !== undefined
|
||||
? Math.min(it.durationMs, Math.max(0, it.callStartedAtMs - it.argStartedAtMs))
|
||||
: 0;
|
||||
add(it.argStartedAtMs, genMs);
|
||||
add(it.approvalAtMs ?? it.callStartedAtMs, it.durationMs - genMs);
|
||||
}
|
||||
intervals.sort((a, b) => a[0] - b[0]);
|
||||
let curStart: number | null = null;
|
||||
let curEnd = 0;
|
||||
for (const [start, end] of intervals) {
|
||||
if (curStart === null || start > curEnd) {
|
||||
if (curStart !== null) durationMs += curEnd - curStart;
|
||||
curStart = start;
|
||||
curEnd = end;
|
||||
} else if (end > curEnd) {
|
||||
curEnd = end;
|
||||
}
|
||||
}
|
||||
if (curStart !== null) durationMs += curEnd - curStart;
|
||||
return { steps, durationMs };
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
/**
|
||||
* Workspace file browser (the "Browse All" tab of the Files panel): directory navigation
|
||||
* (breadcrumbs, going up a level navigates via a breadcrumb segment), file list (name/size/
|
||||
* modified time), preview (Markdown/HTML default to a rendered view + a source toggle, text/
|
||||
* images shown inline, other types prompt a download), upload (multi-select in the current
|
||||
* directory, single file <=14MB) and download. Path scoping is validated by the server (including
|
||||
* auto-creating missing parent directories within the sandbox, an API-level capability).
|
||||
*
|
||||
* Single-column list <-> preview drill-down (no side-by-side layout): the panel's width is
|
||||
* controlled by the outer Files panel and may be much narrower than the viewport, so `lg:`-style
|
||||
* viewport breakpoints would misjudge things here (a wide viewport doesn't mean this component
|
||||
* got a wide allotment of space) — so the list and preview are shown mutually exclusively,
|
||||
* routed by whether the existing `preview` is null, without introducing extra state.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ChangeEvent } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { SessionInfo, WorkspaceFilesResponse } from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { formatBytes, formatDateTime } from "../../lib/format";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Dropdown } from "../../components/ui/dropdown";
|
||||
import { SkeletonList } from "../../components/ui/skeleton";
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
const TEXT_EXTS = new Set([
|
||||
"txt",
|
||||
"md",
|
||||
"json",
|
||||
"js",
|
||||
"mjs",
|
||||
"cjs",
|
||||
"ts",
|
||||
"tsx",
|
||||
"jsx",
|
||||
"py",
|
||||
"sh",
|
||||
"bash",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"css",
|
||||
"html",
|
||||
"htm",
|
||||
"csv",
|
||||
"log",
|
||||
"xml",
|
||||
"ini",
|
||||
"conf",
|
||||
"rs",
|
||||
"go",
|
||||
"java",
|
||||
"c",
|
||||
"h",
|
||||
"cpp",
|
||||
"hpp",
|
||||
"sql",
|
||||
"rb",
|
||||
"php",
|
||||
"gitignore",
|
||||
"env",
|
||||
]);
|
||||
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg"]);
|
||||
const HTML_EXTS = new Set(["html", "htm"]);
|
||||
/** Read cap for text preview (beyond this, truncated with a download prompt). */
|
||||
const TEXT_PREVIEW_LIMIT = 256 * 1024;
|
||||
/** Source highlighting cap: tokenizing the full 256KB preview cap's worth of content in one go would block the main thread, so beyond this it falls back to unhighlighted. */
|
||||
const HIGHLIGHT_LIMIT = 64 * 1024;
|
||||
|
||||
/** Extension -> Shiki language id; extensions not listed are highlighted as "text" (plain text
|
||||
* with the theme's background color); an id Shiki doesn't recognize is caught by CodeBlock and falls back to an unhighlighted <pre>. */
|
||||
const SHIKI_LANG_BY_EXT: Record<string, string> = {
|
||||
html: "html",
|
||||
htm: "html",
|
||||
css: "css",
|
||||
js: "javascript",
|
||||
mjs: "javascript",
|
||||
cjs: "javascript",
|
||||
jsx: "jsx",
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
json: "json",
|
||||
md: "markdown",
|
||||
py: "python",
|
||||
rb: "ruby",
|
||||
php: "php",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
java: "java",
|
||||
c: "c",
|
||||
h: "c",
|
||||
cpp: "cpp",
|
||||
hpp: "cpp",
|
||||
sh: "shellscript",
|
||||
bash: "shellscript",
|
||||
yml: "yaml",
|
||||
yaml: "yaml",
|
||||
toml: "toml",
|
||||
ini: "ini",
|
||||
conf: "ini",
|
||||
xml: "xml",
|
||||
sql: "sql",
|
||||
log: "log",
|
||||
};
|
||||
|
||||
function langForExt(ext: string): string {
|
||||
return SHIKI_LANG_BY_EXT[ext] ?? "text";
|
||||
}
|
||||
|
||||
function extOf(name: string): string {
|
||||
const i = name.lastIndexOf(".");
|
||||
return i >= 0 ? name.slice(i + 1).toLowerCase() : name.toLowerCase();
|
||||
}
|
||||
|
||||
function joinPath(dir: string, name: string): string {
|
||||
return dir === "" ? name : `${dir}/${name}`;
|
||||
}
|
||||
|
||||
function dirOf(filePath: string): string {
|
||||
return filePath.includes("/") ? filePath.slice(0, filePath.lastIndexOf("/")) : "";
|
||||
}
|
||||
|
||||
/** An external reference with a scheme (http(s)/mailto/data, etc.), passed through as-is in the md rendered view. */
|
||||
const EXTERNAL_REF_RE = /^[a-z][a-z0-9+.-]*:/i;
|
||||
|
||||
/** Resolves relative references (image src / link href) within the md rendered view: based on
|
||||
* the md file's directory, handling ./ and ../ (clamped to the root if it would go past it);
|
||||
* a leading "/" is treated as the Workspace root. */
|
||||
function resolveRelative(baseDir: string, ref: string): string {
|
||||
const out = ref.startsWith("/") || baseDir === "" ? [] : baseDir.split("/");
|
||||
for (const seg of ref.split("/")) {
|
||||
if (seg === "" || seg === ".") continue;
|
||||
if (seg === "..") out.pop();
|
||||
else out.push(seg);
|
||||
}
|
||||
return out.join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage shim injected into the HTML preview: when the sandbox lacks allow-same-origin, the
|
||||
* iframe has an opaque origin, and accessing localStorage/sessionStorage throws a SecurityError
|
||||
* that halts scripts. The shim runs before any page script and falls back to a synchronous
|
||||
* in-memory implementation (substituted only when the native access throws), preserving sandbox
|
||||
* isolation while letting the page's scripts run normally.
|
||||
*/
|
||||
const STORAGE_SHIM =
|
||||
"<script>(function(){function mk(){var m={};return{getItem:function(k){return k in m?m[k]:null}," +
|
||||
"setItem:function(k,v){m[k]=String(v)},removeItem:function(k){delete m[k]},clear:function(){m={}}," +
|
||||
"key:function(i){return Object.keys(m)[i]||null},get length(){return Object.keys(m).length}}}" +
|
||||
"['localStorage','sessionStorage'].forEach(function(n){try{window[n].length}catch(e){" +
|
||||
"Object.defineProperty(window,n,{value:mk(),configurable:true})}})})();</script>";
|
||||
|
||||
/** Injects the storage shim at the earliest possible script position in the HTML (right after <head>, otherwise right after <html>, otherwise at the very start). */
|
||||
function withStorageShim(html: string): string {
|
||||
if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + STORAGE_SHIM);
|
||||
if (/<html[^>]*>/i.test(html)) return html.replace(/<html[^>]*>/i, (m) => m + STORAGE_SHIM);
|
||||
return STORAGE_SHIM + html;
|
||||
}
|
||||
|
||||
interface Preview {
|
||||
path: string;
|
||||
name: string;
|
||||
kind: "text" | "md" | "image" | "html" | "pdf" | "unsupported";
|
||||
/** Content for kind=text/md/html (may be truncated). */
|
||||
content?: string;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
export function WorkspaceBrowser({
|
||||
session,
|
||||
openRequest,
|
||||
active,
|
||||
onPreviewOpen,
|
||||
}: {
|
||||
session: SessionInfo;
|
||||
/** External navigation command (from clicking a file chip in a message): navigates to the
|
||||
* directory and previews that path. Triggers again whenever the object reference changes,
|
||||
* even if path is the same as last time (clicking the same file again must still re-locate it). */
|
||||
openRequest?: { path: string } | null;
|
||||
/** Whether the panel is visible: when collapsed in the docked state, the component stays
|
||||
* mounted (width 0), during which the list can go stale as the Agent writes files; a refresh
|
||||
* is issued right at the moment it transitions from hidden to visible. */
|
||||
active?: boolean;
|
||||
/** Callback when entering file preview (used by the mobile Sheet to raise its snap point to full). */
|
||||
onPreviewOpen?: () => void;
|
||||
}) {
|
||||
const [path, setPath] = useState("");
|
||||
const [data, setData] = useState<WorkspaceFilesResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [reloadTick, setReloadTick] = useState(0);
|
||||
const [showPath, setShowPath] = useState(false);
|
||||
/** HTML / Markdown preview: rendered view (HTML via sandboxed iframe, Markdown via md-body) / source toggle. */
|
||||
const [richView, setRichView] = useState<"rendered" | "source">("rendered");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
api
|
||||
.listWorkspaceFiles(session.sessionId, path)
|
||||
.then((res) => {
|
||||
if (!cancelled) setData(res);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) setError(e instanceof ApiError ? e.message : S.files.loadFailed);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session.sessionId, path, reloadTick]);
|
||||
|
||||
// Returns to the root directory and clears the preview when the Session changes.
|
||||
useEffect(() => {
|
||||
setPath("");
|
||||
setPreview(null);
|
||||
setData(null);
|
||||
setNotice(null);
|
||||
}, [session.sessionId]);
|
||||
|
||||
// Edge-triggered refresh on the panel's hidden -> visible transition (doesn't count the initial mount: mounting itself already fetches once).
|
||||
const prevActive = useRef(active);
|
||||
useEffect(() => {
|
||||
if (active && !prevActive.current) setReloadTick((t) => t + 1);
|
||||
prevActive.current = active;
|
||||
}, [active]);
|
||||
|
||||
/** The preview callback goes through a ref: keeps previewPath's useCallback dependency stable,
|
||||
* so even if the parent passes an inline arrow function, the openRequest locate effect doesn't
|
||||
* replay just because previewPath's reference changed. */
|
||||
const onPreviewOpenRef = useRef(onPreviewOpen);
|
||||
onPreviewOpenRef.current = onPreviewOpen;
|
||||
|
||||
const previewPath = useCallback(
|
||||
async (filePath: string) => {
|
||||
onPreviewOpenRef.current?.();
|
||||
const name = filePath.includes("/")
|
||||
? filePath.slice(filePath.lastIndexOf("/") + 1)
|
||||
: filePath;
|
||||
const ext = extOf(name);
|
||||
setRichView("rendered");
|
||||
if (IMAGE_EXTS.has(ext)) {
|
||||
setPreview({ path: filePath, name, kind: "image" });
|
||||
return;
|
||||
}
|
||||
// PDF: the server returns it inline as application/pdf, embedded directly in an iframe and rendered by the browser.
|
||||
if (ext === "pdf") {
|
||||
setPreview({ path: filePath, name, kind: "pdf" });
|
||||
return;
|
||||
}
|
||||
const isHtml = HTML_EXTS.has(ext);
|
||||
const isMd = ext === "md";
|
||||
if (!isHtml && !TEXT_EXTS.has(ext)) {
|
||||
setPreview({ path: filePath, name, kind: "unsupported" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// The server downgrades html/svg served inline to text/plain (a same-origin XSS
|
||||
// defense); this fetches the raw content back, and the HTML rendered view is placed in a
|
||||
// sandboxed iframe (without allow-scripts), so scripts don't execute.
|
||||
const res = await fetch(api.workspaceFileUrl(session.sessionId, filePath), {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const full = await res.text();
|
||||
const truncated = full.length > TEXT_PREVIEW_LIMIT;
|
||||
// Oversized Markdown defaults to the source view (benefiting from the unhighlighted
|
||||
// highlight=false path): feeding the whole block to remark for parsing is a one-time
|
||||
// main-thread cost; the user can still manually switch to "rendered view" as an informed choice.
|
||||
if (isMd && full.length > HIGHLIGHT_LIMIT) setRichView("source");
|
||||
setPreview({
|
||||
path: filePath,
|
||||
name,
|
||||
kind: isHtml ? "html" : isMd ? "md" : "text",
|
||||
content: truncated ? full.slice(0, TEXT_PREVIEW_LIMIT) : full,
|
||||
truncated,
|
||||
});
|
||||
} catch {
|
||||
setPreview({ path: filePath, name, kind: "unsupported" });
|
||||
}
|
||||
},
|
||||
[session.sessionId],
|
||||
);
|
||||
|
||||
// External navigation command (clicking a file chip in a message / a file card): navigates to
|
||||
// the directory and previews the target path. Also refreshes the list: the target is most
|
||||
// likely a file the Agent just wrote, so the cached list is very likely stale; and when it's
|
||||
// the same directory, setPath is a same-value no-op that won't trigger the fetch effect, so it must be explicitly bumped.
|
||||
useEffect(() => {
|
||||
if (!openRequest) return;
|
||||
const target = openRequest.path;
|
||||
const dir = target.includes("/") ? target.slice(0, target.lastIndexOf("/")) : "";
|
||||
setPath(dir);
|
||||
setReloadTick((t) => t + 1);
|
||||
void previewPath(target);
|
||||
}, [openRequest, previewPath]);
|
||||
|
||||
const openEntry = (name: string) => {
|
||||
void previewPath(joinPath(path, name));
|
||||
};
|
||||
|
||||
const onUpload = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
setUploading(true);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
for (const file of files) {
|
||||
const b64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const url = reader.result as string;
|
||||
resolve(url.slice(url.indexOf(",") + 1)); // Strip the data:...;base64, prefix
|
||||
};
|
||||
reader.onerror = () => reject(new Error("read failed"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
await api.uploadWorkspaceFile(session.sessionId, joinPath(path, file.name), b64);
|
||||
}
|
||||
setNotice(S.files.uploaded);
|
||||
setReloadTick((t) => t + 1);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : S.common.unknownError);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
})();
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const crumbs = path === "" ? [] : path.split("/");
|
||||
|
||||
if (preview !== null) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* flex-wrap: the panel can be dragged down to a 320px minimum width, narrower than this
|
||||
row's uncompressible content (back + view toggle + download ~= 370px+); without
|
||||
wrapping, the panel's overflow-hidden would clip the right-side buttons off. */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-gray-200 px-3 py-2 dark:border-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Refreshes in passing when returning to the list: the Agent may have written new files during the preview.
|
||||
setPreview(null);
|
||||
setReloadTick((t) => t + 1);
|
||||
}}
|
||||
title={S.files.backToList}
|
||||
className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-sm text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
{S.files.backToList}
|
||||
</button>
|
||||
{/* Shows only the filename (full path goes into the title hover tooltip): the
|
||||
directory prefix and extension badge are both information the filename already
|
||||
carries, and on a narrow panel they'd just crowd out the title space. */}
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-sm font-semibold"
|
||||
title={preview.path}
|
||||
>
|
||||
{preview.name}
|
||||
</span>
|
||||
{/* HTML / Markdown: rendered view / source toggle */}
|
||||
{(preview.kind === "html" || preview.kind === "md") && (
|
||||
<div className="flex shrink-0 rounded-md bg-gray-100 p-0.5 dark:bg-gray-800">
|
||||
{(
|
||||
[
|
||||
["rendered", S.files.htmlRendered],
|
||||
["source", S.files.htmlSource],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setRichView(key)}
|
||||
className={`rounded px-2 py-0.5 text-xs transition-colors duration-150 ${
|
||||
richView === key
|
||||
? "bg-white font-medium text-gray-900 shadow-sm dark:bg-gray-600 dark:text-gray-100"
|
||||
: "text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<a
|
||||
href={api.workspaceFileUrl(session.sessionId, preview.path, true)}
|
||||
download={preview.name}
|
||||
className="inline-flex shrink-0 items-center rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm font-medium transition-colors duration-150 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:bg-gray-800"
|
||||
>
|
||||
{S.files.download}
|
||||
</a>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
{preview.kind === "image" ? (
|
||||
<img
|
||||
src={api.workspaceFileUrl(session.sessionId, preview.path)}
|
||||
alt={preview.name}
|
||||
className="max-w-full rounded-md border border-gray-200 dark:border-gray-800"
|
||||
/>
|
||||
) : preview.kind === "pdf" ? (
|
||||
<iframe
|
||||
src={api.workspaceFileUrl(session.sessionId, preview.path)}
|
||||
title={preview.name}
|
||||
className="h-full min-h-[60vh] w-full rounded-md border border-gray-200 dark:border-gray-800"
|
||||
/>
|
||||
) : preview.kind === "html" && richView === "rendered" ? (
|
||||
// sandbox allows scripts but **without allow-same-origin**: the iframe has an opaque
|
||||
// origin, so scripts can run to fully render the page, yet can't read the app's
|
||||
// same-origin cookies / DOM (an XSS defense). The storage shim is injected to avoid
|
||||
// a SecurityError when a script accesses localStorage from an opaque origin.
|
||||
<iframe
|
||||
srcDoc={withStorageShim(preview.content ?? "")}
|
||||
title={preview.name}
|
||||
sandbox="allow-scripts"
|
||||
className="h-full min-h-[60vh] w-full rounded-md border border-gray-200 bg-white dark:border-gray-800"
|
||||
/>
|
||||
) : preview.kind === "md" && richView === "rendered" ? (
|
||||
// Markdown's default rendered view: uses the same md-body layout as message bodies
|
||||
// (ReactMarkdown outputs pure static HTML with no script execution surface, so no iframe sandbox is needed).
|
||||
<>
|
||||
<div className="md-body text-base leading-relaxed text-gray-800 dark:text-gray-100">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// Relative images are resolved against the md file's directory into the file API (otherwise resolving against the app's origin would always 404).
|
||||
img: ({ src, alt }) => (
|
||||
<img
|
||||
src={
|
||||
typeof src === "string" && !EXTERNAL_REF_RE.test(src)
|
||||
? api.workspaceFileUrl(
|
||||
session.sessionId,
|
||||
resolveRelative(dirOf(preview.path), src),
|
||||
)
|
||||
: src
|
||||
}
|
||||
alt={alt ?? ""}
|
||||
loading="lazy"
|
||||
className="max-w-full"
|
||||
/>
|
||||
),
|
||||
// External links open in a new tab; relative links point to a Workspace
|
||||
// file, clicking switches the preview directly; in-page anchors keep default behavior.
|
||||
a: ({ href, children }) => {
|
||||
if (typeof href !== "string" || href.startsWith("#")) {
|
||||
return <a href={href}>{children}</a>;
|
||||
}
|
||||
if (EXTERNAL_REF_RE.test(href)) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
const target = resolveRelative(dirOf(preview.path), href);
|
||||
return (
|
||||
<a
|
||||
href={api.workspaceFileUrl(session.sessionId, target)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void previewPath(target);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{preview.content ?? ""}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
{preview.truncated && (
|
||||
<p className="mt-1 text-xs text-gray-400">… {S.files.previewTruncated}</p>
|
||||
)}
|
||||
</>
|
||||
) : preview.kind === "text" || preview.kind === "html" || preview.kind === "md" ? (
|
||||
// The source view reuses the message stream's CodeBlock: Shiki dual-theme
|
||||
// highlighting + language label + copy button, no line wrapping, horizontal scroll
|
||||
// instead (wrapping code is a disaster for readability, see the old mobile styling).
|
||||
<>
|
||||
<CodeBlock
|
||||
language={langForExt(extOf(preview.name))}
|
||||
code={preview.content ?? ""}
|
||||
highlight={(preview.content?.length ?? 0) <= HIGHLIGHT_LIMIT}
|
||||
/>
|
||||
{preview.truncated && (
|
||||
<p className="mt-1 text-xs text-gray-400">… {S.files.previewTruncated}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{S.files.previewUnsupported}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Toolbar: breadcrumbs + actions */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1 border-b border-gray-200 px-3 py-2 dark:border-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPath("");
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 text-sm text-gray-600 transition-colors duration-150 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
{S.files.root}
|
||||
</button>
|
||||
{crumbs.map((seg, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<span className="text-gray-300 dark:text-gray-700">/</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPath(crumbs.slice(0, i + 1).join("/"))}
|
||||
className="max-w-32 truncate rounded px-1 py-0.5 text-sm text-gray-600 transition-colors duration-150 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
{seg}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<span className="flex-1" />
|
||||
{/* Details: a popup card showing the full absolute Workspace path (break-all wraps in full, never truncated). */}
|
||||
<Dropdown
|
||||
open={showPath}
|
||||
setOpen={setShowPath}
|
||||
menuClass="right-0 top-full mt-1 w-max max-w-72 origin-top-right"
|
||||
button={
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showPath ? "primary" : "ghost"}
|
||||
onClick={() => setShowPath((v) => !v)}
|
||||
>
|
||||
{S.files.details}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="px-3.5 py-2.5">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{S.files.workspacePath}
|
||||
</p>
|
||||
<p className="mt-1 break-all font-mono text-xs leading-5">{session.workspace}</p>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<Button size="sm" variant="ghost" onClick={() => setReloadTick((t) => t + 1)}>
|
||||
{S.files.refresh}
|
||||
</Button>
|
||||
{/* Matches the same visual style and font size (sm = text-xs) as the adjacent ghost Buttons (Details/Refresh): no border, light background on hover. */}
|
||||
<label className="inline-flex cursor-pointer items-center rounded-md border border-transparent bg-transparent px-2.5 py-1 text-xs font-medium text-gray-600 transition-colors duration-150 focus-within:ring-2 focus-within:ring-gray-400/30 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100">
|
||||
{/* sr-only rather than hidden: keyboard users can still Tab-focus it (display:none would remove it from the focus order). */}
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="sr-only"
|
||||
onChange={onUpload}
|
||||
disabled={uploading}
|
||||
/>
|
||||
{uploading ? S.common.saving : S.files.upload}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<p className="px-3 py-3 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
) : data === null ? (
|
||||
<SkeletonList rows={6} />
|
||||
) : data.entries.length === 0 ? (
|
||||
<p className="px-3 py-3 text-sm text-gray-400">{S.files.empty}</p>
|
||||
) : (
|
||||
// No "up a level" row: going up a level is done via the toolbar breadcrumbs (root / any segment is clickable).
|
||||
<ul className="divide-y divide-gray-100 dark:divide-gray-800/60">
|
||||
{data.entries.map((entry) => (
|
||||
<li key={entry.name}>
|
||||
<div className="group flex items-center gap-2 px-3 py-1.5 transition-colors duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
entry.kind === "dir"
|
||||
? setPath(joinPath(path, entry.name))
|
||||
: openEntry(entry.name)
|
||||
}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
title={entry.name}
|
||||
>
|
||||
<span className="shrink-0 text-gray-400" aria-hidden>
|
||||
{entry.kind === "dir" ? (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
>
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
>
|
||||
<path d="M6 3h8l4 4v14H6zM14 3v4h4" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{entry.name}</span>
|
||||
</button>
|
||||
<span className="shrink-0 font-mono text-xs text-gray-400 dark:text-gray-500">
|
||||
{entry.kind === "file" ? formatBytes(entry.sizeBytes) : ""}
|
||||
</span>
|
||||
<span className="hidden shrink-0 font-mono text-xs text-gray-400 sm:block dark:text-gray-500">
|
||||
{entry.mtime ? formatDateTime(entry.mtime) : ""}
|
||||
</span>
|
||||
{entry.kind === "file" && (
|
||||
<a
|
||||
href={api.workspaceFileUrl(
|
||||
session.sessionId,
|
||||
joinPath(path, entry.name),
|
||||
true,
|
||||
)}
|
||||
download={entry.name}
|
||||
title={S.files.download}
|
||||
className="shrink-0 rounded p-1 text-gray-300 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-700 group-hover:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 21h16" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{notice && (
|
||||
<p className="px-3 py-2 text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Search filtering and grouping-by-provider for the models page (pure functions, easy to
|
||||
* unit test): grouping uses the entry's **provider field** directly ((provider, model_id) is
|
||||
* the entry's unique key, with no `<provider>/<id>` concatenation anywhere in the pipeline).
|
||||
* A provider not in the catalog list is a **user-defined group**: each
|
||||
* forms its own group, keeping its original value, with OpenAI protocol semantics (env
|
||||
* fallback OPENAI_*), sorted by name and appended after custom. Matches model_id /
|
||||
* display name / provider / vendor name case-insensitively; built-in group order follows
|
||||
* the MODEL_PROVIDERS definition. Empty groups aren't returned, except the custom group,
|
||||
* which is always shown when there's no search query (rendered even when empty, to host
|
||||
* the generic "add model" entry point).
|
||||
*/
|
||||
import { MODEL_PROVIDERS } from "@prismshadow/penguin-core/model-catalog";
|
||||
import type { ModelProviderInfo } from "@prismshadow/penguin-core/model-catalog";
|
||||
|
||||
/** Paired model reference (same shape as the server DTO's ModelRefDto; a model is always referenced as (provider, modelId)). */
|
||||
export interface ModelRefValue {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
/** Paired-reference equality (the sole comparison standard; either side missing counts as unequal). */
|
||||
export function sameModelRef(
|
||||
a: ModelRefValue | null | undefined,
|
||||
b: ModelRefValue | null | undefined,
|
||||
): boolean {
|
||||
return !!a && !!b && a.provider === b.provider && a.modelId === b.modelId;
|
||||
}
|
||||
|
||||
/** Minimal row shape needed for grouping/filtering (models-page's RowState and the DTO's ModelInfo are both supersets of this). */
|
||||
export interface ModelRowLike {
|
||||
/** Vendor id (entry field): a value not in the catalog list is a user-defined group, forming its own group while keeping its original value. */
|
||||
provider: string;
|
||||
/** Upstream model id (i.e. the stored model_id). */
|
||||
modelId: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
/** Synthesized vendor info for a user-defined group: OpenAI protocol semantics (env fallback OPENAI_*), no external links or gateway endpoint. */
|
||||
export function userProviderInfo(id: string): ModelProviderInfo {
|
||||
return { id, label: id, envKey: "OPENAI_API_KEY", envBaseUrlKey: "OPENAI_BASE_URL" };
|
||||
}
|
||||
|
||||
/** Case-insensitive match against model_id / display name / raw provider value / vendor display name; empty query always matches. */
|
||||
export function matchesQuery(row: ModelRowLike, query: string): boolean {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
const provider = MODEL_PROVIDERS.find((p) => p.id === row.provider);
|
||||
return (
|
||||
row.modelId.toLowerCase().includes(q) ||
|
||||
(row.displayName ?? "").toLowerCase().includes(q) ||
|
||||
row.provider.toLowerCase().includes(q) ||
|
||||
(provider?.label ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProviderGroup<T extends ModelRowLike> {
|
||||
provider: ModelProviderInfo;
|
||||
rows: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter + group by vendor; rows within a group keep their original order. Built-in groups
|
||||
* follow MODEL_PROVIDERS order (the custom group is returned even when empty, when there's
|
||||
* no search query); user-defined groups each form their own group, sorted by name and
|
||||
* appended after custom.
|
||||
*/
|
||||
export function groupModelRows<T extends ModelRowLike>(
|
||||
rows: T[],
|
||||
query: string,
|
||||
): ProviderGroup<T>[] {
|
||||
const searching = query.trim() !== "";
|
||||
const filtered = rows.filter((r) => matchesQuery(r, query));
|
||||
const builtin = MODEL_PROVIDERS.map((provider) => ({
|
||||
provider,
|
||||
rows: filtered.filter((r) => r.provider === provider.id),
|
||||
}));
|
||||
const extraIds = [
|
||||
...new Set(
|
||||
filtered.map((r) => r.provider).filter((p) => !MODEL_PROVIDERS.some((k) => k.id === p)),
|
||||
),
|
||||
].sort();
|
||||
const extras = extraIds.map((id) => ({
|
||||
provider: userProviderInfo(id),
|
||||
rows: filtered.filter((r) => r.provider === id),
|
||||
}));
|
||||
return [...builtin, ...extras].filter(
|
||||
(g) => g.rows.length > 0 || (!searching && g.provider.id === "custom"),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Skill icon component (shared by Skill library cards and the input area's Skill dropdown):
|
||||
* DTO icon (raw icon.svg from the Skill directory) is rendered inline once it passes
|
||||
* sanitizeSkillIcon (stroke uses currentColor, following text color); falls back to the
|
||||
* default book icon if missing or if validation fails (e.g. user-created Skills).
|
||||
*/
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import { BOOK_ICON } from "../chat/skill-use";
|
||||
import { sanitizeSkillIcon } from "./skill-icon";
|
||||
|
||||
export function SkillIcon({
|
||||
icon,
|
||||
size = 20,
|
||||
className = "",
|
||||
}: {
|
||||
icon?: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const safe = sanitizeSkillIcon(icon);
|
||||
if (!safe) return <GlyphIcon d={BOOK_ICON} size={size} className={className} />;
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{ width: size, height: size }}
|
||||
className={`block shrink-0 [&>svg]:block [&>svg]:h-full [&>svg]:w-full ${className}`}
|
||||
dangerouslySetInnerHTML={{ __html: safe }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Pre-render check for custom Skill icons (DTO `icon` field = raw icon.svg from the Skill
|
||||
* directory), used inline (shared by the Skills page and unit tests): the icon is inlined
|
||||
* into the page (inheriting currentColor), so any executable content must be rejected —
|
||||
* `<script`, event attributes (on*=), `<foreignObject>`, or any `href` attribute (icons
|
||||
* don't need links; this covers xlink:href and `<a href=…>`) all cause a fallback to the
|
||||
* default book icon; content whose root isn't `<svg>` also falls back.
|
||||
* Checks match the raw text case-insensitively (no DOM parsing: prefer false positives
|
||||
* over letting something through).
|
||||
*/
|
||||
|
||||
/** Event attribute (onclick / onload / …): attribute name preceded by whitespace/quote, followed by `=`. */
|
||||
const EVENT_ATTR = /[\s"'/]on[a-z]+\s*=/i;
|
||||
|
||||
/**
|
||||
* Any `href` attribute (regardless of xlink: prefix, case-insensitive, with or without quotes):
|
||||
* icons don't need links, so `href` can only carry risky content like javascript:/external links.
|
||||
*/
|
||||
const HREF_ATTR = /href\s*=/i;
|
||||
|
||||
/**
|
||||
* Returns the inlinable SVG source (trimmed) if validation passes, otherwise null
|
||||
* (caller falls back to the default book icon).
|
||||
*/
|
||||
export function sanitizeSkillIcon(svg: string | undefined): string | null {
|
||||
if (!svg) return null;
|
||||
const trimmed = svg.trim();
|
||||
if (!/^<svg[\s>]/i.test(trimmed)) return null;
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.includes("<script")) return null;
|
||||
if (lower.includes("foreignobject")) return null;
|
||||
if (HREF_ATTR.test(trimmed)) return null;
|
||||
if (EVENT_ATTR.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Skill library page: the @prismshadow/penguin-skills
|
||||
* Skill library, shown sectioned by skill group. Group styling matches the model
|
||||
* config page — the group header (group name + skill count, no icon) is
|
||||
* collapsible, highlights on hover, and animates height on expand/collapse;
|
||||
* expanded by default. Cards within a group form a grid (up to three per row on
|
||||
* wide screens, generously sized). Each card = an enlarged skill icon spanning
|
||||
* two rows (rounded border + light background; DTO icon = the raw icon.svg from
|
||||
* the catalog, rendered inline once it passes sanitize, otherwise falls back to
|
||||
* a default book icon) + a name (monospace) and short description on the right,
|
||||
* one line each (single-line truncation, falling back to the full description
|
||||
* when missing) + a metadata line (version · semantic update time · usage count
|
||||
* "used by N Agents"); group and card copy follow the UI language (localizedText /
|
||||
* localizedShortText), and groups have no description. Two **icon buttons** for
|
||||
* actions (copy goes into aria-label and title) —
|
||||
* - Paper plane "quick invoke": enters /chat/new draft mode with default_agent,
|
||||
* pre-selects the skill, and pre-fills the invocation text per UI language
|
||||
* (zh "使用 X 技能" / en "use the X skill", overwriting any existing draft body);
|
||||
* - Download "manage installs": a Modal listing every Agent in the current
|
||||
* Project — not-installed shows "安装"/"Install", installed shows
|
||||
* "已安装"/"Installed" (hover switches to "卸载"/"Uninstall", click to
|
||||
* uninstall); any member can operate it; optimistic update, a top-level
|
||||
* toast on success for install/uninstall, rollback plus a toast on failure.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import type { SkillGroupItem, SkillMetadataItem } from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { apiErrorText } from "../../lib/api-error";
|
||||
import { formatRelativeDate } from "../../lib/format";
|
||||
import { useDocumentTitle } from "../../lib/use-document-title";
|
||||
import { useAuth } from "../../state/auth";
|
||||
import { useLocale } from "../../state/locale";
|
||||
import { agentDisplayName, useProject } from "../../state/project";
|
||||
import { AgentAvatar } from "../../components/ui/agent-avatar";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Chevron } from "../../components/ui/chevron";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import { Modal } from "../../components/ui/modal";
|
||||
import { Skeleton, SkeletonCard } from "../../components/ui/skeleton";
|
||||
import { toastError, toastSuccess } from "../../components/ui/toast";
|
||||
import { DRAFT_SESSION_ID } from "../chat/chat-page";
|
||||
import { draftKey, loadDraft, saveDraft } from "../chat/draft-cache";
|
||||
import { localizedShortText, localizedText } from "../chat/skill-use";
|
||||
import { SkillIcon } from "./skill-icon-view";
|
||||
|
||||
/** agentId → set of installed skill names (in-page install-state snapshot, rewritten in place by optimistic updates). */
|
||||
type InstalledMap = ReadonlyMap<string, ReadonlySet<string>>;
|
||||
|
||||
/** "Quick invoke" button icon (paper plane, 24×24 line path; button shows only the icon, copy goes into aria/title). */
|
||||
const SEND_ICON = "M22 2 11 13M22 2 15 22 11 13 2 9 22 2";
|
||||
/** "Manage installs" button icon (download into tray, 24×24 line path). */
|
||||
const INSTALL_ICON = "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3";
|
||||
|
||||
export function SkillsPage() {
|
||||
useDocumentTitle(S.nav.skills);
|
||||
const navigate = useNavigate();
|
||||
const { locale } = useLocale();
|
||||
const userId = useAuth().user?.userId ?? null;
|
||||
const { currentProject, agents, setCurrentAgentId } = useProject();
|
||||
const projectId = currentProject?.projectId ?? null;
|
||||
|
||||
const [groups, setGroups] = useState<SkillGroupItem[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [installed, setInstalled] = useState<InstalledMap>(new Map());
|
||||
/** Collapsed skill groups (all expanded by default; same convention as the model page's provider groups). */
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
|
||||
// Library list: readable once logged in, fetched once on page entry.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
api
|
||||
.getSkillLibrary()
|
||||
.then((res) => {
|
||||
if (!cancelled) setGroups(res.groups);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) setError(apiErrorText(e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Installed skills for every Agent in the current Project (fetched in
|
||||
// parallel, same convention as the sessions context): a single Agent's
|
||||
// failure is silently treated as "no skills" and doesn't break the whole page.
|
||||
const agentIdsKey = agents.map((a) => a.agentId).join(",");
|
||||
useEffect(() => {
|
||||
// Clear the snapshot before fetching: agentId (e.g. default_agent) is
|
||||
// reused across Projects, and leftover state from the previous project
|
||||
// would otherwise overwrite the new data when merged below, leaving the
|
||||
// page permanently showing the old project's install state.
|
||||
setInstalled(new Map());
|
||||
if (!projectId || agentIdsKey === "") return;
|
||||
let cancelled = false;
|
||||
const ids = agentIdsKey.split(",");
|
||||
void Promise.all(
|
||||
ids.map(async (agentId) => {
|
||||
try {
|
||||
const res = await api.getAgentSkills(projectId, agentId);
|
||||
return [agentId, new Set(res.skills.map((s) => s.name))] as const;
|
||||
} catch {
|
||||
return [agentId, new Set<string>()] as const;
|
||||
}
|
||||
}),
|
||||
).then((entries) => {
|
||||
// Merge instead of replacing the whole table: an Agent the user has
|
||||
// already interacted with during the fetch keeps its interaction result
|
||||
// (an optimistic state or an install/uninstall response is newer than
|
||||
// this mount-time snapshot), so a late-arriving initial snapshot never
|
||||
// regresses the UI.
|
||||
if (!cancelled)
|
||||
setInstalled((prev) => {
|
||||
const next = new Map<string, ReadonlySet<string>>(entries);
|
||||
for (const [agentId, set] of prev) next.set(agentId, set);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, agentIdsKey]);
|
||||
|
||||
/** Rewrite one Agent's install state in place (shared by optimistic updates and failure rollback). */
|
||||
const setAgentSkill = (agentId: string, name: string, on: boolean) =>
|
||||
setInstalled((prev) => {
|
||||
const next = new Map(prev);
|
||||
const set = new Set(next.get(agentId) ?? []);
|
||||
if (on) set.add(name);
|
||||
else set.delete(name);
|
||||
next.set(agentId, set);
|
||||
return next;
|
||||
});
|
||||
|
||||
/** Check to install / uncheck to uninstall (any member can do this): optimistic update, a confirmation toast on success, rollback plus a toast on failure. */
|
||||
const toggleInstall = async (agentId: string, name: string, on: boolean) => {
|
||||
if (!projectId) return;
|
||||
setAgentSkill(agentId, name, on);
|
||||
const target = agents.find((a) => a.agentId === agentId);
|
||||
const agentName = target ? agentDisplayName(target) : agentId;
|
||||
try {
|
||||
if (on) {
|
||||
// On a successful install, calibrate the whole Agent from the list the response carries (concurrent install/uninstall also converges to the server's truth).
|
||||
const res = await api.installAgentSkills(projectId, agentId, [name]);
|
||||
setInstalled((prev) => new Map(prev).set(agentId, new Set(res.skills.map((s) => s.name))));
|
||||
toastSuccess(S.skills.installedToast(name, agentName));
|
||||
} else {
|
||||
await api.removeAgentSkill(projectId, agentId, name);
|
||||
toastSuccess(S.skills.uninstalledToast(name, agentName));
|
||||
}
|
||||
} catch (e) {
|
||||
// A 404 on uninstall means "was already not installed": the target
|
||||
// state is already reached, so keep it unchecked without rolling back
|
||||
// or erroring (otherwise the checkbox would be stuck permanently
|
||||
// checked whenever this page's snapshot is stale).
|
||||
if (!on && e instanceof ApiError && e.status === 404) return;
|
||||
setAgentSkill(agentId, name, !on);
|
||||
toastError(apiErrorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Quick invoke: pre-selects the skill in the draft cache (the `skills`
|
||||
* field, used by ChatInput as its initial selection on mount), pre-fills
|
||||
* the invocation text per UI language (overwriting any existing draft
|
||||
* body — quick invoke's intent is unambiguous, and leftover draft text
|
||||
* would only be noise here), and points the Agent to default_agent before
|
||||
* entering draft mode — the route state explicitly carries agentId
|
||||
* (overriding whatever was last selected in the cache). handoffAgentId
|
||||
* must be cleared: a leftover @ target would forward the whole skill
|
||||
* invocation to a different Agent — quick invoke must always start a new
|
||||
* conversation with default_agent.
|
||||
*/
|
||||
const quickInvoke = (name: string) => {
|
||||
if (userId && projectId) {
|
||||
const key = draftKey(userId, projectId);
|
||||
saveDraft(key, {
|
||||
...loadDraft(key),
|
||||
agentId: "default_agent",
|
||||
text: S.skills.quickInvokeText(name),
|
||||
skills: [name],
|
||||
handoffAgentId: undefined,
|
||||
});
|
||||
}
|
||||
setCurrentAgentId("default_agent");
|
||||
navigate(`/chat/${DRAFT_SESSION_ID}`, { state: { agentId: "default_agent" } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 md:p-6">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<h1 className="text-xl font-semibold">{S.skills.pageTitle}</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{S.skills.pageDesc}</p>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
<Button size="sm" onClick={() => window.location.reload()}>
|
||||
{S.common.retry}
|
||||
</Button>
|
||||
</div>
|
||||
) : groups === null ? (
|
||||
<div className="mt-6 grid gap-2.5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<SkeletonCard key={i} className="p-4">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="mt-2 h-4 w-3/4" />
|
||||
<Skeleton className="mt-3 h-6 w-36" />
|
||||
</SkeletonCard>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 space-y-3">
|
||||
{groups.map((group) => {
|
||||
const open = !collapsed.has(group.id);
|
||||
return (
|
||||
<section
|
||||
key={group.id}
|
||||
className="overflow-hidden rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
{/* Group header (styled like the model page's provider groups): group name +
|
||||
skill count (no icon, no description); the whole row toggles
|
||||
collapse on click and highlights on hover. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(group.id)) next.delete(group.id);
|
||||
else next.add(group.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className="flex w-full items-center gap-2.5 bg-gray-50 px-3 py-2.5 text-left transition-colors duration-150 hover:bg-gray-100 dark:bg-gray-900/60 dark:hover:bg-gray-800/60"
|
||||
>
|
||||
{/* Group name can truncate (min-w-0): the count and collapse arrow must not shrink. */}
|
||||
<span className="min-w-0 truncate text-sm font-semibold">
|
||||
{localizedText(locale, group.title, group.titleZh)}
|
||||
</span>
|
||||
<span className="shrink-0 whitespace-nowrap font-mono text-xs text-gray-400">
|
||||
{S.skills.skillCount(group.skills.length)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1" />
|
||||
<Chevron open={open} className="text-gray-400" />
|
||||
</button>
|
||||
|
||||
{/* Expand/collapse height transition: grid-template-rows tweens between
|
||||
0fr and 1fr, with the inner overflow-hidden clipping the content
|
||||
(same convention as the model page). */}
|
||||
<div
|
||||
className={`grid transition-[grid-template-rows] duration-200 ease-out ${open ? "grid-rows-[1fr]" : "grid-rows-[0fr]"}`}
|
||||
>
|
||||
{/* inert while collapsed: cards at zero height shouldn't still be Tab-focusable or clickable. */}
|
||||
<div className="overflow-hidden" inert={!open}>
|
||||
{/* Up to three cards per row, generously sized (3 columns ≥xl, 2 columns ≥md, 1 column on narrow screens). */}
|
||||
<div
|
||||
className={`grid gap-2.5 border-t border-gray-200 p-2.5 transition-opacity duration-200 md:grid-cols-2 xl:grid-cols-3 dark:border-gray-800 ${open ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
{group.skills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
installed={installed}
|
||||
onQuickInvoke={quickInvoke}
|
||||
onToggleInstall={toggleInstall}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single skill card: metadata display (including a semantic metadata line) + quick invoke + "manage installs" Modal. */
|
||||
function SkillCard({
|
||||
skill,
|
||||
installed,
|
||||
onQuickInvoke,
|
||||
onToggleInstall,
|
||||
}: {
|
||||
skill: SkillMetadataItem;
|
||||
installed: InstalledMap;
|
||||
onQuickInvoke: (name: string) => void;
|
||||
onToggleInstall: (agentId: string, name: string, on: boolean) => Promise<void>;
|
||||
}) {
|
||||
const { locale } = useLocale();
|
||||
const { agents } = useProject();
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
let installedCount = 0;
|
||||
for (const set of installed.values()) if (set.has(skill.name)) installedCount += 1;
|
||||
|
||||
// Short description takes priority, falling back to the full description
|
||||
// when missing (per UI language); title carries the full description for hover reading.
|
||||
const description = localizedShortText(locale, skill);
|
||||
const fullDescription = skill.description;
|
||||
// Metadata line: version · semantic update time (omitted when there's no
|
||||
// date) · usage count (a plain, readable phrase rather than a bare number badge).
|
||||
const meta = [
|
||||
`v${skill.version}`,
|
||||
skill.updated ? formatRelativeDate(skill.updated, locale) : null,
|
||||
S.skills.usedByAgents(installedCount),
|
||||
]
|
||||
.filter((v): v is string => v !== null)
|
||||
.join(" · ");
|
||||
return (
|
||||
<div className="flex h-full flex-col rounded-md border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
||||
{/* Header: an enlarged skill icon spanning two rows (rounded border + light background), with the name and short description on one line each to the right. */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-gray-600 dark:border-gray-700 dark:bg-gray-800/60 dark:text-gray-300">
|
||||
<SkillIcon icon={skill.icon} size={26} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate font-mono text-[13px] font-semibold" title={skill.name}>
|
||||
{skill.name}
|
||||
</span>
|
||||
{/* Short description truncates to one line (full description goes into title for hover reading). */}
|
||||
<p
|
||||
className="mt-0.5 truncate text-xs leading-5 text-gray-500 dark:text-gray-400"
|
||||
title={fullDescription}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Footer row: metadata on the left (e.g. `v1 · 今天更新 · N 个 Agent 在用`) +
|
||||
icon buttons on the right (copy goes into aria-label and title), pinned to the card's bottom with mt-auto. */}
|
||||
<div className="mt-auto flex items-center gap-1.5 pt-3">
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-[11px] text-gray-400 dark:text-gray-500"
|
||||
title={meta}
|
||||
>
|
||||
{meta}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0 px-1.5"
|
||||
aria-label={`${S.skills.quickInvoke} ${skill.name}`}
|
||||
title={S.skills.quickInvoke}
|
||||
onClick={() => onQuickInvoke(skill.name)}
|
||||
>
|
||||
<GlyphIcon d={SEND_ICON} size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
className="shrink-0 px-1.5"
|
||||
aria-label={`${S.skills.manageInstall} ${skill.name}`}
|
||||
title={S.skills.manageInstall}
|
||||
onClick={() => setInstallOpen(true)}
|
||||
>
|
||||
<GlyphIcon d={INSTALL_ICON} size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
{installOpen && (
|
||||
<Modal
|
||||
open
|
||||
title={S.skills.manageInstallTitle(skill.name)}
|
||||
onClose={() => setInstallOpen(false)}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
{agents.length === 0 && (
|
||||
<p className="py-1.5 text-xs text-gray-400">{S.common.loading}</p>
|
||||
)}
|
||||
{agents.map((a) => (
|
||||
<InstallRow
|
||||
key={a.agentId}
|
||||
agentId={a.agentId}
|
||||
name={agentDisplayName(a)}
|
||||
installed={installed.get(a.agentId)?.has(skill.name) ?? false}
|
||||
onToggle={(on) => void onToggleInstall(a.agentId, skill.name, on)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One Agent row in the "manage installs" Modal: not-installed shows
|
||||
* "安装"/"Install"; installed shows "已安装"/"Installed", switching to
|
||||
* "卸载"/"Uninstall" on hover (same button, click to uninstall). Both
|
||||
* install and uninstall go through optimistic updates (toggleInstall), rolling back on failure.
|
||||
*/
|
||||
function InstallRow({
|
||||
agentId,
|
||||
name,
|
||||
installed,
|
||||
onToggle,
|
||||
}: {
|
||||
agentId: string;
|
||||
name: string;
|
||||
installed: boolean;
|
||||
onToggle: (on: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md px-1.5 py-1.5 transition-colors duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/60">
|
||||
<AgentAvatar id={agentId} size={22} className="shrink-0 rounded" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm" title={agentId}>
|
||||
{name}
|
||||
</span>
|
||||
{installed ? (
|
||||
// group: on hover the button's copy switches "已安装"/"Installed" → "卸载"/"Uninstall" (the same button carries the uninstall action).
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="group shrink-0"
|
||||
aria-label={`${S.skills.uninstall} ${agentId}`}
|
||||
onClick={() => onToggle(false)}
|
||||
>
|
||||
<span className="group-hover:hidden">{S.skills.installed}</span>
|
||||
<span className="hidden text-red-600 group-hover:inline dark:text-red-400">
|
||||
{S.skills.uninstall}
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
aria-label={`${S.skills.install} ${agentId}`}
|
||||
onClick={() => onToggle(true)}
|
||||
>
|
||||
{S.skills.install}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
/**
|
||||
* Trace observation: a
|
||||
* CUDA-profiling-style two-dimensional lane chart.
|
||||
*
|
||||
* Trace records completion times; the server infers segments from "model
|
||||
* messages are produced serially (autoregressive decoding)" and groups by Task:
|
||||
* - Each Task has its own independent timeline (Tasks far apart in time would
|
||||
* leave large gaps if sharing one axis);
|
||||
* - Bars come in only five kinds, each with a fixed color (no gray/black/white):
|
||||
* thinking, model reply, tool-call generation (model lane), approval wait,
|
||||
* tool-call execution (each tool gets its own row);
|
||||
* - Highlighting is always expressed as "fade out the rest", never an outline:
|
||||
* hovering a segment highlights just that segment (by its unique key, so
|
||||
* adjacent segments at the same timestamp aren't highlighted together), and
|
||||
* it's linked by timestamp to the event list below (onHighlight); clicking a
|
||||
* segment jumps to and briefly highlights the corresponding message row (onJump);
|
||||
* - Hovering a legend item highlights matching segments (fading out the rest);
|
||||
* - Time-axis zoom/pan: drag the slider below the legend (drag the body to
|
||||
* pan, drag either handle to zoom, double-click to reset), similar to
|
||||
* Premiere's timeline navigator. Scroll-wheel zoom is deliberately
|
||||
* unsupported — to avoid accidentally changing zoom while scrolling the page.
|
||||
*/
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
||||
import type { TraceModelSegment, TraceToolSpan } from "@prismshadow/penguin-server/api";
|
||||
import { S } from "../../lib/strings";
|
||||
import { humanizeDuration } from "../../lib/format";
|
||||
|
||||
/**
|
||||
* Linked highlighting: `ts` is the anchor shared by both sides; `key` /
|
||||
* `rowKey` uniquely identify a bar / message row.
|
||||
*
|
||||
* **Only one bar and one message light up at a time**: multiple elements
|
||||
* often share the same millisecond (a tool_call and its request_end), so
|
||||
* matching by `ts` alone would highlight adjacent items together. The
|
||||
* initiating side supplies its own unique key; the other side takes the
|
||||
* first element at that timestamp.
|
||||
*/
|
||||
export interface TraceHighlight {
|
||||
ts?: string;
|
||||
/** Unique bar identifier (generated internally by the timeline). */
|
||||
key?: string;
|
||||
/** Unique message-row identifier (generated by the event list). */
|
||||
rowKey?: string;
|
||||
}
|
||||
|
||||
/** Fixed color for each of the five bar kinds (solid fill, clear contrast; no gray/black/white/indigo). */
|
||||
const COLORS = {
|
||||
thinking: "bg-violet-500 dark:bg-violet-400",
|
||||
text: "bg-sky-500 dark:bg-sky-400",
|
||||
toolgen: "bg-amber-500 dark:bg-amber-400",
|
||||
approvalWait: "bg-rose-400 dark:bg-rose-300",
|
||||
exec: "bg-emerald-500 dark:bg-emerald-400",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Left-side label column: sticky-pinned to the far left during horizontal
|
||||
* scroll/zoom, so labels don't scroll away with the timeline.
|
||||
* Must be an opaque block the **same height** as a lane (h-4) — otherwise a
|
||||
* bar scrolled underneath the label would peek out above/below the text's
|
||||
* line height, looking like the bar is overlapping the label text. Width
|
||||
* lines up with the task label row's `pl-[5.5rem]`.
|
||||
*/
|
||||
const LABEL_STICKY =
|
||||
"sticky left-0 z-10 flex h-4 w-[5.5rem] shrink-0 items-center justify-end bg-white pr-2 dark:bg-gray-950";
|
||||
/** Label text (truncated when too long). */
|
||||
const LABEL_TEXT = "truncate font-mono text-[11px]";
|
||||
/** Zoom range (time-axis multiplier). */
|
||||
const ZOOM_MIN = 0.25;
|
||||
const ZOOM_MAX = 24;
|
||||
/** Slider window's max width corresponds to zoom=1 (window = the whole content); zoom<1 (a compressed overview) is triggered via the −/+ buttons or by dragging the slider. */
|
||||
const MIN_WIN = 1 / ZOOM_MAX;
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||||
|
||||
/** Model-lane segment color: thinking / text (model reply) / tool_call (tool-call generation). */
|
||||
function segmentColor(kind: TraceModelSegment["kind"]): string {
|
||||
if (kind === "thinking") return COLORS.thinking;
|
||||
if (kind === "text") return COLORS.text;
|
||||
return COLORS.toolgen;
|
||||
}
|
||||
|
||||
function segmentLabel(kind: TraceModelSegment["kind"]): string {
|
||||
if (kind === "thinking") return S.traces.kindThinking;
|
||||
if (kind === "text") return S.traces.kindModelReply;
|
||||
return S.traces.kindToolGen;
|
||||
}
|
||||
|
||||
/** Legend key a segment belongs to (used for hover-linked fade-out). */
|
||||
function segmentLegendKey(kind: TraceModelSegment["kind"]): string {
|
||||
if (kind === "thinking") return "thinking";
|
||||
if (kind === "text") return "text";
|
||||
return "toolgen";
|
||||
}
|
||||
|
||||
/** Normalize an invalid/missing timestamp to null (treated as unclosed), so a single NaN doesn't propagate. */
|
||||
function msOf(ts: string | undefined): number | null {
|
||||
if (ts === undefined) return null;
|
||||
const ms = Date.parse(ts);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
interface PlacedSegment {
|
||||
kind: TraceModelSegment["kind"];
|
||||
name?: string;
|
||||
ts: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
interface PlacedSpan {
|
||||
name: string;
|
||||
toolCallId: string;
|
||||
callTs: string;
|
||||
approvalTsRaw?: string;
|
||||
outputTsRaw?: string;
|
||||
callMs: number;
|
||||
approvalMs: number | null;
|
||||
outputMs: number | null;
|
||||
failed: boolean;
|
||||
status?: string;
|
||||
decision?: string;
|
||||
}
|
||||
|
||||
interface TaskGroup {
|
||||
taskIndex: number;
|
||||
segs: PlacedSegment[];
|
||||
spans: PlacedSpan[];
|
||||
t0: number;
|
||||
total: number;
|
||||
toolNames: string[];
|
||||
}
|
||||
|
||||
function buildGroups(segments: TraceModelSegment[], toolSpans: TraceToolSpan[]): TaskGroup[] {
|
||||
const byTask = new Map<number, { segs: PlacedSegment[]; spans: PlacedSpan[] }>();
|
||||
const bucket = (i: number) => {
|
||||
let b = byTask.get(i);
|
||||
if (!b) {
|
||||
b = { segs: [], spans: [] };
|
||||
byTask.set(i, b);
|
||||
}
|
||||
return b;
|
||||
};
|
||||
|
||||
for (const s of segments) {
|
||||
const startMs = msOf(s.startTs);
|
||||
const endMs = msOf(s.endTs);
|
||||
if (startMs === null || endMs === null) continue;
|
||||
bucket(s.taskIndex).segs.push({
|
||||
kind: s.kind,
|
||||
...(s.name !== undefined ? { name: s.name } : {}),
|
||||
ts: s.endTs,
|
||||
startMs,
|
||||
endMs,
|
||||
});
|
||||
}
|
||||
for (const t of toolSpans) {
|
||||
const callMs = msOf(t.callTs);
|
||||
if (callMs === null) continue;
|
||||
bucket(t.taskIndex).spans.push({
|
||||
name: t.name || t.toolCallId,
|
||||
toolCallId: t.toolCallId,
|
||||
callTs: t.callTs,
|
||||
...(t.approvalTs !== undefined ? { approvalTsRaw: t.approvalTs } : {}),
|
||||
...(t.outputTs !== undefined ? { outputTsRaw: t.outputTs } : {}),
|
||||
callMs,
|
||||
approvalMs: msOf(t.approvalTs),
|
||||
outputMs: msOf(t.outputTs),
|
||||
failed: t.stopReason !== undefined && t.stopReason !== "completed",
|
||||
...(t.stopReason !== undefined ? { status: t.stopReason } : {}),
|
||||
...(t.decision !== undefined ? { decision: t.decision } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const groups: TaskGroup[] = [];
|
||||
for (const [taskIndex, { segs, spans }] of [...byTask.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
if (segs.length === 0 && spans.length === 0) continue;
|
||||
const starts = [...segs.map((s) => s.startMs), ...spans.map((s) => s.callMs)];
|
||||
const ends = [
|
||||
...segs.map((s) => s.endMs),
|
||||
...spans.map((s) => s.outputMs ?? s.approvalMs ?? s.callMs),
|
||||
];
|
||||
const t0 = Math.min(...starts);
|
||||
const tEnd = Math.max(...ends, ...starts);
|
||||
const total = Math.max(1, tEnd - t0);
|
||||
spans.sort((a, b) => a.callMs - b.callMs);
|
||||
const toolNames = [...new Set(spans.map((s) => s.name))];
|
||||
groups.push({ taskIndex, segs, spans, t0, total, toolNames });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Track: the gray-background container for bars (no rounded corners; overflow-hidden keeps bars from overlapping adjacent text). */
|
||||
function Track({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="relative h-4 min-w-0 flex-1 overflow-hidden bg-gray-100 dark:bg-gray-800/60">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Lane({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<span className={`${LABEL_STICKY} text-gray-400 dark:text-gray-500`} title={label}>
|
||||
<span className={LABEL_TEXT}>{label}</span>
|
||||
</span>
|
||||
<Track>{children}</Track>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-tick row: the first tick left-aligns, the last right-aligns, the rest
|
||||
* center — otherwise `-translate-x-1/2` would overflow the first and last
|
||||
* labels by half a character width, and the horizontal scroll container
|
||||
* would clip the last label (`591ms` truncated to `591`).
|
||||
* This way the default width (zoom=1) already fits the rightmost coordinate, with no extra padding needed.
|
||||
*/
|
||||
function Ticks({ total }: { total: number }) {
|
||||
const fracs = [0, 0.25, 0.5, 0.75, 1];
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<span className={LABEL_STICKY} />
|
||||
<div className="relative h-4 min-w-0 flex-1">
|
||||
{fracs.map((f, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`absolute top-0 font-mono text-[11px] text-gray-400 dark:text-gray-500 ${
|
||||
i === 0 ? "" : i === fracs.length - 1 ? "-translate-x-full" : "-translate-x-1/2"
|
||||
}`}
|
||||
style={{ left: `${f * 100}%` }}
|
||||
>
|
||||
{humanizeDuration(f * total)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimelineChart({
|
||||
segments,
|
||||
toolSpans,
|
||||
highlight,
|
||||
onHighlight,
|
||||
onJump,
|
||||
hideTaskLabel = false,
|
||||
}: {
|
||||
segments: TraceModelSegment[];
|
||||
toolSpans: TraceToolSpan[];
|
||||
highlight?: TraceHighlight | null;
|
||||
onHighlight?: (h: TraceHighlight | null) => void;
|
||||
/** Click a bar: jump to and briefly highlight the message at that moment. */
|
||||
onJump?: (ts: string) => void;
|
||||
/** Hide the "Round N" label when embedded in the "grouped by Task" view (the group header already shows it). */
|
||||
hideTaskLabel?: boolean;
|
||||
}) {
|
||||
const groups = useMemo(() => buildGroups(segments, toolSpans), [segments, toolSpans]);
|
||||
/**
|
||||
* This chart's bar index: timestamp → the **first** bar's key (in render
|
||||
* order), plus the set of every bar's key.
|
||||
* - Highlights initiated by the event list carry only `ts`; `firstBarKeyByTs`
|
||||
* degrades that into "only one bar lights up", so adjacent bars at the
|
||||
* same instant aren't dragged along;
|
||||
* - `barKeys` decides whether the highlight target **belongs to this
|
||||
* chart**: when grouped by Task, each round renders its own TimelineChart
|
||||
* but they share the same highlight — without this scope check, hovering
|
||||
* a bar in round 1 would fade out round 2's entire timeline too.
|
||||
*/
|
||||
const { firstBarKeyByTs, barKeys } = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
const keys = new Set<string>();
|
||||
const put = (ts: string, key: string) => {
|
||||
keys.add(key);
|
||||
if (!m.has(ts)) m.set(ts, key);
|
||||
};
|
||||
for (const g of groups) {
|
||||
g.segs.forEach((s, i) => put(s.ts, `s-${g.taskIndex}-${i}`));
|
||||
for (const s of g.spans) {
|
||||
if (s.approvalMs !== null && s.approvalMs > s.callMs) {
|
||||
put(s.approvalTsRaw ?? s.callTs, `w-${s.toolCallId}`);
|
||||
}
|
||||
if (s.approvalMs === null && s.outputMs === null) put(s.callTs, `p-${s.toolCallId}`);
|
||||
else put(s.outputTsRaw ?? s.callTs, `e-${s.toolCallId}`);
|
||||
}
|
||||
}
|
||||
return { firstBarKeyByTs: m, barKeys: keys };
|
||||
}, [groups]);
|
||||
// Hovering the legend highlights matching segments; null = none.
|
||||
const [legendKey, setLegendKey] = useState<string | null>(null);
|
||||
// Time-axis zoom multiplier + visible window (derived from scroll).
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [view, setView] = useState({ left: 0, width: 1 });
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef(view);
|
||||
viewRef.current = view;
|
||||
const zoomRef = useRef(zoom);
|
||||
zoomRef.current = zoom;
|
||||
// Target scrollLeft to apply after reflow once zoom changes the content width (keeps the anchor point).
|
||||
const pendingScroll = useRef<((el: HTMLDivElement) => number) | null>(null);
|
||||
// Cleanup for an in-progress drag (removes the window listener as a fallback on unmount/interruption).
|
||||
const dragCleanup = useRef<(() => void) | null>(null);
|
||||
|
||||
const updateView = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const sw = el.scrollWidth || 1;
|
||||
setView({ left: el.scrollLeft / sw, width: Math.min(1, el.clientWidth / sw) });
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (pendingScroll.current) {
|
||||
const max = el.scrollWidth - el.clientWidth;
|
||||
el.scrollLeft = clamp(pendingScroll.current(el), 0, Math.max(0, max));
|
||||
pendingScroll.current = null;
|
||||
}
|
||||
updateView();
|
||||
}, [zoom, groups, updateView]);
|
||||
|
||||
// Recompute the slider window after the container size changes (window resize / sidebar collapse), so the thumb's position/width don't get distorted.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => updateView());
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [updateView]);
|
||||
|
||||
// Fallback cleanup of an in-progress drag listener on unmount (pointercancel / mid-drag unmount don't fire pointerup).
|
||||
useLayoutEffect(() => () => dragCleanup.current?.(), []);
|
||||
|
||||
if (groups.length === 0) return <p className="text-xs text-gray-400">{S.common.none}</p>;
|
||||
|
||||
const hlTs = highlight?.ts;
|
||||
const hlKey = highlight?.key;
|
||||
const enter = (key: string, ts: string | undefined) => onHighlight?.(ts ? { ts, key } : null);
|
||||
const leave = () => onHighlight?.(null);
|
||||
|
||||
// The one bar that lights up: use the highlight's own key if it has one;
|
||||
// otherwise, with only ts (from the event list), take the first bar at that instant.
|
||||
// If the target isn't in this chart (the highlight belongs to another Task), treat it as no highlight — otherwise this chart would fade out entirely.
|
||||
const target = hlKey ?? (hlTs !== undefined ? firstBarKeyByTs.get(hlTs) : undefined);
|
||||
const activeBarKey = target !== undefined && barKeys.has(target) ? target : undefined;
|
||||
const hlOn = activeBarKey !== undefined;
|
||||
const isActive = (key: string): boolean => activeBarKey === key;
|
||||
|
||||
/** Highlight/legend filtering is always expressed as "fade out the rest", never an outline. */
|
||||
const dimClass = (active: boolean, legendMatch: boolean) =>
|
||||
(legendKey !== null && !legendMatch) || (hlOn && !active) ? "opacity-15" : "";
|
||||
|
||||
// Position strictly by exact timestamp (no minimum width, no right-edge
|
||||
// pull-back): segments sit flush against each other with no overlap, and
|
||||
// highlighting lands only on the current segment without bleeding into
|
||||
// neighbors (per the user's requirement). left/width are clamped to [0,100] to prevent overflow.
|
||||
const placeExact = (startMs: number, endMs: number, t0: number, total: number): CSSProperties => {
|
||||
const left = clamp(((startMs - t0) / total) * 100, 0, 100);
|
||||
const width = clamp(((endMs - startMs) / total) * 100, 0, 100 - left);
|
||||
// A zero-duration segment landing exactly on the right edge: min-w-[2px]
|
||||
// would push it out of the track, and the track's overflow-hidden would
|
||||
// clip it entirely (invisible, unhoverable). Pull that 2px back into the
|
||||
// track with a -2px margin, while the start point stays exactly aligned.
|
||||
if (left >= 100) return { left: "100%", width: "0%", marginLeft: "-2px" };
|
||||
return { left: `${left}%`, width: `${width}%` };
|
||||
};
|
||||
|
||||
const legendChips: Array<{ key: string; className: string; label: string }> = [
|
||||
{ key: "thinking", className: COLORS.thinking, label: S.traces.kindThinking },
|
||||
{ key: "text", className: COLORS.text, label: S.traces.kindModelReply },
|
||||
{ key: "toolgen", className: COLORS.toolgen, label: S.traces.kindToolGen },
|
||||
{ key: "approvalWait", className: COLORS.approvalWait, label: S.traces.legendApprovalWait },
|
||||
{ key: "exec", className: COLORS.exec, label: S.traces.legendToolExec },
|
||||
];
|
||||
|
||||
// —— Slider (Premiere-style): drag the body to pan, drag either handle to zoom, double-click to reset ——
|
||||
const applyWindow = (left: number, width: number) => {
|
||||
const w = clamp(width, MIN_WIN, 1);
|
||||
const next = clamp(1 / w, ZOOM_MIN, ZOOM_MAX);
|
||||
const l = clamp(left, 0, 1 - w);
|
||||
pendingScroll.current = (el) => l * el.scrollWidth;
|
||||
setZoom(next);
|
||||
};
|
||||
|
||||
const beginPan = (e: ReactPointerEvent) => {
|
||||
e.preventDefault();
|
||||
const track = trackRef.current;
|
||||
const el = scrollRef.current;
|
||||
if (!track || !el) return;
|
||||
const trackW = track.getBoundingClientRect().width || 1;
|
||||
const startX = e.clientX;
|
||||
const startScroll = el.scrollLeft;
|
||||
const max = Math.max(0, el.scrollWidth - el.clientWidth);
|
||||
const move = (ev: PointerEvent) => {
|
||||
const dxFrac = (ev.clientX - startX) / trackW;
|
||||
el.scrollLeft = clamp(startScroll + dxFrac * el.scrollWidth, 0, max);
|
||||
updateView();
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
window.removeEventListener("pointercancel", up);
|
||||
dragCleanup.current = null;
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
window.addEventListener("pointercancel", up);
|
||||
dragCleanup.current = up;
|
||||
};
|
||||
|
||||
const beginResize = (side: "left" | "right") => (e: ReactPointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
const rect = track.getBoundingClientRect();
|
||||
const move = (ev: PointerEvent) => {
|
||||
const frac = clamp((ev.clientX - rect.left) / (rect.width || 1), 0, 1);
|
||||
const cur = viewRef.current;
|
||||
if (side === "right") {
|
||||
applyWindow(cur.left, frac - cur.left);
|
||||
} else {
|
||||
const right = cur.left + cur.width;
|
||||
applyWindow(frac, right - frac);
|
||||
}
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
window.removeEventListener("pointercancel", up);
|
||||
dragCleanup.current = null;
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
window.addEventListener("pointercancel", up);
|
||||
dragCleanup.current = up;
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
pendingScroll.current = () => 0;
|
||||
setZoom(1);
|
||||
};
|
||||
|
||||
/** −/+ buttons: zoom anchored at the viewport center (covers the full range from <1 overview to >1 zoomed-in). */
|
||||
const zoomStep = (factor: number) => {
|
||||
const el = scrollRef.current;
|
||||
const next = clamp(zoomRef.current * factor, ZOOM_MIN, ZOOM_MAX);
|
||||
if (next === zoomRef.current) return;
|
||||
if (el) {
|
||||
const px = el.clientWidth / 2;
|
||||
const contentFrac = (el.scrollLeft + px) / (el.scrollWidth || 1);
|
||||
pendingScroll.current = (node) => contentFrac * node.scrollWidth - px;
|
||||
}
|
||||
setZoom(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Timeline (horizontal scroll container; native scrollbar hidden in favor of the slider below). overflow-y-hidden avoids a spurious vertical scrollbar. */}
|
||||
<div ref={scrollRef} className="no-scrollbar overflow-x-auto overflow-y-hidden">
|
||||
<div className="space-y-4" style={{ width: `${zoom * 100}%` }}>
|
||||
{groups.map((g) => {
|
||||
return (
|
||||
<div key={g.taskIndex} className="space-y-1">
|
||||
{!hideTaskLabel && (
|
||||
<div className="flex items-center gap-2 pl-[5.5rem]">
|
||||
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[11px] font-medium text-gray-500 dark:bg-gray-800 dark:text-gray-400">
|
||||
{S.traces.task(g.taskIndex + 1)}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-gray-400">
|
||||
{humanizeDuration(g.total)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model lane: serial segments (thinking / model reply / tool-call generation) */}
|
||||
<Lane label={S.traces.laneLLM}>
|
||||
{g.segs.map((s, i) => {
|
||||
const lk = segmentLegendKey(s.kind);
|
||||
const legendMatch = legendKey === null || legendKey === lk;
|
||||
const barKey = `s-${g.taskIndex}-${i}`;
|
||||
const active = isActive(barKey);
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
onMouseEnter={() => enter(barKey, s.ts)}
|
||||
onMouseLeave={leave}
|
||||
onClick={() => onJump?.(s.ts)}
|
||||
title={`${segmentLabel(s.kind)}${s.name ? ` ${s.name}` : ""} · ${humanizeDuration(s.endMs - s.startMs)}`}
|
||||
className={`absolute inset-y-0 min-w-[2px] cursor-pointer ${segmentColor(s.kind)} ${dimClass(active, legendMatch)}`}
|
||||
style={placeExact(s.startMs, s.endMs, g.t0, g.total)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Lane>
|
||||
|
||||
{/* Each tool gets its own row: approval wait → execution */}
|
||||
{g.spans.map((s) => {
|
||||
const execStart = s.approvalMs ?? s.callMs;
|
||||
const open = s.outputMs === null;
|
||||
const endMs = s.outputMs ?? g.t0 + g.total;
|
||||
const approvalTs = s.approvalTsRaw ?? s.callTs;
|
||||
const execTs = s.outputTsRaw ?? s.callTs;
|
||||
return (
|
||||
<div key={s.toolCallId} className="flex items-center">
|
||||
<span
|
||||
className={`${LABEL_STICKY} text-gray-500 dark:text-gray-400`}
|
||||
title={s.name}
|
||||
>
|
||||
<span className={LABEL_TEXT}>{s.name}</span>
|
||||
</span>
|
||||
<Track>
|
||||
{/* Approval-wait segment (positioned exactly, flush against the execution segment) */}
|
||||
{s.approvalMs !== null && s.approvalMs > s.callMs && (
|
||||
<span
|
||||
onMouseEnter={() => enter(`w-${s.toolCallId}`, approvalTs)}
|
||||
onMouseLeave={leave}
|
||||
onClick={() => onJump?.(approvalTs)}
|
||||
title={`${s.name} · ${S.traces.legendApprovalWait}${s.decision ? ` (${s.decision})` : ""} · ${humanizeDuration(s.approvalMs - s.callMs)}`}
|
||||
className={`absolute inset-y-0 min-w-[2px] cursor-pointer ${COLORS.approvalWait} ${dimClass(
|
||||
isActive(`w-${s.toolCallId}`),
|
||||
legendKey === null || legendKey === "approvalWait",
|
||||
)}`}
|
||||
style={placeExact(s.callMs, s.approvalMs, g.t0, g.total)}
|
||||
/>
|
||||
)}
|
||||
{/* Whole-segment pulse while approval is pending / execution segment */}
|
||||
{s.approvalMs === null && open
|
||||
? (() => {
|
||||
// animate-pulse's keyframes override the static opacity: the pulse animation must be removed when fading out.
|
||||
const dim = dimClass(
|
||||
isActive(`p-${s.toolCallId}`),
|
||||
legendKey === null || legendKey === "approvalWait",
|
||||
);
|
||||
return (
|
||||
<span
|
||||
onMouseEnter={() => enter(`p-${s.toolCallId}`, s.callTs)}
|
||||
onMouseLeave={leave}
|
||||
onClick={() => onJump?.(s.callTs)}
|
||||
title={`${s.name} · ${S.traces.legendApprovalWait} · ${S.traces.inProgress}`}
|
||||
className={`absolute inset-y-0 min-w-[2px] cursor-pointer ${COLORS.approvalWait} ${
|
||||
dim || "animate-pulse"
|
||||
}`}
|
||||
style={placeExact(s.callMs, endMs, g.t0, g.total)}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
: (() => {
|
||||
// Fade-out and the "running" translucency share the same opacity property: fade-out takes priority to avoid the two overriding each other.
|
||||
const dim = dimClass(
|
||||
isActive(`e-${s.toolCallId}`),
|
||||
legendKey === null || legendKey === "exec",
|
||||
);
|
||||
const running = open && !dim;
|
||||
return (
|
||||
<span
|
||||
onMouseEnter={() => enter(`e-${s.toolCallId}`, execTs)}
|
||||
onMouseLeave={leave}
|
||||
onClick={() => onJump?.(execTs)}
|
||||
title={`${s.name} · ${S.traces.legendToolExec} · ${
|
||||
open ? S.traces.inProgress : humanizeDuration(endMs - execStart)
|
||||
}${s.failed ? ` · ${s.status}` : ""}`}
|
||||
className={`absolute inset-y-0 min-w-[2px] cursor-pointer ${COLORS.exec} ${
|
||||
s.failed ? "ring-1 ring-red-500" : ""
|
||||
} ${running ? "animate-pulse opacity-70" : dim}`}
|
||||
style={placeExact(execStart, endMs, g.t0, g.total)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</Track>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Time-axis ticks */}
|
||||
<Ticks total={g.total} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend: hovering highlights matching segments — same convention as the bars, non-matching items fade out, no background color */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-gray-100 pt-2.5 dark:border-gray-800">
|
||||
{legendChips.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
onMouseEnter={() => setLegendKey(c.key)}
|
||||
onMouseLeave={() => setLegendKey(null)}
|
||||
className={`flex items-center gap-1 px-1 font-mono text-[11px] text-gray-500 transition-opacity duration-150 dark:text-gray-400 ${
|
||||
legendKey !== null && legendKey !== c.key ? "opacity-30" : ""
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block h-2 w-3 rounded-sm ${c.className}`} />
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Premiere-style zoom/pan slider: drag the body to pan, drag either handle to zoom, double-click to reset; −/+ covers the full range from <1 to >1 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="shrink-0 text-[11px] text-gray-400">{S.traces.zoom}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={S.traces.zoomOut}
|
||||
title={S.traces.zoomOut}
|
||||
onClick={() => zoomStep(1 / 1.4)}
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-gray-200 text-gray-500 transition-colors duration-150 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span className="text-[11px] leading-none">−</span>
|
||||
</button>
|
||||
<div
|
||||
ref={trackRef}
|
||||
onDoubleClick={resetZoom}
|
||||
title={S.traces.zoomReset}
|
||||
className="relative h-3 min-w-0 flex-1 rounded bg-gray-100 dark:bg-gray-800"
|
||||
>
|
||||
<div
|
||||
role="scrollbar"
|
||||
aria-label={S.traces.zoom}
|
||||
onPointerDown={beginPan}
|
||||
className="absolute inset-y-0 flex cursor-grab items-center justify-between rounded bg-gray-400/70 active:cursor-grabbing dark:bg-gray-500/70"
|
||||
style={{ left: `${view.left * 100}%`, width: `${Math.max(view.width * 100, 3)}%` }}
|
||||
>
|
||||
<span
|
||||
onPointerDown={beginResize("left")}
|
||||
className="h-full w-1.5 shrink-0 cursor-ew-resize rounded-l bg-gray-500/80 dark:bg-gray-400/80"
|
||||
/>
|
||||
<span
|
||||
onPointerDown={beginResize("right")}
|
||||
className="h-full w-1.5 shrink-0 cursor-ew-resize rounded-r bg-gray-500/80 dark:bg-gray-400/80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={S.traces.zoomIn}
|
||||
title={S.traces.zoomIn}
|
||||
onClick={() => zoomStep(1.4)}
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-gray-200 text-gray-500 transition-colors duration-150 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span className="text-[11px] leading-none">+</span>
|
||||
</button>
|
||||
<span className="w-9 shrink-0 text-right font-mono text-[11px] tabular-nums text-gray-400">
|
||||
{zoom.toFixed(2)}×
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{onHighlight && (
|
||||
<p className="text-[11px] text-gray-400 dark:text-gray-600">{S.traces.linkHint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Trace event row (shared): timestamp + type icon + type badge + one-line
|
||||
* summary; expanding **renders** the content (text/thinking and session_meta's
|
||||
* system prompt go through Markdown, tool arguments/output go through a code
|
||||
* block, token_usage goes through a bucket table, session_meta goes through a
|
||||
* field table + tool definitions) instead of dumping raw JSON;
|
||||
* the stop reason (stop_reason / status) is shown in the bottom-right of the
|
||||
* expanded area.
|
||||
* Linked highlighting with the timeline — a matched row only gets a
|
||||
* background color, never an outline; only one row lights up at a time (each
|
||||
* row has its own unique rowKey, so adjacent messages at the same timestamp
|
||||
* aren't highlighted together).
|
||||
*/
|
||||
import { Fragment, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { S } from "../../lib/strings";
|
||||
import type { OmniMessage } from "@prismshadow/penguin-core/omnimessage";
|
||||
import { formatTime, humanizeTokens } from "../../lib/format";
|
||||
import { Badge, stopReasonTone } from "../../components/ui/badge";
|
||||
import type { BadgeTone } from "../../components/ui/badge";
|
||||
import { ZoomableImage } from "../../components/ui/image-zoom";
|
||||
|
||||
export function typeTone(type: string): BadgeTone {
|
||||
if (type === "session_meta") return "brand";
|
||||
if (type === "model_msg") return "gray";
|
||||
return "amber";
|
||||
}
|
||||
|
||||
/** Icon for each event type (24×24 line path). */
|
||||
const TYPE_ICON: Record<string, string> = {
|
||||
text: "M8 10h8M8 14h5M21 12a9 9 0 1 1-4-7.5",
|
||||
thinking: "M9 18h6M10 21h4M12 3a6 6 0 0 0-3 11v2h6v-2a6 6 0 0 0-3-11z",
|
||||
image_url: "M3 5h18v14H3zM3 15l5-5 4 4 3-3 6 6",
|
||||
inline_data: "M3 5h18v14H3zM3 15l5-5 4 4 3-3 6 6",
|
||||
tool_call: "M14.7 6.3a4 4 0 0 0-5 5L4 17v3h3l5.7-5.7a4 4 0 0 0 5-5l-2.5 2.5-2-2 2.5-2.5z",
|
||||
tool_call_output: "M4 6l4 4-4 4M12 18h8",
|
||||
token_usage: "M4 20V10m6 10V4m6 16v-7m4 7H2",
|
||||
request_begin: "M5 12h14M13 6l6 6-6 6",
|
||||
request_end: "M19 12H5M11 6l-6 6 6 6",
|
||||
approval_decision: "M9 12l2 2 4-4M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z",
|
||||
compaction_begin: "M8 3H4v4M16 3h4v4M8 21H4v-4M16 21h4v-4M9 12h6",
|
||||
compaction_end: "M8 3H4v4M16 3h4v4M8 21H4v-4M16 21h4v-4M9 12h6",
|
||||
abort: "M6 6h12v12H6z",
|
||||
subagent: "M12 3v6m0 0l-5 4v8m5-12l5 4v8M4 21h16",
|
||||
};
|
||||
const DEFAULT_ICON = "M12 8v5m0 3h.01M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z";
|
||||
|
||||
function TypeIcon({ type }: { type: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0 text-gray-400 dark:text-gray-500"
|
||||
aria-hidden
|
||||
>
|
||||
<path d={TYPE_ICON[type] ?? DEFAULT_ICON} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** One-line summary: pull key info per payload type (truncated to one line). */
|
||||
export function summarizeEvent(msg: OmniMessage): string {
|
||||
const p = msg.payload as Record<string, unknown> & { type?: string };
|
||||
switch (p.type) {
|
||||
case undefined:
|
||||
return "";
|
||||
case "text":
|
||||
return `${String(p["role"])}: ${String(p["text"] ?? "").slice(0, 120)}`;
|
||||
case "thinking":
|
||||
return String(p["thinking"] ?? "").slice(0, 120);
|
||||
case "image_url":
|
||||
return "[image]";
|
||||
case "inline_data":
|
||||
return `[image] ${String(p["mime_type"] ?? "")}`;
|
||||
case "tool_call":
|
||||
return `${String(p["name"])} ${String(p["arguments"] ?? "").slice(0, 100)}`;
|
||||
case "tool_call_output": {
|
||||
const text = String(p["output"] ?? "").slice(0, 120);
|
||||
// Tool output carrying an image: prefix the summary with [image] to flag it.
|
||||
return Array.isArray(p["images"]) && p["images"].length > 0 ? `[image] ${text}` : text;
|
||||
}
|
||||
case "token_usage": {
|
||||
const req = p["request"] as { total?: number } | undefined;
|
||||
const ses = p["session"] as { total?: number } | undefined;
|
||||
return `request.total=${req?.total ?? 0} session.total=${ses?.total ?? 0}`;
|
||||
}
|
||||
case "request_end":
|
||||
return `status=${String(p["status"])}`;
|
||||
case "approval_decision":
|
||||
return `${String(p["decision"])} · ${String(p["tool_call_id"])}`;
|
||||
case "compaction_begin":
|
||||
case "compaction_end":
|
||||
return `${String(p["mode"])} (${String(p["reason"])})${p["status"] ? ` · ${String(p["status"])}` : ""}`;
|
||||
case "abort":
|
||||
return p["reason"] != null ? String(p["reason"]) : "";
|
||||
case "subagent":
|
||||
return String(p["session_id"] ?? "");
|
||||
default:
|
||||
if (msg.type === "session_meta") {
|
||||
return `${String(p["session_id"] ?? "")} · ${String(p["model_id"] ?? "")}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop reason (a model message's stop_reason / request_end's status); undefined if absent. */
|
||||
function stopReasonOf(msg: OmniMessage): string | undefined {
|
||||
const p = msg.payload as { stop_reason?: string; status?: string };
|
||||
return p.stop_reason ?? p.status;
|
||||
}
|
||||
|
||||
const codeBlock =
|
||||
"max-h-72 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-100 px-2.5 py-2 text-xs dark:bg-gray-800/70";
|
||||
|
||||
/** token_usage bucket table (a single event only reports exact numbers; the donut ring only appears in the top-right of the Task card, so it isn't redrawn here). */
|
||||
function UsageTable({ p }: { p: Record<string, unknown> }) {
|
||||
const req = (p.request ?? {}) as Record<string, number>;
|
||||
const ses = (p.session ?? {}) as Record<string, number>;
|
||||
const row = (label: string, r: Record<string, number>) => (
|
||||
<tr>
|
||||
<td className="pr-3 text-gray-400">{label}</td>
|
||||
<td className="pr-3 text-right font-mono">{humanizeTokens(r.cache_read ?? 0)}</td>
|
||||
<td className="pr-3 text-right font-mono">{humanizeTokens(r.cache_write ?? 0)}</td>
|
||||
<td className="pr-3 text-right font-mono">{humanizeTokens(r.output ?? 0)}</td>
|
||||
<td className="text-right font-mono">{humanizeTokens(r.total ?? 0)}</td>
|
||||
</tr>
|
||||
);
|
||||
return (
|
||||
<table className="text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-400">
|
||||
<th />
|
||||
<th className="pr-3 text-right font-normal">cacheRead</th>
|
||||
<th className="pr-3 text-right font-normal">cacheWrite</th>
|
||||
<th className="pr-3 text-right font-normal">output</th>
|
||||
<th className="text-right font-normal">total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{row("request", req)}
|
||||
{row("session", ses)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
const summaryClass =
|
||||
"cursor-pointer text-xs font-medium text-gray-500 marker:text-gray-400 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200";
|
||||
|
||||
/** session_meta: a field table + system prompt (Markdown) + tool definition list. */
|
||||
function SessionMetaBody({ p }: { p: Record<string, unknown> }) {
|
||||
const rows: Array<[string, string]> = [
|
||||
["session_id", String(p.session_id ?? "")],
|
||||
["model_id", String(p.model_id ?? "")],
|
||||
["context_window", String(p.model_context_window ?? "")],
|
||||
["thinking_level", String(p.thinking_level ?? "")],
|
||||
["agent_state", String(p.agent_state ?? "")],
|
||||
["workspace", String(p.workspace ?? "")],
|
||||
];
|
||||
const prompt = String(p.system_prompt ?? "");
|
||||
const tools = Array.isArray(p.tools)
|
||||
? (p.tools as Array<{ name?: string; description?: string }>)
|
||||
: [];
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-xs">
|
||||
{rows.map(([k, v]) => (
|
||||
<Fragment key={k}>
|
||||
<dt className="text-gray-400">{k}</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-gray-700 dark:text-gray-300">
|
||||
{v || "—"}
|
||||
</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{prompt.trim() && (
|
||||
<details>
|
||||
<summary className={summaryClass}>{S.traces.systemPrompt}</summary>
|
||||
<div className="md-body mt-1.5 max-h-96 overflow-auto rounded bg-gray-100 px-2.5 py-2 text-sm leading-relaxed text-gray-700 dark:bg-gray-800/70 dark:text-gray-300">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{prompt}</ReactMarkdown>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{tools.length > 0 && (
|
||||
<details>
|
||||
<summary className={summaryClass}>{S.traces.toolDefs(tools.length)}</summary>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{tools.map((t, i) => (
|
||||
<li key={i} className="text-xs">
|
||||
<span className="font-mono font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t.name ?? "—"}
|
||||
</span>
|
||||
{t.description && (
|
||||
<span className="ml-2 text-gray-500 dark:text-gray-400">{t.description}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Expanded-area content: rendered per type (no raw JSON dump). */
|
||||
function EventBody({ msg }: { msg: OmniMessage }) {
|
||||
const p = msg.payload as Record<string, unknown> & { type?: string };
|
||||
if (msg.type === "session_meta") return <SessionMetaBody p={p} />;
|
||||
switch (p.type) {
|
||||
case "text":
|
||||
case "thinking": {
|
||||
const md = String(p.text ?? p.thinking ?? "");
|
||||
if (!md.trim()) return <p className="text-xs text-gray-400">—</p>;
|
||||
return (
|
||||
<div className="md-body text-sm leading-relaxed text-gray-700 dark:text-gray-300">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{md}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "image_url":
|
||||
return (
|
||||
<ZoomableImage
|
||||
src={String(p.image_url ?? "")}
|
||||
alt="trace"
|
||||
className="max-h-48 max-w-full rounded-md"
|
||||
/>
|
||||
);
|
||||
case "inline_data":
|
||||
// base64 bytes + mime → render directly as a data URL.
|
||||
return (
|
||||
<ZoomableImage
|
||||
src={`data:${String(p.mime_type ?? "")};base64,${String(p.data ?? "")}`}
|
||||
alt="trace"
|
||||
className="max-h-48 max-w-full rounded-md"
|
||||
/>
|
||||
);
|
||||
case "tool_call":
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="font-mono text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
{String(p.name ?? "")}
|
||||
</p>
|
||||
<pre className={codeBlock}>{String(p.arguments ?? "")}</pre>
|
||||
</div>
|
||||
);
|
||||
case "tool_call_output": {
|
||||
// Tool output carrying images (e.g. read_image): render images below the text code block.
|
||||
const images = Array.isArray(p.images) ? (p.images as string[]) : [];
|
||||
if (images.length === 0) return <pre className={codeBlock}>{String(p.output ?? "")}</pre>;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<pre className={codeBlock}>{String(p.output ?? "")}</pre>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{images.map((src, i) => (
|
||||
<ZoomableImage
|
||||
key={i}
|
||||
src={src}
|
||||
alt="trace"
|
||||
className="max-h-48 max-w-full rounded-md"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "token_usage":
|
||||
return <UsageTable p={p} />;
|
||||
default:
|
||||
// Short events like compaction / abort / request_*: key-value pairs are enough (session_meta is already rendered separately above).
|
||||
return <pre className={codeBlock}>{JSON.stringify(p, null, 2)}</pre>;
|
||||
}
|
||||
}
|
||||
|
||||
export function EventRow({
|
||||
msg,
|
||||
rowKey,
|
||||
matched,
|
||||
onHighlight,
|
||||
}: {
|
||||
msg: OmniMessage;
|
||||
/** This row's unique identifier (the scroll target for timeline jumps, and also the basis for "only one row lights up"). */
|
||||
rowKey: string;
|
||||
matched: boolean;
|
||||
onHighlight?: (h: { ts: string; rowKey: string } | null) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const payloadType = (msg.payload as { type?: string }).type ?? msg.type;
|
||||
const stopReason = stopReasonOf(msg);
|
||||
return (
|
||||
<li data-trace-row={rowKey}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onMouseEnter={() => onHighlight?.({ ts: msg.timestamp, rowKey })}
|
||||
onMouseLeave={() => onHighlight?.(null)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors duration-150 ${
|
||||
matched
|
||||
? "bg-amber-50 dark:bg-amber-950/40"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0 font-mono text-[11px] text-gray-400">
|
||||
{formatTime(msg.timestamp)}
|
||||
</span>
|
||||
<TypeIcon type={payloadType} />
|
||||
<Badge tone={typeTone(msg.type)}>{payloadType}</Badge>
|
||||
{msg.origin && msg.origin.length > 0 && <Badge tone="brand">origin</Badge>}
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
{summarizeEvent(msg)}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-t border-gray-100 bg-gray-50/60 px-3 py-2 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<EventBody msg={msg} />
|
||||
{/* Stop reason: bottom-right */}
|
||||
{stopReason && (
|
||||
<div className="mt-1.5 flex justify-end">
|
||||
<Badge tone={stopReasonTone(stopReason)}>{stopReason}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* Trace file view (reworked):
|
||||
* grouped by **round (Task)** — a global summary at the top; below it, one
|
||||
* group per Task, with the card's top-right corner showing that round's stats
|
||||
* and a **context-usage donut ring** (upper bound = the session's context
|
||||
* window, default 128000; the three segments are cacheRead / cacheWrite /
|
||||
* output, showing both usage ratio and composition, with exact numbers on
|
||||
* hover), followed by that round's execution timeline and all of its messages.
|
||||
*
|
||||
* Token usage here is **broken down by category** rather than given as one
|
||||
* lump sum (a total alone doesn't show where the money went): this round's
|
||||
* input (with the portion that was a **cache hit** in parentheses, target
|
||||
* icon, hover shows the hit rate = cache hit ÷ input), this round's output,
|
||||
* plus tool-call count / cost / duration / output TPS. The conversation
|
||||
* page's stats row only gives input/output totals — cache composition and
|
||||
* this kind of debugging detail belongs here.
|
||||
*
|
||||
* Task attribution: model segments/tool spans carry their own taskIndex
|
||||
* (computed by the server), and messages fall into a Task's time range by
|
||||
* timestamp. Timeline ↔ message linked highlighting: hovering either side
|
||||
* highlights the other (only one bar / one message lights up at a time);
|
||||
* clicking a bar scrolls to the corresponding message and pins the highlight for PIN_MS.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { OmniMessage } from "@prismshadow/penguin-core/omnimessage";
|
||||
import type {
|
||||
ModelsResponse,
|
||||
TraceAnalysisResponse,
|
||||
TraceModelSegment,
|
||||
TraceTaskStats,
|
||||
TraceToolSpan,
|
||||
} from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import {
|
||||
computeTps,
|
||||
formatMoney,
|
||||
formatPercent,
|
||||
formatTps,
|
||||
humanizeDuration,
|
||||
humanizeTokens,
|
||||
} from "../../lib/format";
|
||||
import { STAT_ICONS } from "../../lib/stat-icons";
|
||||
import { resolveContextWindow } from "../../lib/context";
|
||||
import { useTheme } from "../../state/theme";
|
||||
import { Skeleton } from "../../components/ui/skeleton";
|
||||
import { Chevron } from "../../components/ui/chevron";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import { TokenDonut } from "../../components/ui/token-donut";
|
||||
import { TimelineChart } from "./timeline-chart";
|
||||
import type { TraceHighlight } from "./timeline-chart";
|
||||
import { EventRow } from "./trace-event-row";
|
||||
|
||||
/**
|
||||
* The three Token buckets (token_usage.request). No `total` field: usage is
|
||||
* always shown broken down, and the total = input (cacheRead + cacheWrite) +
|
||||
* output — there's no second convention.
|
||||
*/
|
||||
interface Buckets {
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
output: number;
|
||||
}
|
||||
const zeroBuckets = (): Buckets => ({ cacheRead: 0, cacheWrite: 0, output: 0 });
|
||||
|
||||
interface TaskData {
|
||||
taskIndex: number;
|
||||
segments: TraceModelSegment[];
|
||||
spans: TraceToolSpan[];
|
||||
messages: OmniMessage[];
|
||||
toolCalls: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
const msOf = (ts: string): number => {
|
||||
const n = Date.parse(ts);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
/** How long the target message row stays pinned highlighted after a bar-click jump (milliseconds). */
|
||||
const PIN_MS = 2500;
|
||||
|
||||
/** Unique key for a message row (also the DOM scroll anchor). */
|
||||
const rowKeyOf = (taskIndex: number, i: number): string => `${taskIndex}-${i}`;
|
||||
|
||||
/**
|
||||
* One row of the global summary: name on the left, value on the right
|
||||
* (tabular-nums right-aligned → values line up column-wise across rows, easy to compare at a glance).
|
||||
* Each item takes its own row, with three groups arranged side by side as
|
||||
* columns — laid out horizontally it would read as a blur of digits, while
|
||||
* giving each group a full row would waste the right half of the space.
|
||||
*/
|
||||
function SummaryRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3 py-0.5">
|
||||
<span className="shrink-0 text-[11px] text-gray-400">{label}</span>
|
||||
<span className="truncate font-mono text-sm font-semibold tabular-nums">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** This round's input = cache hit (cacheRead) + cache miss (cacheWrite). */
|
||||
const inputOf = (b: Buckets): number => b.cacheRead + b.cacheWrite;
|
||||
|
||||
/** Cache hit rate = cache hit ÷ this round's input; undefined when input is 0 → null (shown as `—`). */
|
||||
const hitRateOf = (b: Buckets): number | null => {
|
||||
const input = inputOf(b);
|
||||
return input > 0 ? b.cacheRead / input : null;
|
||||
};
|
||||
|
||||
/** Shared style for stat rows (icon + value, tabular figures). */
|
||||
const CHIP_CLASS =
|
||||
"flex shrink-0 items-center font-mono text-[11px] tabular-nums text-gray-500 dark:text-gray-400";
|
||||
|
||||
/** Icon + value; hover shows what this item is (plain text alone doesn't convey the meaning). */
|
||||
function StatChip({ icon, value, label }: { icon: string; value: string; label: string }) {
|
||||
return (
|
||||
<span title={label} aria-label={label} className={`${CHIP_CLASS} gap-1`}>
|
||||
<GlyphIcon d={icon} className="text-gray-400" />
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This round's input chip: `↑ 84k (◎ 60k)` — the parenthesized number is the
|
||||
* portion that was a **cache hit** (target icon), with the hit rate shown on
|
||||
* hovering the parenthesized part. The hit rate is hover-only: cramming a
|
||||
* third number into the row would blow out this row of chips, and "how much
|
||||
* was hit" already gives a rough sense on its own — hover for the exact ratio.
|
||||
*/
|
||||
function InputChip({ buckets }: { buckets: Buckets }) {
|
||||
const input = inputOf(buckets);
|
||||
const hitTitle =
|
||||
`${S.traces.cacheHit} ${humanizeTokens(buckets.cacheRead)}` +
|
||||
` · ${S.traces.hitRate} ${formatPercent(hitRateOf(buckets))}`;
|
||||
return (
|
||||
<span
|
||||
aria-label={`${S.traces.taskInput} ${humanizeTokens(input)} · ${hitTitle}`}
|
||||
className={CHIP_CLASS}
|
||||
>
|
||||
<span title={S.traces.taskInput} className="flex items-center gap-1">
|
||||
<GlyphIcon d={STAT_ICONS.input} className="text-gray-400" />
|
||||
{humanizeTokens(input)}
|
||||
</span>
|
||||
<span title={hitTitle} className="ml-1 flex items-center gap-0.5 text-gray-400">
|
||||
<span>(</span>
|
||||
<GlyphIcon d={STAT_ICONS.cacheHit} />
|
||||
<span>{humanizeTokens(buckets.cacheRead)}</span>
|
||||
<span>)</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TraceFileView({
|
||||
projectId,
|
||||
agentId,
|
||||
sessionId,
|
||||
index,
|
||||
highlight,
|
||||
onHighlight,
|
||||
}: {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
index: number;
|
||||
highlight: TraceHighlight | null;
|
||||
onHighlight: (h: TraceHighlight | null) => void;
|
||||
}) {
|
||||
const { currency } = useTheme();
|
||||
const [analysis, setAnalysis] = useState<TraceAnalysisResponse | null>(null);
|
||||
const [events, setEvents] = useState<OmniMessage[]>([]);
|
||||
/** events' starting index within the file (pagination offset): used to align with analysis.tasks' index ranges. */
|
||||
const [eventsOffset, setEventsOffset] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [models, setModels] = useState<ModelsResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(new Set());
|
||||
/** Message row pinned highlighted after a bar-click jump; auto-clears when its timer fires (independent of hover highlighting, and can stack with it). */
|
||||
const [pinnedRow, setPinnedRow] = useState<string | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const pinTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (pinTimer.current) clearTimeout(pinTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setAnalysis(null);
|
||||
setEvents([]);
|
||||
setError(null);
|
||||
setCollapsed(new Set());
|
||||
setPinnedRow(null);
|
||||
let cancelled = false;
|
||||
Promise.all([
|
||||
api.getAgentTraceAnalysis(projectId, agentId, sessionId, index),
|
||||
api.getAgentTraceEvents(projectId, agentId, sessionId, index, 0, 1000),
|
||||
api.getModels(projectId).catch(() => null),
|
||||
])
|
||||
.then(([a, e, m]) => {
|
||||
if (cancelled) return;
|
||||
setAnalysis(a);
|
||||
setEvents(e.events);
|
||||
setEventsOffset(e.offset);
|
||||
setTotal(e.total);
|
||||
setModels(m);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setError(err instanceof ApiError ? err.message : S.common.unknownError);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, agentId, sessionId, index]);
|
||||
|
||||
// Pricing for the session's Model (main session only; sub-session Tokens
|
||||
// live in their own Trace and aren't part of this file):
|
||||
// session_meta carries a paired reference (provider + model_id), matched against model config by that pair.
|
||||
const pricing = useMemo(() => {
|
||||
const meta = events.find((m) => m.type === "session_meta");
|
||||
const ref = meta ? (meta.payload as { model_id?: string; provider?: string }) : undefined;
|
||||
if (!ref?.model_id) return undefined;
|
||||
return models?.models.find(
|
||||
(m) =>
|
||||
m.modelId === ref.model_id && (ref.provider === undefined || m.provider === ref.provider),
|
||||
)?.pricing;
|
||||
}, [events, models]);
|
||||
|
||||
const costOf = (b: Buckets): number | null => {
|
||||
if (!pricing) return null;
|
||||
return (
|
||||
(b.cacheRead * pricing.cacheRead +
|
||||
b.cacheWrite * pricing.cacheWrite +
|
||||
b.output * pricing.output) /
|
||||
1e6
|
||||
);
|
||||
};
|
||||
|
||||
// Session context window (the upper bound for each round's donut ring): read once from session_meta, falling back to 128000 if unconfigured.
|
||||
const contextMax = useMemo(() => {
|
||||
const meta = events.find((m) => m.type === "session_meta");
|
||||
return resolveContextWindow(
|
||||
meta
|
||||
? (meta.payload as { model_context_window?: number | string }).model_context_window
|
||||
: undefined,
|
||||
);
|
||||
}, [events]);
|
||||
|
||||
const { tasks, global, statsByTask, globalLlmMs } = useMemo(() => {
|
||||
const g = { buckets: zeroBuckets(), toolCalls: 0 };
|
||||
const empty = new Map<number, TraceTaskStats>();
|
||||
if (!analysis)
|
||||
return {
|
||||
tasks: [] as TaskData[],
|
||||
global: g,
|
||||
statsByTask: empty,
|
||||
globalLlmMs: 0,
|
||||
};
|
||||
|
||||
// A round's duration range always comes from the server (analysis.tasks,
|
||||
// computed over the whole file; the start is that round's first
|
||||
// request_begin). For a degenerate round with no Request, startTs is an
|
||||
// empty string → no range is built, and the duration counts as 0.
|
||||
const boundsByTask = new Map<number, { min: number; max: number }>();
|
||||
for (const t of analysis.tasks) {
|
||||
const min = Date.parse(t.startTs);
|
||||
const max = Date.parse(t.endTs);
|
||||
if (Number.isFinite(min) && Number.isFinite(max)) {
|
||||
boundsByTask.set(t.taskIndex, { min, max });
|
||||
}
|
||||
}
|
||||
|
||||
const map = new Map<number, TaskData>();
|
||||
const ensure = (ti: number): TaskData => {
|
||||
let d = map.get(ti);
|
||||
if (!d) {
|
||||
const b = boundsByTask.get(ti);
|
||||
d = {
|
||||
taskIndex: ti,
|
||||
segments: [],
|
||||
spans: [],
|
||||
messages: [],
|
||||
toolCalls: 0,
|
||||
durationMs: b ? Math.max(0, b.max - b.min) : 0,
|
||||
};
|
||||
map.set(ti, d);
|
||||
}
|
||||
return d;
|
||||
};
|
||||
for (const t of analysis.tasks) ensure(t.taskIndex); // empty rounds still need to appear in the list
|
||||
for (const s of analysis.modelSegments) ensure(s.taskIndex).segments.push(s);
|
||||
for (const s of analysis.toolSpans) {
|
||||
const d = ensure(s.taskIndex);
|
||||
d.spans.push(s);
|
||||
d.toolCalls += 1;
|
||||
}
|
||||
// Message attribution: **by the server-given index range**, never guessed
|
||||
// from timestamps. The same millisecond can be crowded with "the previous
|
||||
// round's last reply, compaction_begin, the compaction prompt, the next
|
||||
// round's request_begin" — splitting by a time boundary can't tell them
|
||||
// apart, and this round's reply would get misattributed to the next
|
||||
// round (the server already knows this message-by-message from its
|
||||
// sequential scan, no need to re-guess it here).
|
||||
// events is only used to populate the message list (a list view that
|
||||
// truthfully indicates truncation at the bottom); no **numeric value**
|
||||
// is ever derived from it: events is paginated (limit=1000, not
|
||||
// continued), so using it for aggregation would undercount Token/cost for a long Trace.
|
||||
const taskOfIndex = (k: number): number | null => {
|
||||
for (const t of analysis.tasks) {
|
||||
if (k >= t.messageFrom && k <= t.messageTo) return t.taskIndex;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const msg = events[i]!;
|
||||
if (msg.origin && msg.origin.length > 0) continue; // sub-session messages don't enter this file's grouping
|
||||
const ti = taskOfIndex(eventsOffset + i); // events is fetched starting at offset; recover the global index within the file
|
||||
if (ti !== null) ensure(ti).messages.push(msg);
|
||||
}
|
||||
g.toolCalls = analysis.toolSpans.length;
|
||||
// Numeric values always come from analysis.tasks, computed by the server
|
||||
// over **the whole file**. Note the differing conventions:
|
||||
// - context: a **snapshot** (usage at that round's last non-compaction Request), not an accumulated value;
|
||||
// - tokens: this round's **throughput** (sum across Requests), used for
|
||||
// Token / cost; `tokens.output` doubles as the TPS numerator;
|
||||
// - llmMs: the TPS denominator (this round's LLM generation time, with human approval wait already deducted).
|
||||
// The global summary and the per-round cards below share **the same
|
||||
// scope** (including compaction rounds): every global figure is the sum
|
||||
// across rounds, and they must add up.
|
||||
const statsByTask = new Map(analysis.tasks.map((t) => [t.taskIndex, t]));
|
||||
for (const t of analysis.tasks) {
|
||||
g.buckets.cacheRead += t.tokens.cacheRead;
|
||||
g.buckets.cacheWrite += t.tokens.cacheWrite;
|
||||
g.buckets.output += t.tokens.output;
|
||||
}
|
||||
const gLlm = analysis.tasks.reduce((s, t) => s + t.llmMs, 0);
|
||||
const tasks = [...map.values()].sort((a, b) => a.taskIndex - b.taskIndex);
|
||||
return { tasks, global: g, statsByTask, globalLlmMs: gLlm };
|
||||
}, [analysis, events, eventsOffset]);
|
||||
|
||||
if (error) return <p className="text-xs text-red-600 dark:text-red-400">{error}</p>;
|
||||
if (!analysis) return <Skeleton className="h-40" />;
|
||||
|
||||
// Duration is likewise "the sum across rounds" computed by the server over
|
||||
// the whole file (including compaction rounds, same scope as the per-round
|
||||
// display below): events is paginated, so subtracting first from last
|
||||
// would truncate a long Trace's duration (a 90s span where the first 1000 events only cover the first 30s → showing 30s).
|
||||
const globalMs = analysis.elapsedMs;
|
||||
|
||||
const toggle = (ti: number) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(ti)) next.delete(ti);
|
||||
else next.add(ti);
|
||||
return next;
|
||||
});
|
||||
|
||||
/** The **first** message row at that instant; a bar-initiated highlight/jump uses this to hit only one row. */
|
||||
const firstRowKeyAt = (ts: string): string | null => {
|
||||
for (const t of tasks) {
|
||||
const i = t.messages.findIndex((m) => m.timestamp === ts);
|
||||
if (i >= 0) return rowKeyOf(t.taskIndex, i);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Target row for hover highlighting: use the highlight's own rowKey (from a
|
||||
// message row) if it has one; otherwise, with only ts (from a bar), take the first row.
|
||||
const hoveredRow =
|
||||
highlight?.rowKey ?? (highlight?.ts !== undefined ? firstRowKeyAt(highlight.ts) : null);
|
||||
|
||||
/** Click a bar: scroll to the corresponding message row and pin the highlight — the mouse moving away afterward shouldn't clear it, so this is stored separately from hover highlighting. */
|
||||
const jumpTo = (ts: string) => {
|
||||
const rk = firstRowKeyAt(ts);
|
||||
if (rk === null) return;
|
||||
setPinnedRow(rk);
|
||||
// The target row may have just re-rendered from the highlight; wait for this frame to commit before scrolling.
|
||||
requestAnimationFrame(() => {
|
||||
rootRef.current
|
||||
?.querySelector(`[data-trace-row="${rk}"]`)
|
||||
?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
});
|
||||
if (pinTimer.current) clearTimeout(pinTimer.current);
|
||||
pinTimer.current = setTimeout(() => setPinnedRow(null), PIN_MS);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="space-y-4">
|
||||
{/* Global summary: split into three groups by nature (count / Token
|
||||
usage / duration·cost·TPS), separated by vertical rules — a dozen
|
||||
metrics laid out in one row would read as a blur of digits; grouping lets you spot the kind you want at a glance. */}
|
||||
<div className="rounded-md border border-gray-200 p-3 dark:border-gray-800">
|
||||
<p className="mb-2 text-xs font-semibold text-gray-500">{S.traces.globalSummary}</p>
|
||||
{/* Three groups side by side as columns, each item within a group
|
||||
taking its own row (name on the left, value on the right): laid
|
||||
out in one row it's a blur of digits, while giving each group a
|
||||
full row only uses a small strip on the left and wastes the rest.
|
||||
Splitting into columns fills the width and keeps it to three rows tall. */}
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-3">
|
||||
{/* Counts */}
|
||||
<div>
|
||||
{/* Rounds = number of cards below (a compaction round counts as
|
||||
a round too): the global summary and the per-round display
|
||||
below share **the same scope** — every figure is the sum
|
||||
across rounds and must add up; how many of them are
|
||||
compaction rounds is answered separately by "compaction count". */}
|
||||
<SummaryRow label={S.traces.tasksLabel} value={String(analysis.tasks.length)} />
|
||||
<SummaryRow label={S.traces.toolCalls} value={String(global.toolCalls)} />
|
||||
<SummaryRow label={S.traces.compactions} value={String(analysis.compactionCount)} />
|
||||
</div>
|
||||
{/* Token usage: broken down by category (input / of which cache hit + hit rate / output), never given as a lump sum. */}
|
||||
<div>
|
||||
<SummaryRow label={S.chat.statInput} value={humanizeTokens(inputOf(global.buckets))} />
|
||||
<SummaryRow
|
||||
label={S.traces.cacheHit}
|
||||
value={`${humanizeTokens(global.buckets.cacheRead)} · ${formatPercent(hitRateOf(global.buckets))}`}
|
||||
/>
|
||||
<SummaryRow label={S.chat.statOutput} value={humanizeTokens(global.buckets.output)} />
|
||||
</div>
|
||||
{/* Duration · cost · TPS (cost above duration, same order as the conversation page's stats row). */}
|
||||
<div>
|
||||
<SummaryRow
|
||||
label={S.chat.statCost}
|
||||
value={formatMoney(costOf(global.buckets), currency)}
|
||||
/>
|
||||
<SummaryRow
|
||||
label={S.chat.statElapsed}
|
||||
value={humanizeDuration(Math.max(0, globalMs))}
|
||||
/>
|
||||
{/* Global TPS = the output of every round (including compaction
|
||||
rounds) ÷ the sum of LLM generation time, same scope as the
|
||||
Token and duration above — both numerator and denominator
|
||||
come from analysis.tasks under the server's whole-file convention, so they share the same source. */}
|
||||
<SummaryRow
|
||||
label={S.chat.statTps}
|
||||
value={formatTps(computeTps(global.buckets.output, globalLlmMs))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grouped by Task */}
|
||||
{tasks.map((t) => {
|
||||
const open = !collapsed.has(t.taskIndex);
|
||||
// This round's convention as computed by the server over the whole
|
||||
// file: ctx = context snapshot at the end of this round (last
|
||||
// non-compaction Request), tokens = this round's throughput (used
|
||||
// for Token and cost, output doubles as the TPS numerator), llmMs = the TPS denominator.
|
||||
const st = statsByTask.get(t.taskIndex);
|
||||
const ctx = st?.context;
|
||||
const tokens = st?.tokens ?? zeroBuckets();
|
||||
return (
|
||||
<div
|
||||
key={t.taskIndex}
|
||||
className="overflow-hidden rounded-md border border-gray-200 dark:border-gray-800"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(t.taskIndex)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-2 bg-gray-50 px-3 py-2 text-left transition-colors duration-150 hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-800/60"
|
||||
>
|
||||
<Chevron open={open} size={13} className="text-gray-400" />
|
||||
<span className="shrink-0 text-sm font-semibold">
|
||||
{S.traces.task(t.taskIndex + 1)}
|
||||
</span>
|
||||
{/* Compaction rounds are explicitly flagged: their Token /
|
||||
cost / duration / TPS count toward the global summary just
|
||||
like user rounds do, and this badge answers "this round isn't answering the user, it's compacting context". */}
|
||||
{st?.compaction === true && (
|
||||
<span className="shrink-0 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-800 dark:text-gray-400">
|
||||
{S.traces.compactionRound}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1" />
|
||||
{/* This round's stats: iconified in the top-right corner (hover gives a text explanation) */}
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
||||
<StatChip
|
||||
icon={STAT_ICONS.toolCalls}
|
||||
value={String(t.toolCalls)}
|
||||
label={S.traces.toolCalls}
|
||||
/>
|
||||
{/* Token usage broken down by category: this round's input
|
||||
(parenthesized portion is the cache hit) + this round's
|
||||
output. Uses the server's this-round throughput tokens (whole file, including compaction), not computed from truncated events. */}
|
||||
<InputChip buckets={tokens} />
|
||||
<StatChip
|
||||
icon={STAT_ICONS.output}
|
||||
value={humanizeTokens(tokens.output)}
|
||||
label={S.traces.taskOutput}
|
||||
/>
|
||||
<StatChip
|
||||
icon={STAT_ICONS.cost}
|
||||
value={formatMoney(costOf(tokens), currency)}
|
||||
label={`${S.chat.statCost}(${currency})`}
|
||||
/>
|
||||
<StatChip
|
||||
icon={STAT_ICONS.elapsed}
|
||||
value={humanizeDuration(t.durationMs)}
|
||||
label={S.chat.statElapsed}
|
||||
/>
|
||||
<StatChip
|
||||
icon={STAT_ICONS.tps}
|
||||
value={formatTps(computeTps(tokens.output, st?.llmMs ?? 0))}
|
||||
label={S.chat.statTps}
|
||||
/>
|
||||
</div>
|
||||
{/* Context-usage donut ring at the end of this round (upper
|
||||
bound = the session context window) + the three-segment
|
||||
composition, with exact numbers on hover (see TokenDonut's
|
||||
title). The exact figures are given by the chips on the
|
||||
left; the ring is only a peripheral hint of the usage
|
||||
ratio, hence its small size. It's fed the snapshot ctx
|
||||
rather than the accumulated t.buckets — the latter
|
||||
recounts the history each round carries forward, so a few
|
||||
rounds of tool calls alone could fill the ring. A pure compaction Task has no snapshot and draws no ring. */}
|
||||
{ctx && (
|
||||
<TokenDonut
|
||||
cacheRead={ctx.cacheRead}
|
||||
cacheWrite={ctx.cacheWrite}
|
||||
output={ctx.output}
|
||||
max={contextMax}
|
||||
size={22}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-3 p-3">
|
||||
{/* This round's timeline */}
|
||||
{(t.segments.length > 0 || t.spans.length > 0) && (
|
||||
<div className="rounded-md border border-gray-100 p-2 dark:border-gray-800/60">
|
||||
<p className="mb-1.5 text-[11px] font-medium text-gray-500">
|
||||
{S.traces.timeline}
|
||||
</p>
|
||||
<TimelineChart
|
||||
segments={t.segments}
|
||||
toolSpans={t.spans}
|
||||
highlight={highlight}
|
||||
onHighlight={onHighlight}
|
||||
onJump={jumpTo}
|
||||
hideTaskLabel
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* This round's messages */}
|
||||
<div>
|
||||
<p className="mb-1.5 text-[11px] font-medium text-gray-500">
|
||||
{S.traces.messages}({t.messages.length})
|
||||
</p>
|
||||
{t.messages.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">{S.common.none}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100 rounded-md border border-gray-200 dark:divide-gray-800/60 dark:border-gray-800">
|
||||
{t.messages.map((msg, i) => {
|
||||
const rk = rowKeyOf(t.taskIndex, i);
|
||||
return (
|
||||
<EventRow
|
||||
key={i}
|
||||
msg={msg}
|
||||
rowKey={rk}
|
||||
matched={rk === hoveredRow || rk === pinnedRow}
|
||||
onHighlight={(h) => onHighlight(h)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{events.length < total && (
|
||||
<p className="text-xs text-gray-400">{S.traces.truncatedNote(events.length, total)}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Trace browsing page: the left-side directory lists Sessions grouped only by
|
||||
* Agent (titles come from the Sessions context, with unmanaged CLI/subagent
|
||||
* Sessions falling back to sessionId); the right side shows the selected
|
||||
* Session's Trace files (paged, most recent first by default) + performance
|
||||
* analysis (an execution timeline) + an event timeline.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import type { AgentTracesResponse } from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { useDocumentTitle } from "../../lib/use-document-title";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { agentDisplayName, useProject } from "../../state/project";
|
||||
import { AgentAvatar } from "../../components/ui/agent-avatar";
|
||||
import { Chevron } from "../../components/ui/chevron";
|
||||
import { Truncated } from "../../components/ui/truncated";
|
||||
import { useSessions } from "../../state/sessions";
|
||||
import { EmptyState } from "../../components/ui/empty-state";
|
||||
import { SkeletonList } from "../../components/ui/skeleton";
|
||||
import { TraceFileView } from "./trace-file-view";
|
||||
import type { TraceHighlight } from "./timeline-chart";
|
||||
|
||||
interface TraceFileRef {
|
||||
index: number;
|
||||
date: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
interface SessionGroup {
|
||||
sessionId: string;
|
||||
files: TraceFileRef[];
|
||||
}
|
||||
|
||||
interface Selection {
|
||||
/** Details go through the Agent-level endpoint (not dependent on the sessions table's tracking), so the owning Agent must be carried along. */
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
files: TraceFileRef[];
|
||||
}
|
||||
|
||||
/** Flatten by Session (merging a Session's files across dates); Sessions are sorted by id descending = newest first. */
|
||||
function flattenSessions(data: AgentTracesResponse): SessionGroup[] {
|
||||
const bySession = new Map<string, TraceFileRef[]>();
|
||||
for (const d of data.dates) {
|
||||
for (const s of d.sessions) {
|
||||
const list = bySession.get(s.sessionId) ?? [];
|
||||
for (const f of s.files) list.push({ index: f.index, date: d.date, sizeBytes: f.sizeBytes });
|
||||
bySession.set(s.sessionId, list);
|
||||
}
|
||||
}
|
||||
return [...bySession.entries()]
|
||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||
.map(([sessionId, files]) => ({
|
||||
sessionId,
|
||||
// Files sorted newest first by default (a higher index is newer).
|
||||
files: files.sort((a, b) => b.index - a.index),
|
||||
}));
|
||||
}
|
||||
|
||||
/** A single Agent's expandable tree node (traces are fetched only when expanded; titles are mapped via the Sessions context). */
|
||||
function AgentNode({
|
||||
projectId,
|
||||
agentId,
|
||||
name,
|
||||
defaultOpen,
|
||||
focusSessionId,
|
||||
titleOf,
|
||||
selection,
|
||||
onSelect,
|
||||
}: {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
name: string;
|
||||
/** Initial expanded state: all expanded when there's no deep link; only the target Agent expanded when there's an ?agentId= deep link. */
|
||||
defaultOpen: boolean;
|
||||
/** ?sessionId= deep link (jumped to directly from the evaluation center's runs): auto-selects that Session once the list is ready (only once). */
|
||||
focusSessionId?: string;
|
||||
titleOf: (agentId: string, sessionId: string) => string | undefined;
|
||||
selection: Selection | null;
|
||||
onSelect: (sel: Selection) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [groups, setGroups] = useState<SessionGroup[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || groups) return;
|
||||
api
|
||||
.getAgentTraces(projectId, agentId)
|
||||
.then((data) => setGroups(flattenSessions(data)))
|
||||
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError));
|
||||
}, [open, groups, projectId, agentId]);
|
||||
|
||||
// The Session deep link is applied only once: it selects the target as
|
||||
// soon as the list is first ready (if not found, it just stays in the
|
||||
// list state without erroring); after that, the user's manual switches
|
||||
// are never pulled back by the deep-link parameter.
|
||||
const focusApplied = useRef(false);
|
||||
useEffect(() => {
|
||||
if (focusApplied.current || !focusSessionId || !groups) return;
|
||||
focusApplied.current = true;
|
||||
const target = groups.find((g) => g.sessionId === focusSessionId);
|
||||
if (target) onSelect({ agentId, sessionId: target.sessionId, files: target.files });
|
||||
}, [groups, focusSessionId, agentId, onSelect]);
|
||||
|
||||
// The group header and Session row styling matches the sidebar
|
||||
// (components/layout/sidebar.tsx): the same information appearing in two
|
||||
// places with a different shape would make it look like two different things.
|
||||
return (
|
||||
<li className="pt-2.5">
|
||||
<div className="flex items-center px-1 pb-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label={open ? S.nav.collapseGroup : S.nav.expandGroup}
|
||||
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<AgentAvatar id={agentId} size={18} className="shrink-0 rounded" />
|
||||
<span className="min-w-0 truncate text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{name}
|
||||
</span>
|
||||
{/* Expand/collapse indicator immediately follows the Agent name */}
|
||||
<Chevron open={open} size={12} className="text-gray-400" />
|
||||
<span className="min-w-0 flex-1" />
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="anim-fade">
|
||||
{error && <p className="px-2.5 py-1 text-xs text-red-500">{error}</p>}
|
||||
{!groups && !error && (
|
||||
<p className="px-2.5 py-1 text-xs text-gray-400">{S.common.loading}</p>
|
||||
)}
|
||||
{groups && groups.length === 0 && (
|
||||
<p className="px-2.5 py-1 text-xs text-gray-400 dark:text-gray-600">{S.traces.empty}</p>
|
||||
)}
|
||||
<ul className="space-y-0.5">
|
||||
{groups?.map((g) => {
|
||||
const active = selection?.agentId === agentId && selection.sessionId === g.sessionId;
|
||||
const title = titleOf(agentId, g.sessionId);
|
||||
return (
|
||||
<li key={g.sessionId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect({ agentId, sessionId: g.sessionId, files: g.files })}
|
||||
className={`flex w-full items-center gap-1.5 rounded-md px-2.5 py-1.5 text-left transition-colors duration-150 ${
|
||||
active
|
||||
? "bg-gray-200/70 dark:bg-gray-800"
|
||||
: "hover:bg-gray-200/50 dark:hover:bg-gray-800/70"
|
||||
}`}
|
||||
>
|
||||
<Truncated
|
||||
text={title ?? g.sessionId}
|
||||
className={`min-w-0 flex-1 ${title ? "text-sm" : "font-mono text-xs"} ${
|
||||
active
|
||||
? "font-medium text-gray-900 dark:text-gray-100"
|
||||
: "text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
/>
|
||||
<span className="shrink-0 font-mono text-[11px] text-gray-400">
|
||||
{g.files.length}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function TracesPage() {
|
||||
useDocumentTitle(S.traces.title);
|
||||
const { currentProject, agents, agentsLoading } = useProject();
|
||||
const { byAgent } = useSessions();
|
||||
const projectId = currentProject?.projectId ?? null;
|
||||
// ?agentId= deep link (from the Agents page's "traces" entry point): only
|
||||
// the target Agent defaults to expanded, the rest collapse to keep focus on it.
|
||||
const [searchParams] = useSearchParams();
|
||||
const focusAgentId = searchParams.get("agentId");
|
||||
// ?sessionId= deep link (jumped to directly from the evaluation center's
|
||||
// runs): auto-selects once the target Agent's Session list is ready.
|
||||
const focusSessionId = searchParams.get("sessionId");
|
||||
const [selection, setSelection] = useState<Selection | null>(null);
|
||||
const [fileIndex, setFileIndex] = useState<number | null>(null);
|
||||
// Linked highlighting between the trace observation view and the event list (keyed by tool_call_id).
|
||||
const [highlight, setHighlight] = useState<TraceHighlight | null>(null);
|
||||
|
||||
// Clear the selection when switching Project.
|
||||
useEffect(() => {
|
||||
setSelection(null);
|
||||
setFileIndex(null);
|
||||
}, [projectId]);
|
||||
|
||||
// Clear the linked highlight when switching Session / Trace file.
|
||||
useEffect(() => {
|
||||
setHighlight(null);
|
||||
}, [selection, fileIndex]);
|
||||
|
||||
const titleOf = (agentId: string, sessionId: string): string | undefined =>
|
||||
byAgent.get(agentId)?.find((s) => s.sessionId === sessionId)?.title;
|
||||
|
||||
if (!projectId) return null;
|
||||
|
||||
const activeFile =
|
||||
selection === null
|
||||
? null
|
||||
: (selection.files.find((f) => f.index === fileIndex) ?? selection.files[0] ?? null);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col md:flex-row">
|
||||
{/* Directory tree: Agent → Session title (≥md left column; <md top collapsible area) */}
|
||||
<aside className="max-h-52 shrink-0 overflow-y-auto border-b border-gray-200 bg-gray-50 px-1 py-2 md:max-h-none md:w-72 md:border-b-0 md:border-r dark:border-gray-800 dark:bg-gray-900">
|
||||
<p className="px-3 pb-1 text-xs font-bold uppercase tracking-wide text-gray-500">
|
||||
{S.traces.title}
|
||||
</p>
|
||||
{agentsLoading ? (
|
||||
<SkeletonList rows={4} />
|
||||
) : (
|
||||
<ul>
|
||||
{agents.map((a) => (
|
||||
<AgentNode
|
||||
key={a.agentId}
|
||||
projectId={projectId}
|
||||
agentId={a.agentId}
|
||||
name={agentDisplayName(a)}
|
||||
defaultOpen={focusAgentId === null || focusAgentId === a.agentId}
|
||||
{...(focusSessionId !== null && focusAgentId === a.agentId
|
||||
? { focusSessionId }
|
||||
: {})}
|
||||
titleOf={titleOf}
|
||||
selection={selection}
|
||||
onSelect={(sel) => {
|
||||
setSelection(sel);
|
||||
setFileIndex(sel.files[0]?.index ?? null);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="min-w-0 flex-1 overflow-y-auto p-3 md:p-4">
|
||||
{selection && activeFile ? (
|
||||
<div className="mx-auto max-w-4xl space-y-4">
|
||||
{/* Header: Session title + Trace file pagination (newest first) */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
{titleOf(selection.agentId, selection.sessionId) ?? (
|
||||
<span className="font-mono text-xs font-normal text-gray-500">
|
||||
{selection.sessionId}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="mr-1 text-xs text-gray-400">{S.traces.filesTitle}</span>
|
||||
{selection.files.map((f) => (
|
||||
<button
|
||||
key={f.index}
|
||||
type="button"
|
||||
onClick={() => setFileIndex(f.index)}
|
||||
title={`${f.date} · ${formatBytes(f.sizeBytes)}`}
|
||||
className={`rounded-md border px-2 py-0.5 font-mono text-xs transition-colors duration-150 ${
|
||||
f.index === activeFile.index
|
||||
? "border-gray-400 bg-gray-200/70 font-semibold text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
: "border-gray-200 text-gray-500 hover:bg-gray-100 dark:border-gray-800 dark:text-gray-400 dark:hover:bg-gray-800/60"
|
||||
}`}
|
||||
>
|
||||
#{String(f.index).padStart(3, "0")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="truncate font-mono text-xs text-gray-400">
|
||||
{selection.sessionId} · {activeFile.date} · {formatBytes(activeFile.sizeBytes)}
|
||||
</p>
|
||||
|
||||
<TraceFileView
|
||||
projectId={projectId}
|
||||
agentId={selection.agentId}
|
||||
sessionId={selection.sessionId}
|
||||
index={activeFile.index}
|
||||
highlight={highlight}
|
||||
onHighlight={setHighlight}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title={S.traces.selectSession} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Geometry math for the cost center charts: pure functions, no React / no
|
||||
* JSX, easy to unit test (see test/usage-charts.test.ts). The two "last 30
|
||||
* days" charts (the daily Token bar's three-segment stack, the daily cost
|
||||
* line + area) share one coordinate system — canvas width, padding, the
|
||||
* x()/y() mapping, SVG paths, x-axis label indices. The stacked bar's
|
||||
* horizontal layout (fixed 25px bar width, spacing ≥ bar width) is computed
|
||||
* by tokenBarLayout, and per-segment geometry (including per-segment hit
|
||||
* bands) is produced by barSegments; there's also pie-slice geometry (each
|
||||
* Agent's call count) and success-rate normalization. See chart-svg.tsx for the render skeleton.
|
||||
*
|
||||
* **Canvas width = the container's measured pixel width (1 canvas unit = 1
|
||||
* CSS pixel)**: the SVG no longer stretches/scales via a fixed viewBox —
|
||||
* a scaled-down "bar width" would be a fake pixel discounted by the
|
||||
* container's width (640 units squeezed into a half-width cell becomes
|
||||
* ~495px, a 0.77 factor), while requirements like "at least 25px wide" must
|
||||
* land on **real display pixels**. So the canvas width is supplied by the caller after measuring the container.
|
||||
*/
|
||||
|
||||
/** Canvas height and padding (carried over from the original TrendChart constants; width is now measured from the container, see the file header). */
|
||||
export const CHART_H = 200;
|
||||
export const PAD_L = 46;
|
||||
export const PAD_R = 8;
|
||||
export const PAD_T = 10;
|
||||
export const PAD_B = 22;
|
||||
|
||||
/** The daily Token chart's three buckets (bottom-to-top stacking order is output → cacheWrite → cacheRead). */
|
||||
export type TokenBucketKey = "cacheRead" | "cacheWrite" | "output";
|
||||
|
||||
/** A chart's coordinate system: canvas width w, data point count n, y-axis upper bound max, and x()/y() mapping "index / value" to canvas coordinates. */
|
||||
export interface ChartGeom {
|
||||
n: number;
|
||||
max: number;
|
||||
/** Total canvas width (= viewBox width = CSS pixel width). */
|
||||
w: number;
|
||||
innerW: number;
|
||||
innerH: number;
|
||||
step: number;
|
||||
x: (i: number) => number;
|
||||
y: (v: number) => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the coordinate system: x takes each cell's midpoint, y runs
|
||||
* top-to-bottom with max as the full height. w is the canvas width (pixels).
|
||||
* When `max <= 0` (no data / all zero), y always takes the baseline —
|
||||
* callers already guarantee max > 0, but this is an exported public pure
|
||||
* function, and without this guard a single 0 would turn the entire chart's coordinates into NaN / Infinity.
|
||||
*/
|
||||
export function makeGeom(n: number, max: number, w: number): ChartGeom {
|
||||
const innerW = Math.max(0, w - PAD_L - PAD_R);
|
||||
const innerH = CHART_H - PAD_T - PAD_B;
|
||||
const step = n > 0 ? innerW / n : innerW;
|
||||
return {
|
||||
n,
|
||||
max,
|
||||
w,
|
||||
innerW,
|
||||
innerH,
|
||||
step,
|
||||
x: (i) => PAD_L + step * i + step / 2,
|
||||
y: (v) => PAD_T + innerH * (1 - (max > 0 ? v / max : 0)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Line path: `M x0,y0 L x1,y1 …` (identical to the original TrendChart's cost line). */
|
||||
export function linePath(geom: ChartGeom, values: number[]): string {
|
||||
return values.map((v, i) => `${i === 0 ? "M" : "L"}${geom.x(i)},${geom.y(v)}`).join(" ");
|
||||
}
|
||||
|
||||
/** Area path: the line drops vertically to the baseline (y=0) at the end, then closes back along the baseline to the start; used by the cost line's fill layer. */
|
||||
export function areaPath(geom: ChartGeom, values: number[]): string {
|
||||
const n = values.length;
|
||||
if (n === 0) return "";
|
||||
const baseY = geom.y(0);
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < n; i++)
|
||||
parts.push(`${i === 0 ? "M" : "L"}${geom.x(i)},${geom.y(values[i]!)}`);
|
||||
parts.push(`L${geom.x(n - 1)},${baseY}`);
|
||||
parts.push(`L${geom.x(0)},${baseY}`);
|
||||
parts.push("Z");
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/** Sparse x-axis label indices: first, middle, last (labeling every point would blur together when cells are narrow and there are many points). */
|
||||
export function sparseLabelIdx(n: number): number[] {
|
||||
if (n <= 0) return [];
|
||||
if (n === 1) return [0];
|
||||
if (n === 2) return [0, 1];
|
||||
return [0, Math.floor((n - 1) / 2), n - 1];
|
||||
}
|
||||
|
||||
/** Horizontal space a single date label (`MM-DD`, fontSize 9) takes up: roughly 28px of text width plus breathing room. */
|
||||
const LABEL_MIN_PX = 40;
|
||||
|
||||
/**
|
||||
* Adaptive x-axis label indices: label more of them when each cell is wide
|
||||
* enough (the stride = the number of cells needed to fit the next label).
|
||||
* The Token bar chart's cells are each ≥ 2×25px, so in practice every day
|
||||
* gets labeled; when cells are narrow it automatically skips a few cells between labels so they don't blur together.
|
||||
*/
|
||||
export function autoLabelIdx(n: number, step: number): number[] {
|
||||
if (n <= 0) return [];
|
||||
const stride = step > 0 ? Math.max(1, Math.ceil(LABEL_MIN_PX / step)) : n;
|
||||
const idx: number[] = [];
|
||||
for (let i = 0; i < n; i += stride) idx.push(i);
|
||||
return idx;
|
||||
}
|
||||
|
||||
/** Request success rate: no requests (total=0) is treated as 1 (matches the old bar's convention, avoiding 0/0). */
|
||||
export function successRate(completed: number, total: number): number {
|
||||
return total > 0 ? completed / total : 1;
|
||||
}
|
||||
|
||||
// —— Daily Token: bar + three-segment stack ——
|
||||
|
||||
/**
|
||||
* Bar width (**real CSS pixels**, since 1 canvas unit = 1 pixel): **a fixed
|
||||
* value, not a minimum** — it used to be implemented as "no less than 25px",
|
||||
* which made bars stretch to fill the container when there were few points
|
||||
* (3 daily points could balloon to ~180px), defeating the intent of "25px
|
||||
* bar width". Now it's always 25px: scroll when it doesn't fit, and give the extra space to bar spacing when it does.
|
||||
*/
|
||||
export const BAR_W = 25;
|
||||
|
||||
/**
|
||||
* Minimum height of the per-segment hover hit band (canvas units = pixels,
|
||||
* innerH=168): in real data, output is often under 1% of the day's total
|
||||
* (sub-pixel height), and if the hit area equaled the visual rectangle it
|
||||
* would be un-hoverable — highlighting down to "every segment" is this
|
||||
* chart's core requirement. Widening the bar (≥25px) doesn't help the
|
||||
* vertical dimension either: a sub-pixel value stays sub-pixel, so this
|
||||
* floor must be kept.
|
||||
* (The hit band's **width** is a separate matter: it spans the full cell horizontally, see TokenBarChart's hitLayer.)
|
||||
*/
|
||||
export const MIN_HIT_H = 8;
|
||||
|
||||
/** The Token bar chart's horizontal layout: bar width (always BAR_W), total canvas width, and whether the content overflows the container (needing horizontal scroll). */
|
||||
export interface TokenBarLayout {
|
||||
/** Bar width (CSS pixels): always BAR_W. */
|
||||
barW: number;
|
||||
/** Total canvas width (CSS pixels): fills the container, or overflows it per "bar + equal spacing". */
|
||||
chartW: number;
|
||||
/** Canvas is wider than the container: the caller needs horizontal scrolling to see it all. */
|
||||
scroll: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token bar chart horizontal layout: **bar width is always BAR_W (25 real
|
||||
* pixels), bar spacing ≥ bar width**.
|
||||
* - Many points (n×2×25px doesn't fit): each cell is exactly 2× the bar
|
||||
* width, and the canvas overflows the container in real pixels →
|
||||
* the container scrolls horizontally (no scaling, no squeezing);
|
||||
* - Few points (fits): the canvas fills the container, and **all the extra
|
||||
* space goes to bar spacing** — bars no longer stretch (the old
|
||||
* implementation treated 25px as a floor, letting 3 daily points' bars
|
||||
* balloon to ~180px), they just stand farther apart.
|
||||
*/
|
||||
export function tokenBarLayout(containerW: number, n: number): TokenBarLayout {
|
||||
const innerW = Math.max(0, containerW - PAD_L - PAD_R);
|
||||
const needed = 2 * BAR_W * n; // inner width needed to lay out n bars (bar + equal spacing)
|
||||
if (needed <= innerW) return { barW: BAR_W, chartW: containerW, scroll: false };
|
||||
return { barW: BAR_W, chartW: PAD_L + needed + PAD_R, scroll: true };
|
||||
}
|
||||
|
||||
/** One segment within a bar: the visual rectangle is drawn strictly to value, the hit band is computed separately (small segments are raised to be hoverable). */
|
||||
export interface BarSegment {
|
||||
key: TokenBucketKey;
|
||||
value: number;
|
||||
/** Visual rectangle: segments sit flush against each other, total height = the day's total (no visual floor, no inflating the bar's height). */
|
||||
y: number;
|
||||
h: number;
|
||||
/** Hit band: fills the whole bar bottom-to-top with no overlap, small segments raised to minHit. */
|
||||
hitY: number;
|
||||
hitH: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hit-band height allocation (water-filling): segments below minHit are
|
||||
* raised to minHit, the rest share the remaining space proportionally to
|
||||
* their visual height; when the whole bar is shorter than k*minHit it
|
||||
* degrades to an even split (nobody can squeeze anybody else out).
|
||||
* Guarantee: the segment heights sum to total (the hit band fills the whole bar with no overlap).
|
||||
*/
|
||||
function hitHeights(heights: number[], total: number, minHit: number): number[] {
|
||||
const k = heights.length;
|
||||
if (k === 0) return [];
|
||||
if (total <= k * minHit) return heights.map(() => total / k);
|
||||
const small = new Set<number>();
|
||||
// Each round adds at most one segment to small; total > k*minHit guarantees not every segment gets added (some segment must end up with > minHit).
|
||||
for (;;) {
|
||||
const rest = total - small.size * minHit;
|
||||
const bigSum = heights.reduce((s, h, i) => (small.has(i) ? s : s + h), 0);
|
||||
const scaled = (i: number) =>
|
||||
bigSum > 0 ? (heights[i]! / bigSum) * rest : rest / (k - small.size);
|
||||
const next = heights.findIndex((_, i) => !small.has(i) && scaled(i) < minHit);
|
||||
if (next < 0) return heights.map((_, i) => (small.has(i) ? minHit : scaled(i)));
|
||||
small.add(next);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stacking order: bottom-to-top output → cacheWrite → cacheRead (matches TOKEN_COLORS' shading, darkest at the bottom). */
|
||||
const STACK_ORDER: readonly TokenBucketKey[] = ["output", "cacheWrite", "cacheRead"];
|
||||
|
||||
/**
|
||||
* A bar's three-segment stack: bottom-to-top output → cacheWrite →
|
||||
* cacheRead, a zero-value bucket produces no segment (not drawn, and shouldn't be hoverable). The visual rectangle is drawn strictly to value; see hitHeights for the hit band.
|
||||
*/
|
||||
export function barSegments(
|
||||
geom: ChartGeom,
|
||||
p: { cacheRead: number; cacheWrite: number; output: number },
|
||||
): BarSegment[] {
|
||||
const stack = STACK_ORDER.map((key) => ({ key, value: p[key] })).filter((b) => b.value > 0);
|
||||
if (stack.length === 0) return [];
|
||||
|
||||
// Visual rectangles: the top edge is taken from the cumulative value, so segments sit flush against each other.
|
||||
const rects: Array<{ y: number; h: number }> = [];
|
||||
let cum = 0;
|
||||
for (const b of stack) {
|
||||
const bottom = geom.y(cum);
|
||||
cum += b.value;
|
||||
const top = geom.y(cum);
|
||||
rects.push({ y: top, h: bottom - top });
|
||||
}
|
||||
|
||||
const base = geom.y(0);
|
||||
const hits = hitHeights(
|
||||
rects.map((r) => r.h),
|
||||
base - geom.y(cum),
|
||||
MIN_HIT_H,
|
||||
);
|
||||
let hitBottom = base;
|
||||
return stack.map((b, i) => {
|
||||
const hitH = hits[i]!;
|
||||
const seg: BarSegment = {
|
||||
key: b.key,
|
||||
value: b.value,
|
||||
y: rects[i]!.y,
|
||||
h: rects[i]!.h,
|
||||
hitY: hitBottom - hitH,
|
||||
hitH,
|
||||
};
|
||||
hitBottom -= hitH;
|
||||
return seg;
|
||||
});
|
||||
}
|
||||
|
||||
// —— Each Agent's call count: pie chart ——
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
/** Path coordinates keep 2 decimal places: the path string stays short and readable, and is easy to assert on in unit tests. */
|
||||
const rnd = (v: number): number => Math.round(v * 100) / 100;
|
||||
|
||||
/** Take a point in polar coordinates: angle is measured from 12 o'clock, clockwise-positive (SVG's y-axis points down). */
|
||||
function polar(cx: number, cy: number, r: number, angle: number): [number, number] {
|
||||
const a = angle - Math.PI / 2;
|
||||
return [rnd(cx + r * Math.cos(a)), rnd(cy + r * Math.sin(a))];
|
||||
}
|
||||
|
||||
/** A single pie slice. */
|
||||
export interface PieSlice {
|
||||
/** Index within the passed-in values (the caller uses this to look up name and color). */
|
||||
index: number;
|
||||
value: number;
|
||||
/** Fraction of the total [0,1]. */
|
||||
frac: number;
|
||||
/** Start/end angle (radians, clockwise from 12 o'clock). */
|
||||
start: number;
|
||||
end: number;
|
||||
/** The slice's path. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice path: `M center L start A radius … end Z`; sweep=1 means clockwise,
|
||||
* large-arc=1 when spanning more than a semicircle.
|
||||
* At 100% the start and end points coincide and the A command degrades into
|
||||
* "draws nothing" — split into two semicircular arcs to get a full circle.
|
||||
*/
|
||||
function slicePath(cx: number, cy: number, r: number, start: number, end: number): string {
|
||||
if (end - start >= TAU - 1e-9) {
|
||||
const [tx, ty] = polar(cx, cy, r, 0);
|
||||
const [bx, by] = polar(cx, cy, r, Math.PI);
|
||||
return `M${tx},${ty} A${r},${r} 0 1 1 ${bx},${by} A${r},${r} 0 1 1 ${tx},${ty} Z`;
|
||||
}
|
||||
const [x0, y0] = polar(cx, cy, r, start);
|
||||
const [x1, y1] = polar(cx, cy, r, end);
|
||||
const large = end - start > Math.PI ? 1 : 0;
|
||||
return `M${rnd(cx)},${rnd(cy)} L${x0},${y0} A${r},${r} 0 ${large} 1 ${x1},${y1} Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pie slices: laid out clockwise from 12 o'clock in the order passed in,
|
||||
* each slice's angle = that value's share of the total.
|
||||
* Non-positive values produce no slice (a 0-degree arc is a degenerate
|
||||
* path); when the total ≤ 0, returns empty (the caller falls back to an empty state).
|
||||
*/
|
||||
export function pieSlices(values: number[], cx: number, cy: number, r: number): PieSlice[] {
|
||||
const total = values.reduce((s, v) => s + Math.max(0, v), 0);
|
||||
if (total <= 0) return [];
|
||||
const slices: PieSlice[] = [];
|
||||
let start = 0;
|
||||
values.forEach((value, index) => {
|
||||
if (value <= 0) return;
|
||||
const frac = value / total;
|
||||
const end = start + frac * TAU;
|
||||
slices.push({ index, value, frac, start, end, path: slicePath(cx, cy, r, start, end) });
|
||||
start = end;
|
||||
});
|
||||
return slices;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Shared SVG skeleton for the daily trend charts (extracted from the
|
||||
* original TrendChart, reused by both the daily Token stacked bar and the
|
||||
* daily cost line): 4 horizontal grid lines + y-axis ticks, x-axis dates, a
|
||||
* hover vertical indicator line + a transparent hit area + a value bubble
|
||||
* that follows the cursor. "Data marks" (line / area / bars) are drawn by
|
||||
* the caller as children in the same x()/y() coordinate system;
|
||||
* see chart-geom.ts for the coordinate math.
|
||||
*
|
||||
* **1 canvas unit = 1 CSS pixel**: the SVG renders at real pixel width per
|
||||
* geom.w (not scaled via viewBox), so sizing requirements like "25px bar
|
||||
* width" land on real display pixels. The canvas width is supplied by the
|
||||
* caller after measuring the container with useChartWidth; when the canvas
|
||||
* is wider than the container (e.g. the Token bar chart stretched out by its bar-width floor), the outer container scrolls horizontally, and the bubble scrolls along with the content.
|
||||
*
|
||||
* Two hit-granularity tiers: the default is "whole column" (the cost line —
|
||||
* a column only has one value); the bar chart passes hitLayer to override
|
||||
* it as "per-segment" (a column has three segments, each independently
|
||||
* hoverable), in which case hover only serves as the bubble's anchor (a
|
||||
* column index).
|
||||
* The hover vertical line likewise has two tiers: on the line chart it's a
|
||||
* necessary x-position indicator, while on the bar chart the bar itself
|
||||
* already indicates the x position — an extra vertical line would just be
|
||||
* noise, so the bar chart passes hoverLine={false} to turn it off.
|
||||
*/
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { CHART_H, PAD_L, PAD_R, PAD_T, sparseLabelIdx, type ChartGeom } from "./chart-geom";
|
||||
|
||||
/** Upper bound on bubble width: clamps the bubble back inside the canvas near the right edge, so it doesn't spuriously trigger extra horizontal scroll. */
|
||||
const BUBBLE_W = 160;
|
||||
|
||||
/**
|
||||
* Measure the available width inside the chart card (CSS pixels, rounded
|
||||
* down — a few stray tenths of a pixel would otherwise spawn a scrollbar out of nowhere).
|
||||
* The canvas is drawn at real pixels, so the container must be measured
|
||||
* first; returns 0 before it's measured (the first frame), and the caller
|
||||
* skips rendering the chart at that point.
|
||||
* What's measured is the **outer plain div** (not the scroll container), whose width is independent of the canvas content and won't trigger the scrollbar back and forth.
|
||||
*/
|
||||
export function useChartWidth(): [RefObject<HTMLDivElement | null>, number] {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const measure = () => setWidth(Math.floor(el.getBoundingClientRect().width));
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return [ref, width];
|
||||
}
|
||||
|
||||
export function ChartFrame({
|
||||
geom,
|
||||
fmtY,
|
||||
dates,
|
||||
hover,
|
||||
onHover,
|
||||
bubble,
|
||||
hitLayer,
|
||||
labels,
|
||||
hoverLine = true,
|
||||
scrollToEnd = false,
|
||||
children,
|
||||
}: {
|
||||
geom: ChartGeom;
|
||||
/** y-axis tick formatting (abbreviated for Token, currency for cost). */
|
||||
fmtY: (v: number) => string;
|
||||
/** Each point's date (x-axis labels and the hit area align to this). */
|
||||
dates: string[];
|
||||
/** Currently hovered column index (the anchor for the vertical line and bubble). */
|
||||
hover: number | null;
|
||||
/** Callback for the default hit area (whole column); always called with null when the mouse leaves the whole chart. */
|
||||
onHover: (i: number | null) => void;
|
||||
/** Bubble content while hovering point i (omit to not show a bubble). */
|
||||
bubble?: (i: number) => ReactNode;
|
||||
/** Custom hit layer (per-segment hits for the bar chart): omit to use the default "whole column" transparent hit area. */
|
||||
hitLayer?: ReactNode;
|
||||
/** Indices for x-axis labels (omit for the default first/middle/last sparse labeling): the bar chart's cells are each wide, so it can label more via autoLabelIdx. */
|
||||
labels?: number[];
|
||||
/** Hover vertical indicator line (drawn by default): the bar chart turns it off — the bar itself already indicates the x position, so an extra line is just noise. */
|
||||
hoverLine?: boolean;
|
||||
/** Scroll to the far right by default when the canvas is wider than the container: the daily chart shows the most recent days first (scroll left for earlier ones). */
|
||||
scrollToEnd?: boolean;
|
||||
/** Data marks: bars / line / area, drawn between the grid and the hit area. */
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const { x, y, w, innerH, step, max } = geom;
|
||||
const gridLevels = [0, 0.25, 0.5, 0.75, 1].map((f) => max * f);
|
||||
const labelIdx = labels ?? sparseLabelIdx(dates.length);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The daily chart defaults to sitting on the most recent day (there's only
|
||||
// room to scroll when the canvas is wider than the container). It
|
||||
// re-snaps whenever the data or canvas width changes, without disturbing a position the user has manually scrolled to in the meantime.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!scrollToEnd || !el) return;
|
||||
el.scrollLeft = el.scrollWidth - el.clientWidth;
|
||||
}, [scrollToEnd, w, dates.length]);
|
||||
|
||||
return (
|
||||
// Horizontal scroll when the canvas is wider than the container (bar
|
||||
// width has a pixel floor, so 30 days won't fit in a half-width panel);
|
||||
// the bubble is this container's absolutely-positioned child element and scrolls along with the content, so anchoring it to the column by pixels is enough.
|
||||
<div ref={scrollRef} className="relative overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${w} ${CHART_H}`}
|
||||
width={w}
|
||||
height={CHART_H}
|
||||
className="text-gray-600 dark:text-gray-400"
|
||||
role="img"
|
||||
onMouseLeave={() => onHover(null)}
|
||||
>
|
||||
{/* Grid lines and y-axis ticks (recessive gray) */}
|
||||
{gridLevels.map((v, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={PAD_L}
|
||||
x2={w - PAD_R}
|
||||
y1={y(v)}
|
||||
y2={y(v)}
|
||||
className="stroke-gray-200 dark:stroke-gray-800"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={PAD_L - 6}
|
||||
y={y(v) + 3}
|
||||
textAnchor="end"
|
||||
className="fill-gray-400 dark:fill-gray-500"
|
||||
fontSize={9}
|
||||
>
|
||||
{fmtY(v)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Hover vertical indicator line (line-chart-only: the bar chart's bar itself is the x indicator, see hoverLine) */}
|
||||
{hoverLine && hover !== null && dates[hover] && (
|
||||
<line
|
||||
x1={x(hover)}
|
||||
x2={x(hover)}
|
||||
y1={PAD_T}
|
||||
y2={PAD_T + innerH}
|
||||
className="stroke-gray-300 dark:stroke-gray-700"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Data marks (provided by the caller) */}
|
||||
{children}
|
||||
|
||||
{/* x-axis dates */}
|
||||
{labelIdx.map((i) => {
|
||||
const d = dates[i];
|
||||
if (!d) return null;
|
||||
return (
|
||||
<text
|
||||
key={i}
|
||||
x={x(i)}
|
||||
y={CHART_H - 6}
|
||||
textAnchor="middle"
|
||||
className="fill-gray-400 dark:fill-gray-500"
|
||||
fontSize={9}
|
||||
>
|
||||
{d.slice(5)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Hover hit area (larger than the mark itself): whole column by default, the bar chart swaps in hitLayer for per-segment */}
|
||||
{hitLayer ??
|
||||
dates.map((_, i) => (
|
||||
<rect
|
||||
key={`hit-${i}`}
|
||||
x={PAD_L + step * i}
|
||||
y={PAD_T}
|
||||
width={step}
|
||||
height={innerH}
|
||||
fill="transparent"
|
||||
className="cursor-crosshair"
|
||||
onMouseEnter={() => onHover(i)}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{bubble && hover !== null && dates[hover] && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 rounded border border-gray-200 bg-white px-2 py-1 text-xs shadow-sm dark:border-gray-700 dark:bg-gray-900"
|
||||
// Anchored near that column's left edge, clamped back inside the
|
||||
// canvas (1 unit = 1 pixel, positioned directly in pixels; the
|
||||
// left edge can't be negative, since the scroll container would clip off the part that sticks out).
|
||||
style={{ left: `${Math.max(0, Math.min(x(hover) - 30, w - BUBBLE_W))}px` }}
|
||||
>
|
||||
{bubble(hover)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Server-side error view for the cost center: **a single panel** — a row of small
|
||||
* stats up top (total / unexpected / expected / most common error code),
|
||||
* with a recent-errors table below (time, source · error code, kind,
|
||||
* message). What an error needs to answer is "what exactly went wrong" — a
|
||||
* detail table is more direct than a chart here: the count alone in the stats already covers the summary.
|
||||
*
|
||||
* Color semantics are consistent site-wide: unexpected (500s / runtime
|
||||
* exceptions) is a prominent rose; expected (HttpError, business 4xx) recedes into gray.
|
||||
* The outer frame is provided by the caller's ChartCard (full width, below the four business charts).
|
||||
*/
|
||||
import type { UsageErrors } from "@prismshadow/penguin-server/api";
|
||||
import { S } from "../../lib/strings";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Empty } from "./usage-charts";
|
||||
|
||||
/** The two error categories. */
|
||||
type ErrorKindKey = "unexpected" | "expected";
|
||||
|
||||
/** Copy: S is a runtime live binding (switching language remounts the whole tree), so it must be read at render time. */
|
||||
function kindLabel(key: ErrorKindKey): string {
|
||||
return key === "unexpected" ? S.usage.errorsUnexpected : S.usage.errorsExpected;
|
||||
}
|
||||
|
||||
function kindOf(kind: string): ErrorKindKey {
|
||||
return kind === "unexpected" ? "unexpected" : "expected";
|
||||
}
|
||||
|
||||
/** A single small stat: name + value, one row side by side (not turned into a chart). */
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
alert,
|
||||
muted,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
/** Prominent value (unexpected errors): rose. */
|
||||
alert?: boolean;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
const tone = alert
|
||||
? "text-rose-600 dark:text-rose-400"
|
||||
: muted
|
||||
? "text-gray-500 dark:text-gray-400"
|
||||
: "text-gray-900 dark:text-gray-100";
|
||||
return (
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{label}</span>
|
||||
<span className={`font-mono text-sm font-semibold tabular-nums ${tone}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Header cell: left-aligned, recessive gray; stickiness is handled by thead. */
|
||||
function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) {
|
||||
return <th className={`py-1.5 pr-2 font-medium ${className}`}>{children}</th>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error panel: stats + a recent-errors table (the server already takes the top N, newest first).
|
||||
* The table is table-fixed with in-cell truncation: a long message doesn't break the layout, and the full text goes into title.
|
||||
*/
|
||||
export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
|
||||
const { total, unexpected, topCode, recent } = errors;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stats: a row of small stats (unexpected is prominent, expected recedes) */}
|
||||
<div className="flex flex-wrap items-baseline gap-x-6 gap-y-1.5">
|
||||
<Stat label={S.usage.errorsTotal} value={String(total)} />
|
||||
<Stat
|
||||
label={S.usage.errorsUnexpected}
|
||||
value={String(unexpected)}
|
||||
alert={unexpected > 0}
|
||||
muted={unexpected === 0}
|
||||
/>
|
||||
<Stat label={S.usage.errorsExpected} value={String(total - unexpected)} muted />
|
||||
{topCode && (
|
||||
<Stat
|
||||
label={S.usage.errorsTopCode}
|
||||
value={`${topCode.source} · ${topCode.code} ×${topCode.count}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent-errors table */}
|
||||
{recent.length === 0 ? (
|
||||
<Empty text={S.usage.errorsEmpty} />
|
||||
) : (
|
||||
<div className="mt-2.5 max-h-72 overflow-y-auto border-t border-gray-200 dark:border-gray-800">
|
||||
<table className="w-full table-fixed text-xs">
|
||||
<thead className="sticky top-0 bg-white text-left text-gray-400 dark:bg-gray-900 dark:text-gray-500">
|
||||
<tr>
|
||||
<Th className="w-32">{S.usage.errorsColTime}</Th>
|
||||
{/* Wide enough to fully fit the longest error code: a tool
|
||||
failure's code carries the tool name (e.g. environment ·
|
||||
tool_failed:exec_command), and truncating it would hide which tool failed. */}
|
||||
<Th className="w-72">{S.usage.errorsColCode}</Th>
|
||||
<Th className="w-20">{S.usage.errorsColKind}</Th>
|
||||
<Th>{S.usage.errorsColMessage}</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map((e, i) => {
|
||||
const key = kindOf(e.kind);
|
||||
return (
|
||||
<tr
|
||||
key={`${e.ts}-${i}`}
|
||||
className="border-t border-gray-100 dark:border-gray-800/60"
|
||||
>
|
||||
<td className="py-1.5 pr-2 font-mono tabular-nums text-gray-400">
|
||||
{formatDateTime(e.ts)}
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 font-mono text-gray-500 dark:text-gray-400">
|
||||
<span className="block truncate" title={`${e.source} · ${e.code}`}>
|
||||
{e.source} · {e.code}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-2">
|
||||
<Badge tone={key === "unexpected" ? "red" : "gray"}>{kindLabel(key)}</Badge>
|
||||
</td>
|
||||
<td className="py-1.5 text-gray-500 dark:text-gray-400">
|
||||
<span className="block truncate" title={e.message}>
|
||||
{e.message}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Daily cost trend chart (hand-drawn SVG, no chart
|
||||
* library; a single accent color + gray grid, desaturated in dark mode, no
|
||||
* clashing red/green): a line + a semi-transparent area layered down to the
|
||||
* baseline to reinforce the trend over time, with a hover vertical line + whole-column hit area + bubble.
|
||||
* The coordinate system / grid / hover logic is extracted into chart-svg.tsx's ChartFrame (shared with the daily Token bar chart).
|
||||
*
|
||||
* Canvas width = the container's measured pixels (1 unit = 1 pixel, see
|
||||
* chart-svg): the line chart itself has no "minimum step" requirement, so it
|
||||
* simply fills the container and never scrolls horizontally — but once the
|
||||
* Token bar chart went full-width, this chart shares the same row, and if it
|
||||
* still stretched a fixed 640-unit viewBox, its height would get capped by max-h and centered with large empty margins on both sides.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import type { UsageTrendPoint } from "@prismshadow/penguin-server/api";
|
||||
import { formatMoney } from "../../lib/format";
|
||||
import type { Currency } from "../../state/theme";
|
||||
import { makeGeom, linePath, areaPath } from "./chart-geom";
|
||||
import { ChartFrame, useChartWidth } from "./chart-svg";
|
||||
|
||||
export function TrendChart({
|
||||
points,
|
||||
currency = "USD",
|
||||
}: {
|
||||
points: UsageTrendPoint[];
|
||||
currency?: Currency;
|
||||
}) {
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const [ref, width] = useChartWidth();
|
||||
|
||||
const cost = points.map((p) => p.cost ?? 0);
|
||||
const max = Math.max(1e-9, ...cost);
|
||||
const geom = makeGeom(points.length, max, width);
|
||||
const dates = points.map((p) => p.date);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{width > 0 && (
|
||||
<ChartFrame
|
||||
geom={geom}
|
||||
fmtY={(v) => formatMoney(v, currency)}
|
||||
dates={dates}
|
||||
hover={hover}
|
||||
onHover={setHover}
|
||||
bubble={(i) => {
|
||||
const p = points[i]!;
|
||||
return (
|
||||
<>
|
||||
<p className="text-gray-400">{p.date}</p>
|
||||
<p className="font-mono">{formatMoney(p.cost, currency)}</p>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
>
|
||||
<g>
|
||||
{/* Area fill: the line closes down to the baseline, low opacity reinforces the trend's sense of "volume" */}
|
||||
<path
|
||||
d={areaPath(geom, cost)}
|
||||
className="fill-current"
|
||||
stroke="none"
|
||||
opacity={hover !== null ? 0.06 : 0.1}
|
||||
/>
|
||||
<path
|
||||
d={linePath(geom, cost)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
opacity={hover !== null ? 0.35 : 1}
|
||||
/>
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={p.date}
|
||||
cx={geom.x(i)}
|
||||
cy={geom.y(p.cost ?? 0)}
|
||||
r={hover === i ? 4 : 2.5}
|
||||
className="fill-current"
|
||||
opacity={hover !== null && hover !== i ? 0.25 : 1}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</ChartFrame>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Cost center stat charts: hand-drawn SVG
|
||||
* / flex, no chart library. The form follows the nature of the data —
|
||||
* - AgentPieChart: each Agent's call count → a pie chart (compositional
|
||||
* share; each slice's angle is that Agent's share of total calls);
|
||||
* - SuccessBarChart: each Model's success rate → a horizontal progress bar
|
||||
* with a 100% track (shows how far from perfect at a glance), filled with
|
||||
* a single uniform color (the bar's length alone conveys magnitude, not a three-color threshold);
|
||||
* - TokenBarChart: daily Token buckets → a three-segment stacked bar (one
|
||||
* bar per day, bottom-to-top output → cacheWrite → cacheRead, same blue
|
||||
* family, darkest at the bottom and lightest at the top; bar width fixed
|
||||
* at 25 real pixels, spacing ≥ bar width, scrolls horizontally when it doesn't fit the card).
|
||||
* Daily cost reuses TrendChart (line + area fill).
|
||||
*
|
||||
* Unified highlight interaction (a site-wide convention): highlight = fade
|
||||
* out the rest. Pie slices and the legend are linked both ways; the Token
|
||||
* bar is **precise down to the segment** — hovering a given day's given
|
||||
* bucket lights up only that segment, and the bubble reports only that segment's value (not the whole column's total).
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
UsageAgentCount,
|
||||
UsageSuccessRate,
|
||||
UsageTrendPoint,
|
||||
} from "@prismshadow/penguin-server/api";
|
||||
import { catalogEntryFor, providerInfo } from "@prismshadow/penguin-core/model-catalog";
|
||||
import { S } from "../../lib/strings";
|
||||
import { humanizeTokens } from "../../lib/format";
|
||||
import { TOKEN_COLORS } from "../../lib/token-colors";
|
||||
import { categoryColor } from "../../lib/category-colors";
|
||||
import {
|
||||
makeGeom,
|
||||
autoLabelIdx,
|
||||
barSegments,
|
||||
tokenBarLayout,
|
||||
pieSlices,
|
||||
successRate,
|
||||
type TokenBucketKey,
|
||||
} from "./chart-geom";
|
||||
import { ChartFrame, useChartWidth } from "./chart-svg";
|
||||
|
||||
/** Empty state for a chart card (defaults to "no usage records yet"; the errors chart passes its own copy). */
|
||||
export function Empty({ text }: { text?: string }) {
|
||||
return <p className="py-6 text-center text-xs text-gray-400">{text ?? S.usage.empty}</p>;
|
||||
}
|
||||
|
||||
/** Bucket name copy: S is a runtime live binding (switching language remounts the whole tree), so it must be read at render time and never cached at module scope. */
|
||||
function bucketLabel(key: TokenBucketKey): string {
|
||||
if (key === "cacheRead") return S.usage.colCacheRead;
|
||||
if (key === "cacheWrite") return S.usage.colCacheWrite;
|
||||
return S.usage.colOutput;
|
||||
}
|
||||
|
||||
/** Highlight = fade out the rest. */
|
||||
const DIM = "opacity-25";
|
||||
|
||||
// —— Each Agent's call count: pie chart ——
|
||||
|
||||
/** Pie chart canvas (square viewBox) and radius: leave a 5px margin so slice edges don't get clipped by the viewBox. */
|
||||
const PIE_SIZE = 160;
|
||||
const PIE_R = 75;
|
||||
|
||||
/**
|
||||
* Each Agent's call count → a pie chart: each slice's angle = that Agent's
|
||||
* share of total calls, laid out clockwise from 12 o'clock sorted by
|
||||
* requests descending (re-sorted here defensively). Slices and the legend
|
||||
* on the right link both ways: hovering either side lights up the other and fades out the rest.
|
||||
* When a single Agent holds 100%, pieSlices degrades to a full circle (see chart-geom).
|
||||
*/
|
||||
export function AgentPieChart({ data }: { data: UsageAgentCount[] }) {
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const total = data.reduce((s, d) => s + d.requests, 0);
|
||||
if (data.length === 0 || total <= 0) return <Empty />;
|
||||
|
||||
const rows = [...data].sort((a, b) => b.requests - a.requests);
|
||||
const slices = pieSlices(
|
||||
rows.map((d) => d.requests),
|
||||
PIE_SIZE / 2,
|
||||
PIE_SIZE / 2,
|
||||
PIE_R,
|
||||
);
|
||||
const pct = (v: number) => `${Math.round((v / total) * 100)}%`;
|
||||
const dim = (i: number) => (hover !== null && hover !== i ? DIM : "");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3" onMouseLeave={() => setHover(null)}>
|
||||
<svg
|
||||
viewBox={`0 0 ${PIE_SIZE} ${PIE_SIZE}`}
|
||||
className="h-40 w-40 shrink-0"
|
||||
role="img"
|
||||
aria-label={S.usage.chartAgentCalls}
|
||||
>
|
||||
{slices.map((s) => {
|
||||
const d = rows[s.index]!;
|
||||
return (
|
||||
<path
|
||||
key={d.agentId}
|
||||
d={s.path}
|
||||
onMouseEnter={() => setHover(s.index)}
|
||||
className={`cursor-pointer ${categoryColor(s.index).fill} transition-opacity duration-150 ${dim(s.index)}`}
|
||||
>
|
||||
<title>{`${d.agentId} · ${d.requests} ${S.usage.requests} · ${pct(d.requests)}`}</title>
|
||||
</path>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Legend: name + count + share (hover links to the pie slice; a long agentId truncates, with the title giving the full name and total Token count) */}
|
||||
<ul className="flex max-h-40 min-w-0 flex-1 flex-col gap-1 overflow-y-auto">
|
||||
{rows.map((d, i) => (
|
||||
<li
|
||||
key={d.agentId}
|
||||
onMouseEnter={() => setHover(d.requests > 0 ? i : null)}
|
||||
title={`${d.agentId} · ${d.requests} ${S.usage.requests} · ${humanizeTokens(d.total)}`}
|
||||
className={`flex cursor-pointer items-center gap-1.5 text-[10px] transition-opacity duration-150 ${dim(i)}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-sm ${categoryColor(i).swatch}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-gray-500 dark:text-gray-400">
|
||||
{d.agentId}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono tabular-nums text-gray-500 dark:text-gray-400">
|
||||
{d.requests}
|
||||
</span>
|
||||
<span className="w-8 shrink-0 text-right font-mono tabular-nums text-gray-400 dark:text-gray-500">
|
||||
{pct(d.requests)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// —— Each Model's success rate: horizontal progress bar ——
|
||||
|
||||
/** A progress bar row's shell: label on the left + track/fill in the middle + value on the right. Fades when hovering a different row. */
|
||||
function BarRow({
|
||||
dimmed,
|
||||
onEnter,
|
||||
title,
|
||||
label,
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
dimmed: boolean;
|
||||
onEnter: () => void;
|
||||
title: string;
|
||||
label: string;
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={onEnter}
|
||||
className={`flex cursor-pointer items-center gap-2 transition-opacity duration-150 ${dimmed ? DIM : ""}`}
|
||||
title={title}
|
||||
>
|
||||
<span className="w-24 shrink-0 truncate font-mono text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
<span className="w-10 shrink-0 text-right font-mono text-[10px] tabular-nums text-gray-500 dark:text-gray-400">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Row label: falls back from the catalog display name to the upstream id ((provider, modelId) paired lookup against the catalog). */
|
||||
function successLabel(d: UsageSuccessRate): string {
|
||||
return catalogEntryFor(d.provider, d.modelId)?.displayName ?? d.modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover detail: the model's paired reference (upstream id + provider name) +
|
||||
* `completed/denominator` + a failure breakdown + excluded aborted runs. The
|
||||
* denominator already excludes aborted (the user clicking "stop" isn't a
|
||||
* model failure), so aborted is listed as its own item and labeled "not counted".
|
||||
*/
|
||||
function successTitle(d: UsageSuccessRate): string {
|
||||
const parts = [`${d.completed}/${d.total}`];
|
||||
if (d.failed > 0) parts.push(`failed ${d.failed}`);
|
||||
if (d.timeout > 0) parts.push(`timeout ${d.timeout}`);
|
||||
if (d.malformed > 0) parts.push(`malformed ${d.malformed}`);
|
||||
if (d.aborted > 0) parts.push(`${S.usage.successAborted} ${d.aborted}`);
|
||||
const provider = providerInfo(d.provider)?.label ?? d.provider;
|
||||
return `${d.modelId} · ${provider} · ${parts.join(" · ")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each Model's success rate → a horizontal progress bar: the 100% track
|
||||
* (light gray) makes "how far from perfect" obvious at a glance, with the
|
||||
* percentage shown on the right. Filled with a **single uniform color**
|
||||
* (sky, the same primary color family as the Token chart): the bar's length
|
||||
* alone already conveys magnitude — a three-color threshold (green/yellow/red) would just re-encode the same information and add two more colors to the page unrelated to any site-wide meaning.
|
||||
*/
|
||||
export function SuccessBarChart({ data }: { data: UsageSuccessRate[] }) {
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
if (data.length === 0) return <Empty />;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5" onMouseLeave={() => setHover(null)}>
|
||||
{data.map((d, i) => {
|
||||
const rate = successRate(d.completed, d.total);
|
||||
const pct = Math.round(rate * 100);
|
||||
return (
|
||||
<BarRow
|
||||
// Row key is a pair: the same model_id can coexist under multiple providers, so using modelId alone would collide.
|
||||
key={`${d.provider}:${d.modelId}`}
|
||||
dimmed={hover !== null && hover !== i}
|
||||
onEnter={() => setHover(i)}
|
||||
title={successTitle(d)}
|
||||
label={successLabel(d)}
|
||||
value={`${pct}%`}
|
||||
>
|
||||
<div className="h-3 min-w-0 flex-1 overflow-hidden rounded-sm bg-gray-200 dark:bg-gray-800">
|
||||
<div
|
||||
className="h-full rounded-sm bg-sky-500 dark:bg-sky-400"
|
||||
style={{ width: `${rate * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</BarRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// —— Daily Token: three-segment stacked bar ——
|
||||
|
||||
/** The currently hovered segment: which day (column index), which bucket. */
|
||||
interface SegHover {
|
||||
i: number;
|
||||
key: TokenBucketKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily Token buckets → a three-segment stacked bar (SVG, reusing
|
||||
* TrendChart's coordinate system and grid), bottom-to-top output → cacheWrite → cacheRead.
|
||||
*
|
||||
* **Bar width fixed at 25 real pixels, spacing ≥ bar width** (see
|
||||
* chart-geom's tokenBarLayout): the canvas renders at real pixels per the
|
||||
* container's measured width (1 canvas unit = 1 pixel, no scaling); when 30
|
||||
* days' worth of n×2×25px doesn't fit a half-width card, the canvas
|
||||
* overflows and ChartFrame's container carries horizontal scroll; with few
|
||||
* points the bars **never stretch** (25px is a fixed value, not a floor) —
|
||||
* all the extra space goes to bar spacing, and the canvas still fills the card without a scrollbar.
|
||||
*
|
||||
* **Each segment is an independent, individually hoverable rect**: the hit
|
||||
* layer swaps ChartFrame's whole-column hit area for a per-segment hit band
|
||||
* (see chart-geom's barSegments — the hit band fills the whole bar and small
|
||||
* segments have a height floor, otherwise a sub-pixel output segment would
|
||||
* be un-hoverable; widening the bar doesn't help the vertical dimension
|
||||
* either). Hitting a segment highlights only that segment and fades out
|
||||
* everything else; the bubble reports only that segment's date/bucket
|
||||
* name/Token count. When legend is passed in (legend hover), it highlights all segments of the matching bucket.
|
||||
* No hover vertical line is drawn (hoverLine={false}): the bar itself already indicates the x position.
|
||||
*/
|
||||
export function TokenBarChart({
|
||||
trend,
|
||||
legend,
|
||||
}: {
|
||||
trend: UsageTrendPoint[];
|
||||
/** The bucket currently hovered in the legend (highlights matching segments); null = none. */
|
||||
legend?: TokenBucketKey | null;
|
||||
}) {
|
||||
const [hover, setHover] = useState<SegHover | null>(null);
|
||||
// Bar width is a pixel constraint, so the container must be measured
|
||||
// first (unmeasured on the first frame → width=0, at which point nothing is rendered — see the ref container below).
|
||||
const [ref, width] = useChartWidth();
|
||||
if (trend.length === 0) return <Empty />;
|
||||
|
||||
const sums = trend.map((p) => p.cacheRead + p.cacheWrite + p.output);
|
||||
const max = Math.max(1, ...sums);
|
||||
const { barW, chartW, scroll } = tokenBarLayout(width, trend.length);
|
||||
const geom = makeGeom(trend.length, max, chartW);
|
||||
const dates = trend.map((p) => p.date);
|
||||
const segs = trend.map((p) => barSegments(geom, p));
|
||||
|
||||
// Highlight = fade out the rest: segment-level hover leaves only "that day's that bucket", legend hover leaves all segments of the matching bucket.
|
||||
const dimmed = (i: number, key: TokenBucketKey) =>
|
||||
(hover !== null && !(hover.i === i && hover.key === key)) || (legend != null && legend !== key);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{width > 0 && (
|
||||
<ChartFrame
|
||||
geom={geom}
|
||||
fmtY={(v) => humanizeTokens(Math.round(v))}
|
||||
dates={dates}
|
||||
hover={hover?.i ?? null}
|
||||
// Each cell ≥ 50px: dates can be labeled every day (autoLabelIdx sets the density by cell width, so they never blur together).
|
||||
labels={autoLabelIdx(trend.length, geom.step)}
|
||||
// The bar itself indicates x position: no hover vertical line spanning the whole chart.
|
||||
hoverLine={false}
|
||||
// 30 days doesn't fit a half-width card (bar width fixed at 25px): defaults to sitting on the most recent day, scrolling left for earlier ones.
|
||||
scrollToEnd={scroll}
|
||||
// Per-segment hits go through hitLayer below; ChartFrame only calls back when the mouse leaves the whole chart (i=null).
|
||||
onHover={(i) => {
|
||||
if (i === null) setHover(null);
|
||||
}}
|
||||
bubble={(i) => {
|
||||
const p = trend[i]!;
|
||||
const key = hover?.key;
|
||||
if (!key) return null;
|
||||
return (
|
||||
<>
|
||||
<p className="text-gray-400">{p.date}</p>
|
||||
<p className="font-mono">
|
||||
{bucketLabel(key)} {humanizeTokens(p[key])}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
hitLayer={trend.map((p, i) =>
|
||||
segs[i]!.map((s) => (
|
||||
// The hit band is as wide as the bar horizontally (empty space
|
||||
// outside the bar doesn't trigger highlighting), and split by
|
||||
// segment vertically with no overlap (small segments raised
|
||||
// to the minimum hit height, see hitHeights). Highlighting
|
||||
// clears as soon as the pointer leaves the bar — otherwise the
|
||||
// previous segment's highlight would linger when moving from the bar into the empty space.
|
||||
<rect
|
||||
key={`hit-${p.date}-${s.key}`}
|
||||
x={geom.x(i) - barW / 2}
|
||||
y={s.hitY}
|
||||
width={barW}
|
||||
height={s.hitH}
|
||||
fill="transparent"
|
||||
className="cursor-pointer"
|
||||
onMouseEnter={() => setHover({ i, key: s.key })}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
>
|
||||
{trend.map((p, i) =>
|
||||
segs[i]!.map((s) => (
|
||||
<rect
|
||||
key={`${p.date}-${s.key}`}
|
||||
x={geom.x(i) - barW / 2}
|
||||
y={s.y}
|
||||
width={barW}
|
||||
height={s.h}
|
||||
fill={TOKEN_COLORS[s.key]}
|
||||
className="transition-opacity duration-150"
|
||||
opacity={dimmed(i, s.key) ? 0.2 : 1}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</ChartFrame>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Token bar chart legend (cacheRead / cacheWrite / output): hovering an item highlights matching segments (fading out the rest). */
|
||||
export function TokenLegend({
|
||||
active,
|
||||
onHover,
|
||||
}: {
|
||||
active?: TokenBucketKey | null;
|
||||
onHover?: (key: TokenBucketKey | null) => void;
|
||||
}) {
|
||||
const items: Array<[TokenBucketKey, string]> = [
|
||||
["cacheRead", S.usage.colCacheRead],
|
||||
["cacheWrite", S.usage.colCacheWrite],
|
||||
["output", S.usage.colOutput],
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{items.map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onMouseEnter={() => onHover?.(key)}
|
||||
onMouseLeave={() => onHover?.(null)}
|
||||
className={`flex items-center gap-1 text-[10px] text-gray-500 transition-opacity duration-150 dark:text-gray-400 ${
|
||||
active != null && active !== key ? "opacity-30" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2 w-3 rounded-sm"
|
||||
style={{ backgroundColor: TOKEN_COLORS[key] }}
|
||||
/>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Cost and usage center:
|
||||
* top filters for Agent / Model + a date range (controls have no external
|
||||
* title, the explanation is written into the dropdown options themselves);
|
||||
* three summary cards (today / last 7 days / cumulative, each stat on its own row);
|
||||
* four business charts arranged two-by-two, each taking half the width — a
|
||||
* row of compositional charts (each Agent's call count pie chart, each
|
||||
* Model's success rate progress bar), and a row of time series (daily Token
|
||||
* three-segment stacked bar, daily cost line + area): Token bar width is
|
||||
* fixed at 25px, and it scrolls horizontally within the card when 30 days
|
||||
* doesn't fit the half-width; below that is a full-width "errors" panel
|
||||
* (stats + a recent-errors table).
|
||||
* Currency follows the user's settings; a row with unconfigured pricing shows its cost as "—".
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import type { ModelRefDto, UsageBucket, UsageResponse } from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { useDocumentTitle } from "../../lib/use-document-title";
|
||||
import { formatMoney, humanizeTokens } from "../../lib/format";
|
||||
import { catalogEntryFor } from "@prismshadow/penguin-core/model-catalog";
|
||||
import { useProject } from "../../state/project";
|
||||
import { useTheme } from "../../state/theme";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import { Skeleton } from "../../components/ui/skeleton";
|
||||
import { TrendChart } from "./trend-chart";
|
||||
import type { TokenBucketKey } from "./chart-geom";
|
||||
import { AgentPieChart, SuccessBarChart, TokenBarChart, TokenLegend } from "./usage-charts";
|
||||
import { ErrorsPanel } from "./errors-panel";
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`);
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** A summary card's stat row: name on the left, value on the right — each item on its own row, so a narrow card no longer crams them into one wrapping line. */
|
||||
function SummaryRow({
|
||||
label,
|
||||
value,
|
||||
muted,
|
||||
sup,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
muted?: boolean;
|
||||
/** Show a superscript marker (the cost row's "includes unpriced records" asterisk sits next to the **name**, not stuck after the number). */
|
||||
sup?: boolean;
|
||||
}) {
|
||||
const tone = muted ? "text-gray-500 dark:text-gray-400" : "text-gray-900 dark:text-gray-100";
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="shrink-0 text-xs text-gray-500 dark:text-gray-400">
|
||||
{label}
|
||||
{sup && <sup className="ml-px">*</sup>}
|
||||
</span>
|
||||
<span className={`min-w-0 truncate font-mono text-sm font-semibold tabular-nums ${tone}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Usage summary card: a title + Token / request count / cost each on their own row. */
|
||||
function SummaryCard({
|
||||
title,
|
||||
bucket,
|
||||
currency,
|
||||
}: {
|
||||
title: string;
|
||||
bucket: UsageBucket;
|
||||
currency: "USD" | "CNY";
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
|
||||
<p className="mb-1.5 text-xs font-medium text-gray-500 dark:text-gray-400">{title}</p>
|
||||
<div className="space-y-0.5">
|
||||
<SummaryRow label={S.usage.tokens} value={humanizeTokens(bucket.total)} />
|
||||
<SummaryRow label={S.usage.requests} value={String(bucket.requests)} muted />
|
||||
{/* The unpriced-records asterisk sits on the word "cost" (superscript), keeping the number clean and readable; see the footer for the explanation */}
|
||||
<SummaryRow
|
||||
label={S.usage.colCost}
|
||||
value={formatMoney(bucket.cost, currency)}
|
||||
sup={bucket.hasUncosted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Chart card container: title + content (bounded height, avoiding stretching the whole page and triggering extra scroll). */
|
||||
function ChartCard({
|
||||
title,
|
||||
extra,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
extra?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">{title}</p>
|
||||
{extra}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsagePage() {
|
||||
useDocumentTitle(S.usage.title);
|
||||
const { currency } = useTheme();
|
||||
const { currentProject } = useProject();
|
||||
const projectId = currentProject?.projectId ?? null;
|
||||
|
||||
// ?agentId= deep link (from the Agents page's "cost" entry point): the URL
|
||||
// parameter is the single source of truth for this filter — including
|
||||
// clearing it (/usage?agentId=A → clicking nav to /usage doesn't remount,
|
||||
// so the filter must be reset); manually changing the filter doesn't write
|
||||
// back to the URL (consistent with the existing convention that the model
|
||||
// / date filters likewise don't enter the URL — the effect never overrides a manual selection when the parameter is unchanged).
|
||||
const [searchParams] = useSearchParams();
|
||||
const paramAgentId = searchParams.get("agentId");
|
||||
const [agentFilter, setAgentFilter] = useState<string>(paramAgentId ?? "");
|
||||
// Model filtering is a **paired reference** (the same model_id can coexist
|
||||
// under multiple providers); the dropdown's option value uses the
|
||||
// candidate's index rather than a concatenated string — the reference is always passed as a pair, never concatenated into an id.
|
||||
const [modelFilter, setModelFilter] = useState<ModelRefDto | null>(null);
|
||||
useEffect(() => {
|
||||
setAgentFilter(paramAgentId ?? "");
|
||||
}, [paramAgentId]);
|
||||
const [from, setFrom] = useState(() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 29);
|
||||
return isoDate(d);
|
||||
});
|
||||
const [to, setTo] = useState(() => isoDate(new Date()));
|
||||
const [data, setData] = useState<UsageResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// The bucket currently hovered in the legend: the legend lives in the card
|
||||
// header (ChartCard's extra) while the bars live inside the card, so this state is lifted to this level.
|
||||
const [tokenBucket, setTokenBucket] = useState<TokenBucketKey | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.getUsage(projectId, {
|
||||
from,
|
||||
to,
|
||||
// The detail table has been removed, superseded by the charts above; groupBy is still a required query parameter, fixed to group by date.
|
||||
groupBy: "date",
|
||||
...(agentFilter ? { agentId: agentFilter } : {}),
|
||||
...(modelFilter ? { provider: modelFilter.provider, modelId: modelFilter.modelId } : {}),
|
||||
});
|
||||
setData(res);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
}
|
||||
}, [projectId, from, to, agentFilter, modelFilter?.provider, modelFilter?.modelId]);
|
||||
|
||||
useEffect(() => {
|
||||
setData(null);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (!projectId) return null;
|
||||
|
||||
// Model filter candidates and the currently selected item's index (the option value uses the index, avoiding concatenating an id as the key).
|
||||
const modelOptions = data?.models ?? [];
|
||||
const selectedModelIndex = modelFilter
|
||||
? modelOptions.findIndex(
|
||||
(m) => m.provider === modelFilter.provider && m.modelId === modelFilter.modelId,
|
||||
)
|
||||
: -1;
|
||||
const modelFilterIndex = selectedModelIndex >= 0 ? String(selectedModelIndex) : "";
|
||||
|
||||
const summary = data?.summary;
|
||||
const hasUncostedRows =
|
||||
(summary?.today.hasUncosted ?? false) ||
|
||||
(summary?.last7d.hasUncosted ?? false) ||
|
||||
(summary?.total.hasUncosted ?? false);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 md:p-6">
|
||||
<div className="mx-auto max-w-5xl space-y-4">
|
||||
{/* Top filters: controls have no external title (the explanation is written into the "all …" option), so they're baseline-centered with the page title */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-xl font-semibold">{S.usage.title}</h1>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="w-32">
|
||||
<Select
|
||||
size="sm"
|
||||
value={agentFilter}
|
||||
onChange={(e) => setAgentFilter(e.target.value)}
|
||||
>
|
||||
<option value="">{S.usage.filterAllAgents}</option>
|
||||
{/* The deep-linked agent must still show as the selected option even if it has no usage records yet (not in agentIds) */}
|
||||
{agentFilter && !(data?.agentIds ?? []).includes(agentFilter) && (
|
||||
<option value={agentFilter}>{agentFilter}</option>
|
||||
)}
|
||||
{(data?.agentIds ?? []).map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Select
|
||||
size="sm"
|
||||
value={modelFilterIndex}
|
||||
onChange={(e) => {
|
||||
const i = e.target.value;
|
||||
setModelFilter(i === "" ? null : (modelOptions[Number(i)] ?? null));
|
||||
}}
|
||||
>
|
||||
<option value="">{S.usage.filterAllModels}</option>
|
||||
{modelOptions.map((m, i) => (
|
||||
<option key={`${m.provider}:${m.modelId}`} value={String(i)}>
|
||||
{catalogEntryFor(m.provider, m.modelId)?.displayName ?? m.modelId}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
{/* Date range: a dash between the two inputs stands in for a "from/to" label */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
size="sm"
|
||||
type="date"
|
||||
aria-label={S.usage.from}
|
||||
value={from}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
/>
|
||||
<span className="shrink-0 text-gray-400" aria-hidden>
|
||||
–
|
||||
</span>
|
||||
<Input
|
||||
size="sm"
|
||||
type="date"
|
||||
aria-label={S.usage.to}
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary cards (today / last 7 days / cumulative) */}
|
||||
{data ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<SummaryCard title={S.usage.today} bucket={data.summary.today} currency={currency} />
|
||||
<SummaryCard title={S.usage.last7d} bucket={data.summary.last7d} currency={currency} />
|
||||
<SummaryCard title={S.usage.total} bucket={data.summary.total} currency={currency} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<Skeleton className="h-24" />
|
||||
<Skeleton className="h-24" />
|
||||
<Skeleton className="h-24" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Four business charts two-by-two, each taking half width: a row of
|
||||
compositional charts (pie chart / success rate), a row of time
|
||||
series (daily Token / daily cost). Token bar width fixed at 25px, scrolls horizontally within the card when 30 days doesn't fit the half-width */}
|
||||
{data ? (
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<ChartCard title={S.usage.chartAgentCalls}>
|
||||
<AgentPieChart data={data.byAgent} />
|
||||
</ChartCard>
|
||||
<ChartCard title={S.usage.chartSuccessRate}>
|
||||
<SuccessBarChart data={data.success} />
|
||||
</ChartCard>
|
||||
{/* The legend lives in the card header, the bars live inside the card: the hover-linked bucket state is lifted to this level to be shared */}
|
||||
<ChartCard
|
||||
title={S.usage.chartTokenTrend}
|
||||
extra={<TokenLegend active={tokenBucket} onHover={setTokenBucket} />}
|
||||
>
|
||||
<TokenBarChart trend={data.trend} legend={tokenBucket} />
|
||||
</ChartCard>
|
||||
<ChartCard title={S.usage.chartCostTrend}>
|
||||
<TrendChart points={data.trend} currency={currency} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="h-64" />
|
||||
)}
|
||||
|
||||
{/* Errors (a single full-width panel: stats + a recent-errors table) */}
|
||||
{data && (
|
||||
<ChartCard title={S.usage.errors}>
|
||||
<ErrorsPanel errors={data.errors} />
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{hasUncostedRows && <p className="text-xs text-gray-400">{S.usage.uncostedNote}</p>}
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user