Files
AgentHub/client/src/components/lanes/SessionReplay.tsx
T
nntrivi2001 49f87c4da3 chore: v2.8.12 — clone queue and progress, session defaults, chat agents, drops as @ mentions
Whole tree from parallel sessions, plus the version bump:
- Clone: a queue with live progress (Add workspace and extra folders),
  clones run under nice/ionice so the machine stays responsive.
- Settings → AI tools → Session defaults: a new session's default
  permission mode / bypass per account.
- Chat lists the background subagents that have not reported back yet.
- A file pasted or dropped into a claude pane goes in as an @ mention.
- Changes panel: say why amend/undo is refused on a pushed commit.
- openapi.yaml regenerated (Artifacts, Host, resumeById).
2026-10-19 17:19:41 +07:00

214 lines
8.2 KiB
TypeScript

/**
* @file SessionReplay — an ENDED session opened in its workspace
* (`/workspace?lane=<id>&session=<sessionId>`), the way Claude Desktop opens
* an old one: its conversation, read-only (`ConversationView`), and a prompt
* box under it (a pasted image rides along as a thumbnail). Opening it resumes
* nothing; only SENDING a message does — `POST /api/lanes/:id/start` with
* `resumeSessionId`, that prompt and `slot: "auto"` (the server picks the free
* tab), after which `onResumed` opens the pane (`&run=`). The panes are re-read
* first, and a session some tab already holds opens THAT tab instead of a
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
* message is pasted there (never sent: Enter is the user's), with a toast; while
* it is live the composer is off. Only Claude, Codex and a catalog tool with
* `resumeById` (omp) can resume a session by id — any other tool's session
* offers a new session from the workspace home
* instead. The header carries the transcript page's Resume/Fork
* (`SessionResumeButton`, behind its warning). The session's tool and folder
* come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the
* session ran on (the server enforces that too).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { ArrowUp, SquareTerminal } from "lucide-react";
import { api, type RunHandle } from "../../lib/api";
import type { Lane, Session } from "../../lib/types";
import { ConversationView } from "../conversation/ConversationView";
import { PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { startInFreeTabDetailed } from "./startSession";
import { pasteToRun } from "../../lib/panePaste";
import { toast } from "../../lib/toast";
import i18n from "../../i18n";
import { liveRunFor } from "./WorkspaceSessionList";
import { SessionResumeButton } from "./SessionResumeButton";
import { useAiTools } from "../../hooks/useAiTools";
/** A message for a session that a tab already holds goes into THAT tab as a
* paste, once its terminal is on screen (it mounts after the navigation);
* a toast says where it went — or, failing that, carries the text. */
function pasteWhenShown(runId: string, text: string) {
let tries = 0;
const tick = () => {
if (pasteToRun(runId, text)) toast.success(i18n.t("lanes:replay.pasted"));
else if (++tries < 30) window.setTimeout(tick, 100);
else toast.error(i18n.t("lanes:replay.notPasted", { text }));
};
window.setTimeout(tick, 0);
}
/** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */
// Before the catalog loads: the two hand-written launchers resume by id.
const RESUMABLE = new Set(["claude", "codex"]);
export function SessionReplay({
lane,
sessionId,
liveRuns,
onResumed,
}: {
lane: Lane;
sessionId: string;
/** Every live dashboard pane: a session one of them holds opens that pane. */
liveRuns: RunHandle[];
/** Opens the pane the resume started in — of `laneId` when it is another
* (nested) workspace's pane that holds the session. */
onResumed: (runId: string, laneId?: number) => void;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const { byId } = useAiTools();
const [session, setSession] = useState<Session | null>(null);
const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]);
const [uploading, setUploading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
setSession(null);
setPrompt("");
setImages([]);
setError(null);
api.sessions
.get(sessionId, { events: false })
.then((r) => live && setSession(r.session))
.catch(() => undefined);
return () => {
live = false;
};
}, [sessionId]);
const tool = session?.tool || "claude";
const resumable = byId(tool)?.resumeById ?? RESUMABLE.has(tool);
const livePane = liveRunFor(lane, liveRuns, sessionId);
const text = promptWithImages(prompt, images, tool === "claude");
const send = async () => {
if (!text || busy || uploading || livePane || !resumable) return;
setBusy(true);
setError(null);
try {
// Re-read: a tab that took this session since the list was read is
// opened, never resumed a second time.
const fresh = await api.run
.list()
.then((r) => r.items ?? [])
.catch(() => liveRuns);
const holder = liveRunFor(lane, fresh, sessionId);
const r = holder
? { runId: holder.id, laneId: lane.id, held: true }
: await startInFreeTabDetailed(lane.id, { resumeSessionId: sessionId, prompt: text });
setPrompt("");
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
setImages([]);
onResumed(r.runId, r.laneId);
// Held already: the prompt reached nothing yet — paste it there.
if (r.held) pasteWhenShown(r.runId, text);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<div
data-testid="session-replay"
data-prompt-drop
className="flex min-h-0 flex-1 flex-col gap-2"
>
{session && (
<div className="flex justify-end">
<SessionResumeButton session={session} />
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
<ConversationView
sessionId={sessionId}
fill
ended={!livePane && !!session && session.status !== "active"}
/>
</div>
{livePane ? (
<div
data-testid="session-replay-live"
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.live")}</span>
<button
type="button"
onClick={() => onResumed(livePane.id)}
className="btn-ghost text-xs"
>
<SquareTerminal className="h-4 w-4" />
{t("resume.openPane")}
</button>
</div>
) : session && !resumable ? (
<div
data-testid="session-replay-no-resume"
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
<button
type="button"
onClick={() => navigate(`/workspace?lane=${lane.id}`)}
className="btn-ghost text-xs"
>
{t("replay.newSession")}
</button>
</div>
) : (
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
<PromptEditor
bare
images={images}
onImagesChange={setImages}
onBusyChange={setUploading}
value={prompt}
onChange={setPrompt}
onSubmit={() => void send()}
placeholder={t("replay.placeholder")}
rows={2}
slash={tool === "claude"}
fileCwd={session?.cwd || lane.cwd}
lane={lane.id}
/>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px] text-fg-muted">{t("replay.hint")}</span>
<button
type="button"
data-testid="session-replay-send"
onClick={() => void send()}
disabled={busy || uploading || !text}
aria-label={t("replay.send")}
title={t("replay.send")}
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<ArrowUp className="h-4 w-4" />
</button>
</div>
{error && (
<p role="alert" className="mt-1 text-xs text-status-danger">
{error}
</p>
)}
</div>
)}
</div>
);
}