chore: init main branch

This commit is contained in:
954801926@qq.com committed 2026-07-29 23:36:41 +08:00
commit 549c8b5b13
231 files changed
+27286

No files matched your search

+764
View File
@@ -0,0 +1,764 @@
import { useMemo, useState } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useSessions } from "../../hooks/useSessions";
import { fmtInt, fmtTime } from "../../lib/dashboard-format";
import { useTranslation } from "../../i18n";
import type { SessionRow, WorkspaceRow } from "../../types/sessions";
function fmtBytes(n: number): string {
const v = Number(n) || 0;
if (v < 1024) return `${v} B`;
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB`;
if (v < 1024 * 1024 * 1024) return `${(v / (1024 * 1024)).toFixed(1)} MB`;
return `${(v / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function parseTime(value: string | number | null | undefined): number {
if (value == null || value === "") return 0;
if (typeof value === "number") return value > 1e12 ? value : value * 1000;
const ms = Date.parse(value);
return Number.isFinite(ms) ? ms : 0;
}
function rowKey(row: SessionRow): string {
return `${row.workspaceId}/${row.id}`;
}
type ConfirmState =
| { mode: "one"; row: SessionRow }
| { mode: "bulk"; rows: SessionRow[] }
| { mode: "workspace"; workspace: WorkspaceRow }
| null;
type PreviewState = {
loading: boolean;
error: string | null;
messages: { role: string; time: number | null; text: string }[];
truncated: boolean;
row: SessionRow;
} | null;
export function SessionsPage() {
const { t, lang } = useTranslation();
const locale = lang === "zh" ? "zh" : "en";
const {
status,
setStatus,
workspace,
setWorkspace,
data,
loading,
error,
setError,
refresh,
archiveSession,
unarchiveSession,
deleteSession,
deleteWorkspace,
getPreview,
} = useSessions();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busyId, setBusyId] = useState<string | null>(null);
const [confirm, setConfirm] = useState<ConfirmState>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [preview, setPreview] = useState<PreviewState>(null);
const workspaces = data?.workspaces ?? [];
const sessions = data?.sessions ?? [];
const workspaceMap = useMemo(() => {
const m = new Map<string, WorkspaceRow>();
for (const w of workspaces) m.set(w.id, w);
return m;
}, [workspaces]);
const allKeys = useMemo(() => sessions.map(rowKey), [sessions]);
const allSelected = allKeys.length > 0 && allKeys.every((k) => selected.has(k));
const someSelected = allKeys.some((k) => selected.has(k)) && !allSelected;
const toggleSelect = (row: SessionRow) => {
const key = rowKey(row);
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleSelectAll = () => {
if (allSelected) setSelected(new Set());
else setSelected(new Set(allKeys));
};
const onArchive = async (row: SessionRow) => {
const key = rowKey(row);
setBusyId(key);
try {
await archiveSession(row.workspaceId, row.id);
setSelected(new Set());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
};
const onUnarchive = async (row: SessionRow) => {
const key = rowKey(row);
setBusyId(key);
try {
await unarchiveSession(row.workspaceId, row.id);
setSelected(new Set());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
};
const openPreview = async (row: SessionRow) => {
setPreview({
loading: true,
error: null,
messages: [],
truncated: false,
row,
});
try {
const res = await getPreview(row.workspaceId, row.id, row.status);
setPreview({
loading: false,
error: null,
messages: res.messages,
truncated: res.truncated,
row,
});
} catch (e) {
setPreview({
loading: false,
error: e instanceof Error ? e.message : String(e),
messages: [],
truncated: false,
row,
});
}
};
const bulkArchive = async () => {
const rows = sessions.filter((s) => selected.has(rowKey(s)) && s.status === "active");
for (const row of rows) {
try {
await archiveSession(row.workspaceId, row.id);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
break;
}
}
setSelected(new Set());
};
const bulkUnarchive = async () => {
const rows = sessions.filter((s) => selected.has(rowKey(s)) && s.status === "archived");
for (const row of rows) {
try {
await unarchiveSession(row.workspaceId, row.id);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
break;
}
}
setSelected(new Set());
};
const runConfirm = async () => {
if (!confirm) return;
setConfirmBusy(true);
try {
if (confirm.mode === "one") {
await deleteSession(confirm.row.workspaceId, confirm.row.id, confirm.row.status);
} else if (confirm.mode === "bulk") {
for (const row of confirm.rows) {
await deleteSession(row.workspaceId, row.id, row.status);
}
} else if (confirm.mode === "workspace") {
await deleteWorkspace(confirm.workspace.id);
if (workspace === confirm.workspace.id) setWorkspace("all");
}
setConfirm(null);
setSelected(new Set());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setConfirmBusy(false);
}
};
const totalSessions = workspaces.reduce(
(a, w) => a + (w.activeCount || 0) + (w.archivedCount || 0),
0
);
return (
<div className="flex h-full flex-col overflow-hidden p-4 space-y-3">
<div className="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-content-primary">{t("sessionsTitle")}</h2>
<p className="mt-1 max-w-[60ch] text-sm text-content-muted">{t("sessionsSubtitle")}</p>
</div>
<button
type="button"
onClick={() => refresh()}
disabled={loading}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50"
>
{loading ? t("refreshing") : t("refresh")}
</button>
</div>
{error && (
<div className="shrink-0 rounded-lg border border-red-500/40 bg-red-900/20 px-4 py-3 text-sm text-red-300">
{error}
</div>
)}
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[260px_1fr]">
{/* Workspace rail */}
<div className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-border bg-panel">
<div className="shrink-0 border-b border-border px-4 py-2.5">
<div className="text-sm font-medium text-content-primary">{t("workspaces")}</div>
<div className="text-xs text-content-muted mt-0.5">{t("workspaceIsolatedHint")}</div>
</div>
<div className="flex min-h-0 flex-1 flex-col p-2 overflow-hidden">
<button
type="button"
onClick={() => setWorkspace("all")}
className={`flex w-full shrink-0 items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors ${
workspace === "all"
? "bg-blue-600/20 text-content-primary"
: "text-content-muted hover:bg-input hover:text-content-primary"
}`}
>
<span>{t("allWorkspaces")}</span>
<span className="text-xs tabular-nums">{fmtInt(totalSessions, locale)}</span>
</button>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto mt-1">
{workspaces.map((w) => {
const total = (w.activeCount || 0) + (w.archivedCount || 0);
const isEmpty = total === 0 || w.empty;
return (
<div
key={w.id}
className={`group flex items-start gap-1 rounded-md ${
workspace === w.id ? "bg-blue-600/20" : "hover:bg-input"
}`}
>
<button
type="button"
onClick={() => setWorkspace(w.id)}
className={`flex min-w-0 flex-1 flex-col gap-0.5 px-3 py-2 text-left ${
workspace === w.id ? "text-content-primary" : "text-content-muted group-hover:text-content-primary"
}`}
title={w.root || w.id}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">{w.name || w.id}</span>
<span className="shrink-0 text-[11px] tabular-nums">
{isEmpty ? (
<span className="rounded border border-border px-1.5 py-0 text-[10px] text-content-muted">
{t("emptyWorkspace")}
</span>
) : (
<>
{w.activeCount || 0}
{w.archivedCount ? `/${w.archivedCount}` : ""}
</>
)}
</span>
</div>
{w.root && (
<span className="truncate font-mono text-[10px] opacity-70">{w.root}</span>
)}
</button>
{isEmpty && (
<button
type="button"
className="mt-1 mr-1 h-7 w-7 shrink-0 rounded text-red-400/80 opacity-70 hover:bg-red-900/30 hover:text-red-300 group-hover:opacity-100"
title={t("deleteWorkspace")}
onClick={(e) => {
e.stopPropagation();
setConfirm({ mode: "workspace", workspace: w });
}}
>
×
</button>
)}
</div>
);
})}
{!workspaces.length && !loading && (
<div className="px-3 py-6 text-center text-xs text-content-muted">{t("noSessions")}</div>
)}
</div>
</div>
</div>
{/* Sessions table */}
<div className="flex min-h-0 flex-col space-y-3 overflow-hidden">
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center bg-input border border-border rounded p-0.5">
{(
[
["active", t("statusActive")],
["archived", t("statusArchived")],
["all", t("statusAll")],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => {
setStatus(value);
setSelected(new Set());
}}
className={`px-3 py-1 text-sm rounded transition-colors ${
status === value
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{label}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
{sessions.length > 0 && (
<button
type="button"
onClick={toggleSelectAll}
className="px-2 py-1 text-xs text-content-muted hover:text-content-primary"
>
{allSelected ? t("deselectAll") : t("selectAll")}
</button>
)}
{selected.size > 0 && (
<>
<span className="text-xs text-content-muted">
{t("selectedCount", { n: String(selected.size) })}
</span>
{status !== "archived" && (
<button
type="button"
onClick={bulkArchive}
className="px-2 py-1 text-xs rounded border border-border hover:bg-hover-2"
>
{t("archive")}
</button>
)}
{status !== "active" && (
<button
type="button"
onClick={bulkUnarchive}
className="px-2 py-1 text-xs rounded border border-border hover:bg-hover-2"
>
{t("unarchive")}
</button>
)}
<button
type="button"
onClick={() => {
const rows = sessions.filter((s) => selected.has(rowKey(s)));
if (rows.length) setConfirm({ mode: "bulk", rows });
}}
className="px-2 py-1 text-xs rounded border border-red-500/30 text-red-300 hover:bg-red-900/20"
>
{t("delete")}
</button>
</>
)}
<select
value={workspace}
onChange={(e) => setWorkspace(e.target.value)}
className="bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none max-w-[200px]"
>
<option value="all">{t("allWorkspaces")}</option>
{workspaces.map((w) => (
<option key={w.id} value={w.id}>
{w.name || w.id}
</option>
))}
</select>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-panel">
<div className="shrink-0 border-b border-border px-4 py-2.5">
<div className="text-sm font-medium text-content-primary">
{workspace === "all"
? t("allWorkspaces")
: workspaceMap.get(workspace)?.name || workspace}
</div>
<div className="text-xs text-content-muted mt-0.5">
{t("sessionsInView", { n: String(sessions.length) })}
{workspace !== "all" ? ` · ${t("isolatedToWorkspace")}` : ""}
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-panel text-left text-content-muted border-b border-border">
<tr>
<th className="w-10 px-3 py-2 font-normal">
<input
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
type="checkbox"
className="h-4 w-4 accent-blue-600"
checked={allSelected}
disabled={!sessions.length}
onChange={toggleSelectAll}
aria-label={t("selectAll")}
/>
</th>
<th className="px-2 py-2 font-normal">{t("sessionTitle")}</th>
<th className="px-2 py-2 font-normal">{t("workspace")}</th>
<th className="px-2 py-2 font-normal">{t("status")}</th>
<th className="px-2 py-2 font-normal text-right">{t("size")}</th>
<th className="px-2 py-2 font-normal">{t("updated")}</th>
<th className="px-2 py-2 font-normal text-right">{t("actions")}</th>
</tr>
</thead>
<tbody>
{sessions.length === 0 ? (
<tr>
<td colSpan={7} className="py-10 text-center text-content-muted">
{loading ? t("scanning") : t("noSessions")}
</td>
</tr>
) : (
sessions.map((row) => {
const key = rowKey(row);
const ws = workspaceMap.get(row.workspaceId);
const busy = busyId === key;
return (
<tr
key={key}
className="border-b border-border/50 hover:bg-hover"
>
<td className="px-3 py-2">
<input
type="checkbox"
className="h-4 w-4 accent-blue-600"
checked={selected.has(key)}
onChange={() => toggleSelect(row)}
/>
</td>
<td className="px-2 py-2 max-w-[280px]">
<div className="flex min-w-0 flex-col gap-0.5">
<button
type="button"
className="truncate text-left font-medium text-content-primary hover:text-blue-400 hover:underline"
title={row.title || t("openPreview")}
onClick={() => openPreview(row)}
>
{row.title || row.id}
</button>
<span className="truncate font-mono text-[10px] text-content-muted" title={row.id}>
{row.id}
</span>
{row.workDir && (
<span
className="truncate font-mono text-[10px] text-content-muted/80"
title={row.workDir}
>
{row.workDir}
</span>
)}
</div>
</td>
<td className="px-2 py-2 max-w-[160px]">
<span className="truncate text-sm text-content-muted">
{ws?.name || row.workspaceId}
</span>
</td>
<td className="px-2 py-2 whitespace-nowrap">
{row.status === "archived" ? (
<span className="rounded-md bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 px-2 py-0.5 text-xs">
{t("statusArchived")}
</span>
) : (
<span className="rounded-md bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 px-2 py-0.5 text-xs">
{t("statusActive")}
</span>
)}
</td>
<td className="px-2 py-2 text-right tabular-nums text-content-muted">
{fmtBytes(row.bytes)}
</td>
<td className="px-2 py-2 whitespace-nowrap tabular-nums text-content-muted">
{fmtTime(parseTime(row.updatedAt), locale)}
</td>
<td className="px-2 py-2 text-right">
<div className="flex justify-end gap-1">
{row.status === "active" ? (
<button
type="button"
disabled={busy}
onClick={() => onArchive(row)}
title={t("archive")}
className="px-2 py-1 text-xs rounded border border-border hover:bg-hover-2 disabled:opacity-50"
>
{t("archive")}
</button>
) : (
<button
type="button"
disabled={busy}
onClick={() => onUnarchive(row)}
title={t("unarchive")}
className="px-2 py-1 text-xs rounded border border-border hover:bg-hover-2 disabled:opacity-50"
>
{t("unarchive")}
</button>
)}
<button
type="button"
disabled={busy}
onClick={() => setConfirm({ mode: "one", row })}
title={t("delete")}
className="px-2 py-1 text-xs rounded border border-red-500/30 text-red-300 hover:bg-red-900/20 disabled:opacity-50"
>
{t("delete")}
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
{/* Attribution */}
<div className="text-xs text-content-muted pb-2">
{data?.home && <>{data.home}</>}
<br />
{t("sessionsAttribution")}{" "}
<button
type="button"
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
kimicode-dashboard
</button>{" "}
{t("attributionSuffix")}
</div>
{/* Confirm dialog */}
{confirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-panel shadow-xl">
<div className="space-y-3 p-5">
<h3 className="text-base font-semibold text-content-primary">
{confirm.mode === "bulk"
? t("confirmBulkTitle")
: confirm.mode === "workspace"
? t("confirmDeleteWorkspaceTitle")
: t("confirmDeleteTitle")}
</h3>
<p className="text-sm text-content-muted">
{confirm.mode === "bulk"
? t("confirmBulkDelete", { n: String(confirm.rows.length) })
: confirm.mode === "workspace"
? t("confirmDeleteWorkspace")
: t("confirmDeleteSession")}
</p>
{confirm.mode === "one" && (
<div className="rounded-md border border-border bg-input px-3 py-2.5">
<div className="truncate text-sm font-medium">
{confirm.row.title || confirm.row.id}
</div>
<div className="mt-1 break-all font-mono text-[11px] text-content-muted">
{confirm.row.id}
</div>
</div>
)}
{confirm.mode === "bulk" && (
<div className="rounded-md border border-border bg-input px-3 py-2.5 text-sm text-content-muted">
{t("selectedCount", { n: String(confirm.rows.length) })}
</div>
)}
{confirm.mode === "workspace" && (
<div className="rounded-md border border-border bg-input px-3 py-2.5">
<div className="truncate text-sm font-medium">
{confirm.workspace.name || confirm.workspace.id}
</div>
{confirm.workspace.root && (
<div className="mt-1 break-all font-mono text-[11px] text-content-muted">
{confirm.workspace.root}
</div>
)}
<div className="mt-1 font-mono text-[11px] text-content-muted">
{confirm.workspace.id}
</div>
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-border bg-footer px-5 py-3">
<button
type="button"
disabled={confirmBusy}
onClick={() => setConfirm(null)}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50"
>
{t("cancel")}
</button>
<button
type="button"
disabled={confirmBusy}
onClick={runConfirm}
className="px-3 py-1.5 text-sm rounded bg-red-600 text-white hover:bg-red-500 disabled:opacity-50"
>
{confirmBusy
? t("refreshing")
: confirm.mode === "workspace"
? t("deleteWorkspace")
: t("deleteForever")}
</button>
</div>
</div>
</div>
)}
{/* Preview dialog */}
{preview && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="flex w-full max-w-2xl max-h-[min(80vh,36rem)] flex-col overflow-hidden rounded-xl border border-border bg-panel shadow-xl">
<div className="shrink-0 space-y-2 border-b border-border p-5">
<h3 className="text-base font-semibold text-content-primary">{t("previewTitle")}</h3>
<p className="text-sm text-content-muted">{t("previewHint")}</p>
<div className="text-xs text-content-muted">
<div className="truncate font-medium text-content-primary">
{preview.row.title || preview.row.id}
</div>
<div className="mt-0.5 break-all font-mono opacity-80">{preview.row.id}</div>
</div>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-5">
{preview.loading && (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-content-muted">
<div className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full" />
{t("scanning")}
</div>
)}
{preview.error && (
<div className="rounded-md border border-red-500/40 bg-red-900/20 px-3 py-2 text-sm text-red-300">
{preview.error}
</div>
)}
{!preview.loading && !preview.error && !preview.messages.length && (
<div className="py-10 text-center text-sm text-content-muted">{t("previewEmpty")}</div>
)}
{preview.messages.map((m, i) => {
const roleLabel =
m.role === "user"
? t("roleUser")
: m.role === "assistant"
? t("roleAssistant")
: m.role === "system"
? t("roleSystem")
: m.role;
return (
<PreviewMessageCard
key={`${m.role}-${i}`}
role={m.role}
roleLabel={roleLabel}
time={m.time}
text={m.text}
locale={locale}
/>
);
})}
{preview.truncated && (
<div className="text-center text-xs text-content-muted">{t("previewTruncated")}</div>
)}
</div>
<div className="shrink-0 flex justify-end border-t border-border bg-footer px-5 py-3">
<button
type="button"
onClick={() => setPreview(null)}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2"
>
{t("cancel")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
// Per-message card with 500-char collapse + expand toggle.
// Renders long texts lazily — prevents huge <pre> blocks from bloating the DOM.
const PREVIEW_TEXT_LIMIT = 500;
function PreviewMessageCard({
role,
roleLabel,
time,
text,
locale,
}: {
role: string;
roleLabel: string;
time: number | null;
text: string;
locale: string;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const isLong = text.length > PREVIEW_TEXT_LIMIT;
const visible = expanded || !isLong ? text : text.slice(0, PREVIEW_TEXT_LIMIT);
return (
<div
className={`rounded-lg border border-border px-3 py-2.5 ${
role === "user"
? "bg-blue-600/10"
: role === "assistant"
? "bg-input"
: "bg-panel"
}`}
>
<div className="mb-1 flex items-center justify-between gap-2 text-[11px] text-content-muted">
<span className="font-medium text-content-primary">{roleLabel}</span>
{time ? (
<span className="tabular-nums">{fmtTime(time, locale)}</span>
) : null}
</div>
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-content-primary">
{visible}
{!expanded && isLong && (
<span className="text-content-muted"> …</span>
)}
</pre>
{isLong && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="mt-1 text-[11px] text-blue-400 hover:text-blue-300 hover:underline"
>
{expanded
? t("previewCollapseChars", { n: text.length.toLocaleString() })
: t("previewExpandChars", { n: text.length.toLocaleString() })}
</button>
)}
</div>
);
}