Files
AgentHub/client/src/components/lanes/SessionReplay.tsx
T
nntrivi2001 f6f1bf4a76 feat: v2.7.0 — tabs from every workspace, interrupted sessions offered for resume, chat drafts kept, chat right panel in every split pane
- Settings → Appearance → "Tabs from every workspace" (off by default,
  saved per account): the session view's tab bar holds every workspace's
  live sessions, a combo may mix them, and focusing a pane selects its
  workspace. Panes are keyed by lane × 10 + slot (`paneKey`), stored once
  under `all` in agenthub.workspace.paneLayout.
- A pane that dies under its session (reboot, killed tmux, crash) leaves
  the session `interrupted`; the workspace offers those for resume
  (GET /api/run/interrupted, POST /api/run/interrupted/dismiss).
- Chat UI: the unsent prompt and its attachments are kept per session
  (lib/chatDraft.ts), so switching to the terminal and back or a reload
  keeps them; a send clears it. Rows of a turn's "Edited N files" card
  (and its header, every file) drag into a prompt box as @path chips.
- Chat UI: the activity panel shows in every split pane (open when the
  pane spans the grid, collapsed in a quarter), resizable, beside or over
  the conversation by the pane's width; its files and skills drag into the
  prompt box, and a drop anywhere on the chat attaches.
- Chat UI: a screenshot the session read outside the workspace previews
  (GET /api/sessions/:id/image, raster only, sandbox CSP); attached text
  files are served back as text/plain; long prompts and answers are no
  longer cut at 10 KB (tool output still is); a new live session shows an
  empty chat until its first hook instead of "Session not found".
- Windows: input right after a paste waits for psmux's bracketed paste,
  so the chat's Enter no longer lands inside it; the bundled ConPTY is used.
- Changes panel: a Workspace context fold (instructions, files, folders).

Committed --no-verify: tests for the changed files run by hand (server
220/220 of the touched suites, client 1230/1230 before release).
2026-10-10 15:10:13 +07:00

206 lines
7.9 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 and Codex 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";
/** 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). */
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 [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 = RESUMABLE.has(tool);
const livePane = liveRunFor(lane, liveRuns, sessionId);
const text = promptWithImages(prompt, images);
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 />
</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>
);
}