efa417488a
- 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.
167 lines
6.3 KiB
TypeScript
167 lines
6.3 KiB
TypeScript
/**
|
|
* @file SessionResumeButton.tsx
|
|
* @description The transcript page's explicit Resume and Fork for a Claude
|
|
* session inside a workspace. Opening a session never resumes it; this is the
|
|
* one place that does, behind a warning, because a resume re-reads the whole
|
|
* transcript into context. Fork resumes it into a NEW session id
|
|
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
|
|
* original untouched, and is offered even while the original has a live pane. A
|
|
* session that still has a live pane offers that pane instead. A resume
|
|
* starts in the workspace's first free pane slot, picked by the server
|
|
* (`POST /api/lanes/:id/start {slot: "auto", resumeSessionId}`, panes re-read
|
|
* first) and opens it — offered only
|
|
* for a session whose folder IS the workspace's, because the pane starts in
|
|
* the workspace's folder and Claude finds a session by the folder it ran in.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { GitFork, History, SquareTerminal } from "lucide-react";
|
|
import { api, type RunHandle } from "../../lib/api";
|
|
import type { Lane, Session } from "../../lib/types";
|
|
import { useLanes } from "../../hooks/useLanes";
|
|
import { ConfirmModal } from "../ConfirmModal";
|
|
import { startInFreeTabDetailed } from "./startSession";
|
|
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
|
|
|
/** The workspace of this tool whose folder holds `cwd` most deeply — the
|
|
* same path-boundary rule the server binds hook sessions with. */
|
|
export function owningLane(cwd: string, tool: string, lanes: Lane[]): Lane | null {
|
|
let best: Lane | null = null;
|
|
for (const l of lanes) {
|
|
if (l.tool !== tool) continue;
|
|
const root = l.cwd.replace(/\/+$/, "");
|
|
if (cwd !== root && !cwd.startsWith(root + "/")) continue;
|
|
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
export function SessionResumeButton({
|
|
session,
|
|
}: {
|
|
session: { id: string; cwd: string | null; tool?: string; lane_id?: number | null };
|
|
}) {
|
|
const { t } = useTranslation("lanes");
|
|
const navigate = useNavigate();
|
|
const { lanes } = useLanes();
|
|
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
|
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let live = true;
|
|
api.run
|
|
.list()
|
|
.then((r) => live && setRuns(r.items ?? []))
|
|
.catch(() => live && setRuns([]));
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [session.id]);
|
|
|
|
// Claude only: a Codex session id is not what
|
|
// `codex resume` takes for every kind of thread.
|
|
const tool = session.tool || "claude";
|
|
// The workspace that owns it, else the one whose folder holds it.
|
|
const lane =
|
|
tool !== "claude" || !session.cwd
|
|
? null
|
|
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
|
|
owningLane(session.cwd, tool, lanes));
|
|
if (!lane || runs === null) return null;
|
|
const run = liveRunFor(lane, runs, session.id);
|
|
const same = (a: string, b: string) => a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
|
|
// Its own worktree counts too: the server resumes it there (`claude
|
|
// --worktree` sessions), and refuses one whose worktree is gone.
|
|
const resumable =
|
|
!!session.cwd &&
|
|
(same(session.cwd, lane.cwd) ||
|
|
sessionWorktreeName(lane, { cwd: session.cwd } as Session) !== null);
|
|
if (!run && !resumable) return null;
|
|
const open = (runId: string, laneId = lane.id) =>
|
|
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
|
|
|
const resume = async (fork: boolean) => {
|
|
// Busy first: the Confirm button is disabled only through it, and the
|
|
// re-read below is a round trip a second click could land in.
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
// Re-read: a pane started since this page loaded must not be picked,
|
|
// and a session resumed elsewhere meanwhile is opened, not resumed twice.
|
|
const fresh = await api.run
|
|
.list()
|
|
.then((r) => r.items ?? [])
|
|
.catch(() => runs);
|
|
setRuns(fresh);
|
|
const live = liveRunFor(lane, fresh, session.id);
|
|
if (live && !fork) return open(live.id);
|
|
// A pane of another (nested) workspace may hold it: open it there.
|
|
const r = await startInFreeTabDetailed(lane.id, {
|
|
resumeSessionId: session.id,
|
|
...(fork ? { forkSession: true } : {}),
|
|
});
|
|
open(r.runId, r.laneId);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
setConfirming(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-wrap items-center justify-end gap-1">
|
|
{run ? (
|
|
<button type="button" onClick={() => open(run.id)} className="btn-ghost text-xs">
|
|
<SquareTerminal className="h-4 w-4" />
|
|
{t("resume.openPane")}
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirming("resume")}
|
|
title={t("resume.hint")}
|
|
className="btn-ghost text-xs"
|
|
>
|
|
<History className="h-4 w-4" />
|
|
{t("sessions.resume")}
|
|
</button>
|
|
)}
|
|
{resumable && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirming("fork")}
|
|
title={t("resume.forkHint")}
|
|
className="btn-ghost text-xs"
|
|
>
|
|
<GitFork className="h-4 w-4" />
|
|
{t("resume.fork")}
|
|
</button>
|
|
)}
|
|
{error && (
|
|
<p role="alert" className="basis-full text-right text-[11px] text-status-danger">
|
|
{error}
|
|
</p>
|
|
)}
|
|
<ConfirmModal
|
|
open={confirming !== null}
|
|
title={t(confirming === "fork" ? "resume.forkTitle" : "resume.title")}
|
|
message={t(confirming === "fork" ? "resume.forkBody" : "resume.body", {
|
|
workspace: lane.title || lane.cwd,
|
|
})}
|
|
confirmLabel={t(confirming === "fork" ? "resume.forkConfirm" : "resume.confirm")}
|
|
cancelLabel={t("home.cancel")}
|
|
destructive={false}
|
|
busy={busy}
|
|
onConfirm={() => void resume(confirming === "fork")}
|
|
onCancel={() => setConfirming(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|