Files
AgentHub/client/src/components/lanes/SessionResumeButton.tsx
T
nntrivi2001 f8d07dab61 feat(ai-tools): omp shared + OpenCode/omp sessions, tokens, subagents; Grok hooks; resume by id
- omp-usage.js / opencode-usage.js: poll each CLI's own session store into
  sessions (tool omp/opencode), token_usage per model and subagent agents;
  transcript routes read them (omp journals, OpenCode's opencode.db).
- Grok runs the Claude hooks in ~/.claude/settings.json: its hooks are now
  recognised (hookEventName + workspaceRoot), tagged tool=grok, and their
  transcript_path dropped instead of being parsed as a Claude transcript.
- Catalog `resumeById`: omp/Grok/Cursor `--resume <id>`, OpenCode
  `--session <id>`; session ids may carry `_`. The client offers Resume for
  any tool that resumes by id.
- Kimi Code and GitHub Copilot leave the catalog (their session stores could
  not be verified).
2026-10-19 16:49:59 +07:00

179 lines
7.0 KiB
TypeScript

/**
* @file SessionResumeButton.tsx
* @description The transcript page's explicit Resume and Fork for a Claude
* session inside a workspace (Resume only for a catalog tool that resumes by
* id — omp's `--resume <id>`). 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";
import { usePermissions } from "../AuthGate";
import { useAiTools } from "../../hooks/useAiTools";
/** A path without its trailing separators — `/` or a Windows `\`. */
const strip = (p: string) => p.replace(/[\\/]+$/, "");
/** 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;
const target = strip(cwd);
for (const l of lanes) {
if (l.tool !== tool) continue;
const root = strip(l.cwd);
if (target !== root && !target.startsWith(root + "/") && !target.startsWith(root + "\\"))
continue;
if (!best || root.length > strip(best.cwd).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 { canEditLane } = usePermissions();
const { byId } = useAiTools();
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, or a catalog tool that resumes by id (no launcher of its own): a
// Codex session id is not what `codex resume` takes for every kind of thread.
const tool = session.tool || "claude";
const catalogResume = !!byId(tool)?.resumeById && !byId(tool)?.launcher;
// The workspace that owns it, else the one whose folder holds it.
const lane =
(tool !== "claude" && !catalogResume) || !session.cwd
? null
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
owningLane(session.cwd, tool, lanes));
// Server mode: an analyst may resume only in a workspace of their own.
if (!lane || !canEditLane(lane) || runs === null) return null;
const run = liveRunFor(lane, runs, session.id);
const same = (a: string, b: string) => strip(a) === strip(b);
// 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 && tool === "claude" && (
<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>
);
}