feat(workspace): no double resume, server-picked tabs, terminal a11y
- A resume of a session a live pane already holds is refused (409 ESESSIONLIVE with error.run_id) and the client opens that pane, pasting any typed message there; catalog tools refuse resumeSessionId (EUNSUPPORTED) instead of silently starting fresh. - POST /api/lanes/:id/start takes slot: "auto": the server picks the free tab under the lane lock (components/lanes/startSession.ts on the client, kept out of WorkspaceHome so the sidebar does not pull react-markdown into the main bundle). - Unsaved whole-file edits are guarded on every exit (Back, another file, tab switch, close, lane/session change, beforeunload). - Closing an AI tab asks first; hiding a pane has its own icon. Tabs and pane headers show the session name, a state dot, rename by double-click. - Terminal: Ctrl+M toggles Tab-moves-focus (no keyboard trap), Cmd+F / Ctrl+Shift+F find bar, clickable links (noopener), a write-only OSC 52 clipboard, a Reconnecting overlay, debounced resizes, optional screen reader mode. Alt+1-4 / Alt+\ / Alt+W focus, split and hide panes. - Changes panel: draggable splitter, wrapping git bar, history paging, disarming discard/undo/abort, inline diff comments pasted only into a visible pane; drag MIME guard on sidebar rows; IME-safe Enter; Show more in the sidebar and Recents; idle dots readable with sr-only text. - Adds @xterm/addon-clipboard, @xterm/addon-search, @xterm/addon-web-links.
This commit is contained in:
@@ -2,22 +2,52 @@
|
||||
* @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` and that prompt, in the
|
||||
* first free pane slot, after which `onResumed` opens the pane (`&run=`). 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).
|
||||
* 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 { ArrowUp } from "lucide-react";
|
||||
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 { freeSlot, paneRunId } from "./WorkspaceHome";
|
||||
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,
|
||||
@@ -27,15 +57,18 @@ export function SessionReplay({
|
||||
}: {
|
||||
lane: Lane;
|
||||
sessionId: string;
|
||||
/** Every live dashboard pane, to pick a free slot. */
|
||||
/** Every live dashboard pane: a session one of them holds opens that pane. */
|
||||
liveRuns: RunHandle[];
|
||||
/** Opens the pane the resume started in. */
|
||||
onResumed: (runId: string) => void;
|
||||
/** 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);
|
||||
|
||||
@@ -46,7 +79,7 @@ export function SessionReplay({
|
||||
setImages([]);
|
||||
setError(null);
|
||||
api.sessions
|
||||
.get(sessionId)
|
||||
.get(sessionId, { events: false })
|
||||
.then((r) => live && setSession(r.session))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
@@ -54,26 +87,32 @@ export function SessionReplay({
|
||||
};
|
||||
}, [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 () => {
|
||||
const text = promptWithImages(prompt, images);
|
||||
if (!text || busy) return;
|
||||
const slot = freeSlot(lane.id, liveRuns);
|
||||
if (slot === null) {
|
||||
setError(t("home.allBusy"));
|
||||
return;
|
||||
}
|
||||
if (!text || busy || uploading || livePane || !resumable) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.lanes.action(lane.id, "start", {
|
||||
slot,
|
||||
resumeSessionId: sessionId,
|
||||
prompt: text,
|
||||
});
|
||||
// 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) => URL.revokeObjectURL(i.url));
|
||||
setImages([]);
|
||||
onResumed(r.run_id ?? paneRunId(lane.id, slot));
|
||||
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 {
|
||||
@@ -83,42 +122,79 @@ export function SessionReplay({
|
||||
|
||||
return (
|
||||
<div data-testid="session-replay" 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} />
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border p-2 transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
onSubmit={() => void send()}
|
||||
placeholder={t("replay.placeholder")}
|
||||
rows={2}
|
||||
slash={(session?.tool ?? "claude") === "claude"}
|
||||
fileCwd={session?.cwd || lane.cwd}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px] text-fg-muted">{t("replay.hint")}</span>
|
||||
{livePane ? (
|
||||
<div
|
||||
data-testid="session-replay-live"
|
||||
className="flex 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"
|
||||
data-testid="session-replay-send"
|
||||
onClick={() => void send()}
|
||||
disabled={busy || (!prompt.trim() && images.length === 0)}
|
||||
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 text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
onClick={() => onResumed(livePane.id)}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
<SquareTerminal className="h-4 w-4" />
|
||||
{t("resume.openPane")}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="mt-1 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : session && !resumable ? (
|
||||
<div
|
||||
data-testid="session-replay-no-resume"
|
||||
className="flex 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="rounded-2xl border border-border p-2 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}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user