f6f1bf4a76
- 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).
206 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|