98 lines
2.5 KiB
TypeScript
98 lines
2.5 KiB
TypeScript
import { type MouseEvent, useCallback } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import type { SessionListItem } from "./protocol";
|
|
import { Route } from "./protocol";
|
|
import { fetchJson } from "./api";
|
|
import { classNames, relativeTime } from "./store";
|
|
|
|
interface Props {
|
|
sessions: SessionListItem[];
|
|
onChanged: () => void;
|
|
pushToast: (text: string) => void;
|
|
}
|
|
|
|
export default function SessionsView({
|
|
sessions,
|
|
onChanged,
|
|
pushToast,
|
|
}: Props) {
|
|
const navigate = useNavigate();
|
|
|
|
const open = useCallback(
|
|
(id: string): void => {
|
|
navigate(`/s/${id}`);
|
|
},
|
|
[navigate],
|
|
);
|
|
|
|
const stop = async (e: MouseEvent, s: SessionListItem): Promise<void> => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
try {
|
|
await fetchJson(Route.SessionContainer(s.id), { method: "DELETE" });
|
|
pushToast(`stopped ${s.name ?? s.id}`);
|
|
onChanged();
|
|
} catch (err) {
|
|
pushToast(
|
|
`stop failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
};
|
|
|
|
// Only active (online) pi sessions are shown; offline transcripts stay
|
|
// reachable by direct URL and are dropped from the default list.
|
|
const sorted = [...sessions]
|
|
.filter((s) => s.online)
|
|
.sort(
|
|
(a, b) => (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt),
|
|
);
|
|
|
|
return (
|
|
<div className="page">
|
|
<h1>Active sessions</h1>
|
|
{sorted.length === 0 && (
|
|
<p className="empty">No active sessions. Spawn one from the sidebar.</p>
|
|
)}
|
|
{sorted.map((s) => (
|
|
<div
|
|
key={s.id}
|
|
className="session-card"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`Open session ${s.name ?? s.id}`}
|
|
onClick={() => open(s.id)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") open(s.id);
|
|
}}
|
|
>
|
|
<span
|
|
className={classNames("online-dot", s.online && "on")}
|
|
title={s.online ? "online" : "offline"}
|
|
/>
|
|
<span className="card-body">
|
|
<span className="card-title" style={{ display: "block" }}>
|
|
{s.name ?? s.repo ?? s.cwd}
|
|
</span>
|
|
<span className="card-meta" style={{ display: "flex" }}>
|
|
{s.repo !== null && <span>{s.repo}</span>}
|
|
<span>{s.model}</span>
|
|
<span>{relativeTime(s.lastEventAt)}</span>
|
|
</span>
|
|
</span>
|
|
{s.agent && <span className="badge">agent</span>}
|
|
{s.agent && (
|
|
<button
|
|
type="button"
|
|
className="icon-btn danger"
|
|
aria-label={`Stop container for ${s.name ?? s.id}`}
|
|
onClick={(e) => void stop(e, s)}
|
|
>
|
|
◼
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|