feat(dashboard): lazy routes, Needs-you strip, URL state, exact numbers
- Workspace, SettingsHub, SessionDetail and the Workflow sub-tab are lazy (main bundle 2,166 kB -> ~920 kB), wrapped in RouteErrorBoundary: a stale chunk after a self-update reloads once (vite:preloadError too), any other error shows a Reload panel instead of a blank page. - Live refresh: lib/throttle.ts + hooks/useLiveRefresh.ts replace trailing debounces that froze under a steady event stream; refresh skips hidden tabs and refetches after a WebSocket reconnect. Monitor loads only while shown, with one batched GET /api/agents?session_ids= instead of N+1; a failing endpoint is an inline error, not a blank Dashboard. - NeedsYouStrip on Monitor and above Analytics. Tabs, filters, page, sort and search live in the URL (Dashboard, Sessions, Activity, SessionDetail). - Sessions: tool / workspace / date-range filters, CSV/JSON export (formula-neutralised), focusable row links, Open in workspace (the dead /run links are gone), stale responses dropped, error + Retry. - Fixes: "NaNs" durations, 30/7-day charts skipping idle days, weekday labels west of UTC (now averages), cache hit rate denominator, billable tokens vs cache reads, exact tool-call counts, "To" date = end of day, invalid ?from= no longer throws, recent list no longer shrinks to 15, stable message keys, transcript switch races. - Light-theme-unreadable bg-black code panels and dark-only DAG fills use tokens; status configs and chart colours use theme tokens; Health tab and other literals translated. - Server (additive): GET /api/agents?session_ids=, GET /api/sessions awaiting=1&from=&to=, stats tool_call_count/distinct_tool_count, event rows carry session/agent labels.
This commit is contained in:
+334
-151
@@ -52,8 +52,8 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useState, useCallback, useSyncExternalStore } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useEffect, useMemo, useRef, useState, useCallback, useSyncExternalStore } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FolderOpen,
|
||||
@@ -62,12 +62,18 @@ import {
|
||||
RefreshCw,
|
||||
SortDesc,
|
||||
SortAsc,
|
||||
ChevronDown,
|
||||
Play,
|
||||
Server,
|
||||
Download,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { RemoteSource } from "../lib/api";
|
||||
import type { RemoteSource, RunHandle } from "../lib/api";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { toast } from "../lib/toast";
|
||||
import { downloadText, exportName, toCsv } from "../lib/exportData";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { useDataScope } from "../lib/dataScope";
|
||||
@@ -76,35 +82,60 @@ import { SessionToolBadge } from "../components/SessionToolBadge";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { TableRowSkeleton } from "../components/Skeleton";
|
||||
import { useContextMenu } from "../components/ContextMenu";
|
||||
import { SessionRowMenu } from "../components/SessionRowMenu";
|
||||
import { formatDateTime, formatDuration, truncate, fmtCost } from "../lib/format";
|
||||
import {
|
||||
effectiveSessionStatus,
|
||||
isSessionAwaitingInput,
|
||||
sessionAwaitingReason,
|
||||
} from "../lib/types";
|
||||
import { SessionRowMenu, workspaceHref } from "../components/SessionRowMenu";
|
||||
import { formatDateTime, formatDuration, truncate, fmtCost, isoOrUndefined } from "../lib/format";
|
||||
import { effectiveSessionStatus, sessionAwaitingReason } from "../lib/types";
|
||||
import type { Session, DashboardEvent } from "../lib/types";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export function Sessions() {
|
||||
export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
const navigate = useNavigate();
|
||||
const rowCtx = useContextMenu<Session>();
|
||||
const { t } = useTranslation("sessions");
|
||||
const { tools } = useAiTools();
|
||||
const { lanes } = useLanes();
|
||||
// Filters, sort and page live in the URL (replace, no history spam), so a
|
||||
// reload, the back button and a shared link all land on the same view.
|
||||
const [params, setParams] = useSearchParams();
|
||||
const filter = params.get("status") ?? "";
|
||||
const search = params.get("q") ?? "";
|
||||
const cwd = params.get("cwd") ?? "";
|
||||
const tool = params.get("tool") ?? "";
|
||||
const lane = params.get("lane") ?? "";
|
||||
const from = params.get("from") ?? "";
|
||||
const to = params.get("to") ?? "";
|
||||
const sortBy = params.get("sort") ?? "time";
|
||||
const sortDesc = params.get("asc") !== "1";
|
||||
const page = Math.max(0, parseInt(params.get("page") ?? "0", 10) || 0);
|
||||
/** Writes params; any change but the page itself goes back to page 0 in the
|
||||
* SAME update, so one filter change is one load. */
|
||||
const update = useCallback(
|
||||
(patch: Record<string, string | null>) =>
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (v) next.set(k, v);
|
||||
else next.delete(k);
|
||||
}
|
||||
if (!("page" in patch)) next.delete("page");
|
||||
return next;
|
||||
},
|
||||
{ replace: true }
|
||||
),
|
||||
[setParams]
|
||||
);
|
||||
const setPage = (fn: (p: number) => number) => update({ page: String(fn(page) || "") });
|
||||
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [filter, setFilter] = useState("");
|
||||
// `searchInput` is what the user types; `search` is the debounced value
|
||||
// actually sent to the server. Without debouncing, every keystroke would
|
||||
// hit /api/sessions.
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
// `searchInput` is what the user types; `q` in the URL is the debounced value
|
||||
// actually sent to the server.
|
||||
const [searchInput, setSearchInput] = useState(search);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const [cwd, setCwd] = useState("");
|
||||
const [sortBy, setSortBy] = useState("time");
|
||||
const [sortDesc, setSortDesc] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [directories, setDirectories] = useState<string[]>([]);
|
||||
// Global data scope (which source machines to show). Included in `load`'s deps
|
||||
// so switching scope re-fetches; the actual `sources` param is injected by the
|
||||
@@ -112,9 +143,8 @@ export function Sessions() {
|
||||
const [scope] = useDataScope();
|
||||
// source id → label, so remote-origin rows show a friendly badge.
|
||||
const [sourceLabels, setSourceLabels] = useState<Map<string, string>>(() => new Map());
|
||||
// Set of session IDs that are currently being driven by an in-flight Run
|
||||
// handle on /run. Lets us badge those rows with a "Run" link.
|
||||
const [dashboardRunIds, setDashboardRunIds] = useState<Set<string>>(new Set());
|
||||
// Live dashboard runs, so a row a pane holds links straight to that pane.
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
|
||||
const FILTER_OPTIONS: Array<{ label: string; value: string }> = [
|
||||
{ label: t("filterAll"), value: "" },
|
||||
@@ -128,9 +158,17 @@ export function Sessions() {
|
||||
// Debounce the search input → 300 ms after the user stops typing, the
|
||||
// committed value flips and triggers a fresh fetch.
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setSearch(searchInput.trim()), 300);
|
||||
const id = window.setTimeout(() => {
|
||||
if (searchInput.trim() !== search) update({ q: searchInput.trim() || null });
|
||||
}, 300);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [searchInput]);
|
||||
}, [searchInput, search, update]);
|
||||
|
||||
// `?q=` changed from outside (a link to plain /sessions, back/forward): the
|
||||
// box follows, so the debounce above never writes the old text back.
|
||||
useEffect(() => {
|
||||
setSearchInput((cur) => (cur.trim() === search ? cur : search));
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
api.sessions
|
||||
@@ -156,115 +194,131 @@ export function Sessions() {
|
||||
loadSourceLabels();
|
||||
}, [loadSourceLabels]);
|
||||
|
||||
// Server-side pagination: only the visible page is fetched. Cost
|
||||
// computation on the server scales with PAGE_SIZE, not with the total
|
||||
// session count, so this stays cheap regardless of how many sessions
|
||||
// exist in the database.
|
||||
/** The list query for the current filters (paging added by the caller). */
|
||||
const query = useMemo(
|
||||
() => ({
|
||||
// "Waiting" is an overlay on active sessions (awaiting_input_since);
|
||||
// the server filters it before the limit so paging stays exact.
|
||||
status: filter === "waiting" ? "active" : filter || undefined,
|
||||
awaiting: filter === "waiting" || undefined,
|
||||
q: search || undefined,
|
||||
cwd: cwd || undefined,
|
||||
tool: tool || undefined,
|
||||
lane: lane ? Number(lane) : undefined,
|
||||
// A date input is a local calendar day: `to` covers that whole day.
|
||||
from: isoOrUndefined(from && `${from}T00:00:00`),
|
||||
to: isoOrUndefined(to && `${to}T23:59:59.999`),
|
||||
sort_by: sortBy,
|
||||
sort_desc: sortDesc,
|
||||
}),
|
||||
[filter, search, cwd, tool, lane, from, to, sortBy, sortDesc]
|
||||
);
|
||||
|
||||
// Server-side pagination: only the visible page is fetched. A newer request
|
||||
// (filter typed while a load is in flight) wins; older responses are dropped.
|
||||
const loadSeq = useRef(0);
|
||||
const load = useCallback(async () => {
|
||||
const seq = ++loadSeq.current;
|
||||
try {
|
||||
// The "waiting" filter is a UI-only overlay derived from the
|
||||
// awaiting_input_since column - the underlying SessionStatus is
|
||||
// still "active". Map it to a client-side filter on top of the
|
||||
// active set so paging/totals stay consistent with the visible rows.
|
||||
if (filter === "waiting") {
|
||||
const res = await api.sessions.list({
|
||||
status: "active",
|
||||
q: search || undefined,
|
||||
cwd: cwd || undefined,
|
||||
sort_by: sortBy,
|
||||
sort_desc: sortDesc,
|
||||
limit: 10000,
|
||||
offset: 0,
|
||||
});
|
||||
const waiting = res.sessions.filter(isSessionAwaitingInput);
|
||||
setTotal(waiting.length);
|
||||
setSessions(waiting.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE));
|
||||
return;
|
||||
}
|
||||
const params: {
|
||||
status?: string;
|
||||
q?: string;
|
||||
cwd?: string;
|
||||
sort_by?: string;
|
||||
sort_desc?: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
} = {
|
||||
const res = await api.sessions.list({
|
||||
...query,
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * PAGE_SIZE,
|
||||
sort_by: sortBy,
|
||||
sort_desc: sortDesc,
|
||||
};
|
||||
if (filter) params.status = filter;
|
||||
if (search) params.q = search;
|
||||
if (cwd) params.cwd = cwd;
|
||||
const res = await api.sessions.list(params);
|
||||
});
|
||||
if (seq !== loadSeq.current) return;
|
||||
setSessions(res.sessions);
|
||||
setTotal(res.total);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (seq === loadSeq.current) setError(err instanceof Error ? err.message : t("failedList"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (seq === loadSeq.current) setLoading(false);
|
||||
}
|
||||
// `scope` is a dep so a data-scope change re-fetches; the api layer injects
|
||||
// the matching `sources` param.
|
||||
}, [filter, search, cwd, sortBy, sortDesc, page, scope]);
|
||||
}, [query, page, scope, t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// Reset to page 0 whenever filters or sort changes.
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [filter, search, cwd, sortBy, sortDesc]);
|
||||
|
||||
useEffect(() => {
|
||||
return eventBus.subscribe((msg) => {
|
||||
if (msg.type === "session_created" || msg.type === "session_updated") {
|
||||
load();
|
||||
}
|
||||
if (msg.type === "new_event") {
|
||||
const ev = msg.data as DashboardEvent;
|
||||
if (ev.event_type === "Stop" || ev.event_type === "SessionEnd") {
|
||||
load();
|
||||
}
|
||||
}
|
||||
if (msg.type === "run_status") {
|
||||
loadDashboardRuns();
|
||||
}
|
||||
// A remote source finished syncing: new remote sessions may have landed.
|
||||
if (msg.type === "remote_source.status") {
|
||||
loadSourceLabels();
|
||||
if (isRemoteDataRefreshMessage(msg)) load();
|
||||
} else if (isRemoteDataRefreshMessage(msg)) {
|
||||
load();
|
||||
}
|
||||
});
|
||||
// loadDashboardRuns is a stable useCallback declared below; referenced at
|
||||
// event time only (not in deps) to avoid a temporal-dead-zone at render.
|
||||
}, [load, loadSourceLabels]);
|
||||
|
||||
// Pull active Run handles so we can mark which sessions are being driven
|
||||
// from /run right now. Refresh on mount, on run_status WS messages, and
|
||||
// every 15s as a safety net for stale browser state.
|
||||
// Pull live Run handles so a row a dashboard pane holds can open that pane.
|
||||
const loadDashboardRuns = useCallback(() => {
|
||||
api.run
|
||||
.list()
|
||||
.then((r) => {
|
||||
const ids = new Set<string>();
|
||||
for (const h of r.items) {
|
||||
if (h.sessionId) ids.add(h.sessionId);
|
||||
}
|
||||
setDashboardRunIds(ids);
|
||||
})
|
||||
.then((r) => setRuns(r.items))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardRuns();
|
||||
const t = setInterval(loadDashboardRuns, 15000);
|
||||
return () => clearInterval(t);
|
||||
}, [loadDashboardRuns]);
|
||||
useLiveRefresh(loadDashboardRuns, {
|
||||
intervalMs: 15000,
|
||||
// A pane starting/stopping re-broadcasts its lane (`lane_update`); the
|
||||
// server sends no run_status any more. The 15 s poll covers extra slots.
|
||||
match: (msg) => msg.type === "lane_update",
|
||||
});
|
||||
|
||||
useLiveRefresh(load, {
|
||||
match: (msg) => {
|
||||
if (msg.type === "session_created" || msg.type === "session_updated") return true;
|
||||
if (msg.type === "new_event") {
|
||||
const ev = msg.data as DashboardEvent;
|
||||
return ev.event_type === "Stop" || ev.event_type === "SessionEnd";
|
||||
}
|
||||
// A remote source finished syncing: new remote sessions may have landed.
|
||||
return isRemoteDataRefreshMessage(msg);
|
||||
},
|
||||
});
|
||||
useEffect(
|
||||
() =>
|
||||
eventBus.subscribe((msg) => {
|
||||
if (msg.type === "remote_source.status") loadSourceLabels();
|
||||
}),
|
||||
[loadSourceLabels]
|
||||
);
|
||||
|
||||
async function handleExport(kind: "csv" | "json") {
|
||||
setExporting(true);
|
||||
try {
|
||||
const res = await api.sessions.list({ ...query, limit: 10000 });
|
||||
const name = exportName("sessions", kind);
|
||||
if (kind === "json")
|
||||
downloadText(name, JSON.stringify(res.sessions, null, 2), "application/json");
|
||||
else
|
||||
downloadText(
|
||||
name,
|
||||
toCsv(res.sessions, [
|
||||
"id",
|
||||
"name",
|
||||
"status",
|
||||
"tool",
|
||||
"cwd",
|
||||
"model",
|
||||
"started_at",
|
||||
"ended_at",
|
||||
"agent_count",
|
||||
"cost",
|
||||
"lane_id",
|
||||
]),
|
||||
"text/csv"
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilters = !!(search || filter || cwd || tool || lane || from || to);
|
||||
|
||||
// A `?page=` past the end (a stale link, rows deleted meanwhile) goes back to
|
||||
// the first page rather than showing an empty table.
|
||||
const pageCount = Math.ceil(total / PAGE_SIZE);
|
||||
useEffect(() => {
|
||||
if (!loading && pageCount > 0 && page >= pageCount) update({ page: null });
|
||||
}, [loading, page, pageCount, update]);
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
// The server already paginates, so the rendered page IS the loaded list.
|
||||
const paged = sessions;
|
||||
@@ -284,12 +338,17 @@ export function Sessions() {
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<FolderOpen className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
{!embedded && (
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<FolderOpen className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
{/* Under History's tab bar the tab already names the page. */}
|
||||
<h1 className={embedded ? "sr-only" : "text-lg font-semibold text-fg-primary"}>
|
||||
{t("title")}
|
||||
</h1>
|
||||
{wsConnected ? (
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
|
||||
@@ -304,13 +363,31 @@ export function Sessions() {
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("sessionCount", { count: total })}
|
||||
{filter ? ` ${filter}` : ""}
|
||||
{filter
|
||||
? ` · ${FILTER_OPTIONS.find((o) => o.value === filter)?.label ?? filter}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={load} className="btn-ghost flex-shrink-0">
|
||||
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleExport("csv")}
|
||||
className="btn-ghost"
|
||||
disabled={exporting || total === 0}
|
||||
>
|
||||
<Download className="w-4 h-4" /> {t("exportCsv")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport("json")}
|
||||
className="btn-ghost"
|
||||
disabled={exporting || total === 0}
|
||||
>
|
||||
<Download className="w-4 h-4" /> {t("exportJson")}
|
||||
</button>
|
||||
<button onClick={load} className="btn-ghost">
|
||||
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -321,6 +398,7 @@ export function Sessions() {
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("searchPlaceholder")}
|
||||
aria-label={t("searchPlaceholder")}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="input w-full pl-10"
|
||||
@@ -331,17 +409,17 @@ export function Sessions() {
|
||||
<div className="relative shrink-0 w-[180px]">
|
||||
<select
|
||||
value={cwd}
|
||||
onChange={(e) => setCwd(e.target.value)}
|
||||
className="input w-full text-ellipsis bg-surface-1 pr-9 appearance-none cursor-pointer"
|
||||
onChange={(e) => update({ cwd: e.target.value || null })}
|
||||
aria-label={t("filterDirectory")}
|
||||
className="select w-full text-ellipsis"
|
||||
>
|
||||
<option value="">All Directories</option>
|
||||
<option value="">{t("allDirectories")}</option>
|
||||
{directories.map((d) => (
|
||||
<option key={d} value={d} title={d}>
|
||||
{truncate(d, 30)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Sort Controls */}
|
||||
@@ -349,22 +427,23 @@ export function Sessions() {
|
||||
<div className="relative flex-1">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="bg-transparent w-full text-sm text-fg-secondary outline-none pl-3 pr-8 appearance-none cursor-pointer whitespace-nowrap"
|
||||
onChange={(e) => update({ sort: e.target.value === "time" ? null : e.target.value })}
|
||||
aria-label={t("sortLabel")}
|
||||
className="select w-full border-0 bg-transparent py-0 whitespace-nowrap"
|
||||
>
|
||||
<option value="time">Sort by Time ({sortDesc ? "Newest" : "Oldest"})</option>
|
||||
<option value="time">{sortDesc ? t("sortTimeDesc") : t("sortTimeAsc")}</option>
|
||||
<option value="duration">
|
||||
Sort by Duration ({sortDesc ? "Longest" : "Shortest"})
|
||||
{sortDesc ? t("sortDurationDesc") : t("sortDurationAsc")}
|
||||
</option>
|
||||
<option value="price">Sort by Price ({sortDesc ? "Highest" : "Lowest"})</option>
|
||||
<option value="price">{sortDesc ? t("sortPriceDesc") : t("sortPriceAsc")}</option>
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-fg-muted pointer-events-none" />
|
||||
</div>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<button
|
||||
onClick={() => setSortDesc(!sortDesc)}
|
||||
onClick={() => update({ asc: sortDesc ? "1" : null })}
|
||||
className="p-1.5 rounded hover:bg-surface-3 text-fg-secondary hover:text-fg-primary transition-colors shrink-0"
|
||||
title={sortDesc ? "Descending" : "Ascending"}
|
||||
title={sortDesc ? t("descending") : t("ascending")}
|
||||
aria-label={sortDesc ? t("descending") : t("ascending")}
|
||||
>
|
||||
{sortDesc ? <SortDesc className="w-4 h-4" /> : <SortAsc className="w-4 h-4" />}
|
||||
</button>
|
||||
@@ -375,7 +454,8 @@ export function Sessions() {
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setFilter(opt.value)}
|
||||
onClick={() => update({ status: opt.value || null })}
|
||||
aria-pressed={filter === opt.value}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors whitespace-nowrap ${
|
||||
filter === opt.value
|
||||
? "bg-surface-4 text-fg-secondary"
|
||||
@@ -388,15 +468,106 @@ export function Sessions() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && filtered.length === 0 ? (
|
||||
{/* Tool / workspace / date range */}
|
||||
<div className="flex flex-wrap items-center gap-3 -mt-3 mb-6 text-xs">
|
||||
<select
|
||||
value={tool}
|
||||
onChange={(e) => update({ tool: e.target.value || null })}
|
||||
aria-label={t("filterTool")}
|
||||
className="select py-1 text-xs w-auto"
|
||||
>
|
||||
<option value="">{t("allTools")}</option>
|
||||
{tools
|
||||
.filter((x) => x.installed || x.id === tool)
|
||||
.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={lane}
|
||||
onChange={(e) => update({ lane: e.target.value || null })}
|
||||
aria-label={t("filterWorkspace")}
|
||||
className="select py-1 text-xs w-auto max-w-[16rem]"
|
||||
>
|
||||
<option value="">{t("allWorkspaces")}</option>
|
||||
{lanes.map((l) => (
|
||||
<option key={l.id} value={String(l.id)}>
|
||||
{l.title || l.cwd}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="flex items-center gap-1.5 text-fg-muted">
|
||||
{t("dateFrom")}
|
||||
<input
|
||||
type="date"
|
||||
value={from}
|
||||
max={to || undefined}
|
||||
onChange={(e) => update({ from: e.target.value || null })}
|
||||
className="input py-1 text-xs w-auto"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-fg-muted">
|
||||
{t("dateTo")}
|
||||
<input
|
||||
type="date"
|
||||
value={to}
|
||||
min={from || undefined}
|
||||
onChange={(e) => update({ to: e.target.value || null })}
|
||||
className="input py-1 text-xs w-auto"
|
||||
/>
|
||||
</label>
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchInput("");
|
||||
update({
|
||||
status: null,
|
||||
q: null,
|
||||
cwd: null,
|
||||
tool: null,
|
||||
lane: null,
|
||||
from: null,
|
||||
to: null,
|
||||
});
|
||||
}}
|
||||
className="text-fg-muted hover:text-fg-primary underline"
|
||||
>
|
||||
{t("clearFilters")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="card flex flex-wrap items-center gap-3 p-3 mb-4 border-status-danger/40 text-sm text-status-danger"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">
|
||||
{t("failedList")}: {error}
|
||||
</span>
|
||||
<button onClick={load} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
title={t("noSessions")}
|
||||
description={search || filter || cwd ? t("noSessionsDesc") : t("noSessionsHint")}
|
||||
description={hasFilters ? t("noSessionsDesc") : t("noSessionsHint")}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="card overflow-x-auto">
|
||||
<div
|
||||
className="card overflow-x-auto"
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={t("tableRegion")}
|
||||
>
|
||||
<table className="w-full min-w-[800px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
@@ -437,37 +608,49 @@ export function Sessions() {
|
||||
{paged.map((session) => (
|
||||
<tr
|
||||
key={session.id}
|
||||
onClick={() => navigate(`/sessions/${session.id}`)}
|
||||
// Mouse convenience; the session name below is the real,
|
||||
// focusable link (a click on a link or button inside wins).
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("a,button")) return;
|
||||
navigate(`/sessions/${session.id}`);
|
||||
}}
|
||||
onContextMenu={(e) => rowCtx.open(e, session)}
|
||||
className="hover:bg-surface-4 transition-colors cursor-pointer group"
|
||||
>
|
||||
<td className="px-5 py-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-fg-secondary">
|
||||
<Link
|
||||
to={`/sessions/${session.id}`}
|
||||
className="text-sm font-medium text-fg-secondary hover:text-fg-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent rounded"
|
||||
>
|
||||
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
|
||||
</p>
|
||||
</Link>
|
||||
<SessionToolBadge tool={session.tool} />
|
||||
{session.source && session.source !== "local" && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300 bg-sky-500/10 border border-sky-500/25 px-1.5 py-0.5 rounded-full"
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-syntax-builtin bg-surface-3 border border-border px-1.5 py-0.5 rounded-full"
|
||||
title={t("remoteSourceBadgeTitle", "Collected from a remote machine")}
|
||||
>
|
||||
<Server className="w-2.5 h-2.5" />
|
||||
{sourceLabels.get(session.source) || session.source}
|
||||
</span>
|
||||
)}
|
||||
{dashboardRunIds.has(session.id) && (
|
||||
<Link
|
||||
to={`/run?session=${encodeURIComponent(session.id)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-1.5 py-0.5 rounded-full transition-colors"
|
||||
title={t("dashboardRunBadge", "Driven by Run page · click to open")}
|
||||
>
|
||||
<Play className="w-2.5 h-2.5" />
|
||||
{t("common:dashboardRun", "Run")}
|
||||
</Link>
|
||||
)}
|
||||
{(() => {
|
||||
// A live dashboard pane holds it: open that pane.
|
||||
const run = runs.find((r) => r.sessionId === session.id);
|
||||
const href = run ? workspaceHref(session, runs) : null;
|
||||
return href ? (
|
||||
<Link
|
||||
to={href}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-1.5 py-0.5 rounded-full transition-colors"
|
||||
title={t("openInWorkspaceTitle")}
|
||||
>
|
||||
<Play className="w-2.5 h-2.5" />
|
||||
{t("openInWorkspace")}
|
||||
</Link>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted font-mono">
|
||||
{session.id.slice(0, 12)}
|
||||
|
||||
Reference in New Issue
Block a user