refactor(workspace): fix viewportLocked regression and wire run status callback

This commit is contained in:
2026-08-14 12:09:46 +07:00
parent 8a61a2b359
commit 22ce61bcfe
4 changed files with 74 additions and 1274 deletions
+49 -603
View File
@@ -37,25 +37,14 @@
*
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Play, AlertCircle, X, Plus } from "lucide-react";
import { Plus } from "lucide-react";
import { api } from "../lib/api";
import type {
CwdSuggestion,
DashboardRunHistoryItem,
EffortLevel,
PermissionMode,
RunHandle,
RunListResponse,
RunStartArgs,
} from "../lib/api";
import type { Session, Lane, LaneFeature, LaneCounts, ProofFeature, WSMessage } from "../lib/types";
import type { CwdSuggestion, RunListResponse } from "../lib/api";
import type { Lane, LaneFeature, LaneCounts, ProofFeature, WSMessage } from "../lib/types";
import { eventBus } from "../lib/eventBus";
import { TerminalView } from "../components/run/TerminalView";
import { RunSetup } from "../components/run/RunSetup";
import { ActiveRunsSwitcher } from "../components/run/RunHistory";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard from "../components/lanes/LaneCard";
import LaneStripCard from "../components/lanes/LaneStripCard";
@@ -64,9 +53,7 @@ import { AddLaneModal } from "../components/lanes/AddLaneModal";
// ── Page ──────────────────────────────────────────────────────────────
export function Workspace() {
const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes");
const [searchParams, setSearchParams] = useSearchParams();
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
// Lane state
@@ -83,22 +70,15 @@ export function Workspace() {
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
// Run state
const [prompt, setPrompt] = useState("");
const [model, setModel] = useState("");
const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
const [effort, setEffort] = useState<EffortLevel>("");
const [cwd, setCwd] = useState("");
const [resumeSession, setResumeSession] = useState<Session | null>(null);
const [handle, setHandle] = useState<RunHandle | null>(null);
const [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null);
const [error, setError] = useState<string | null>(null);
// Run state kept at page level: shared across every pane, or drives the
// lane strip itself rather than any one pane's form.
const [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null);
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>(
null
);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [defaultCwd, setDefaultCwd] = useState<string>("");
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
// Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
const refreshLanes = useCallback(async () => {
@@ -126,10 +106,6 @@ export function Workspace() {
.list()
.then(setActiveRuns)
.catch(() => undefined);
api.run
.history(50)
.then((r) => setRunHistory(r.items))
.catch(() => undefined);
api.lanes
.pipelines()
.then((r) => setPipelineTemplates(r.pipelines))
@@ -150,7 +126,7 @@ export function Workspace() {
const dashboard = r.items.find((s) => s.kind === "dashboard");
const preferred = home || dashboard;
if (preferred) {
setCwd((current) => current || preferred.path);
setDefaultCwd(preferred.path);
}
})
.catch(() => undefined);
@@ -196,19 +172,7 @@ export function Workspace() {
.list()
.then(setActiveRuns)
.catch(() => undefined);
// Fetch history for the selected lane only
if (selectedLaneId !== null) {
api.run
.history(50, { laneId: selectedLaneId })
.then((r) => setRunHistory(r.items))
.catch(() => undefined);
} else {
api.run
.history(50)
.then((r) => setRunHistory(r.items))
.catch(() => undefined);
}
}, [selectedLaneId]);
}, []);
// Background poll so the run list and history reflect external changes
// (server-boot reconciliation, sibling tabs, direct DB edits) even when
@@ -237,406 +201,6 @@ export function Workspace() {
};
}, [refreshList]);
// Resume a run from the persistent history list. Routes through the lane
// system: ensure a lane for the cwd, then start a new claude process
// in resumeSessionId mode. If cwd is null, resume as a non-lane run
// (non-lane runs are those created before this feature or via the CLI).
const onResumeFromHistory = useCallback(
async (item: DashboardRunHistoryItem) => {
if (!item.session_id) return;
if (busy) return;
setBusy("start");
setError(null);
try {
let fetched: RunHandle;
if (item.cwd) {
// Resume through a lane: ensure the lane exists, then start on it
const effectiveCwd = item.cwd;
let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd)?.id;
if (!targetLaneId) {
// Ensure lane for this cwd
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
targetLaneId = ensureResult.lane.id;
setLanes((prev) => {
const exists = prev.some((l) => l.id === ensureResult.lane.id);
return exists ? prev : [...prev, ensureResult.lane];
});
}
// Start on the lane with resume
const laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: "",
model: item.model || undefined,
permissionMode: item.permission_mode || undefined,
effort: item.effort || undefined,
resumeSessionId: item.session_id,
});
if (!laneStartResult.lane?.run_id) {
throw new Error("No run_id returned from lane start");
}
fetched = await api.run.get(laneStartResult.lane.run_id);
} else {
// No cwd: resume as a non-lane run (backward compatibility).
// These runs stay outside the lane system and are cleaned up
// by their own expiry, not by lane release.
fetched = await api.run.start({
laneId: 0,
initialPrompt: "",
cwd: undefined,
model: item.model || undefined,
permissionMode: item.permission_mode || undefined,
effort: item.effort || undefined,
resumeSessionId: item.session_id,
});
}
setHandle(fetched);
setResumeSession(null);
refreshList();
} catch (err) {
const msg = err instanceof Error ? err.message : "unknown";
setError(t("errors.startFailed", { message: msg }));
} finally {
setBusy(null);
}
},
[busy, refreshList, t, lanes]
);
// View a past run: navigate to the SessionDetail page which shows the transcript.
const navigate = useNavigate();
const onViewFromHistory = useCallback(
(item: DashboardRunHistoryItem) => {
if (item.session_id) {
navigate(`/sessions/${encodeURIComponent(item.session_id)}`);
}
},
[navigate]
);
const start = useCallback(async () => {
if (!prompt.trim() || busy) return;
setBusy("start");
setError(null);
try {
const effectiveCwd = resumeSession?.cwd || cwd || undefined;
// Determine which lane to use. If no lane is selected or the cwd
// doesn't belong to the selected lane, ensure a lane for this cwd first.
if (!effectiveCwd) {
throw new Error(t("errors.cwdRequired"));
}
let targetLaneId = selectedLaneId;
if (
targetLaneId === null ||
(selectedLaneId !== null &&
lanes.find((l) => l.id === selectedLaneId)?.cwd !== effectiveCwd)
) {
// Check if any existing lane owns this cwd
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd);
if (ownedLane) {
targetLaneId = ownedLane.id;
setSelectedLaneId(ownedLane.id);
} else {
// Ensure a new lane for this cwd
try {
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
targetLaneId = ensureResult.lane.id;
setSelectedLaneId(ensureResult.lane.id);
setLanes((prev) => {
const exists = prev.some((l) => l.id === ensureResult.lane.id);
return exists ? prev : [...prev, ensureResult.lane];
});
} catch (err) {
throw new Error(
t("errors.laneCreateFailed", {
message: err instanceof Error ? err.message : "unknown",
})
);
}
}
}
if (targetLaneId === null) {
throw new Error(t("errors.noLaneSelected"));
}
// Start the run on the target lane
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: prompt || "",
model: model || undefined,
permissionMode,
resumeSessionId: resumeSession?.id,
effort: effort || undefined,
});
} catch (laneErr: unknown) {
// Check for 409 ERUNLIVE — the lane already has a live run
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
// Re-fetch the lane to get its current run_id. Read the run id off
// the response, not off `lanes` - refreshLanes() only schedules a
// setState, so the render-scope `lanes` array here is still the
// pre-409 snapshot and would never carry the live run.
const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
await refreshLanes();
if (updatedLane?.run_id) {
// Attach to the already-running run
await attachToRun(updatedLane.run_id);
return;
}
}
throw laneErr;
}
// The response is { lane? } per the API. Read the run_id from the lane.
if (!laneStartResult.lane?.run_id) {
throw new Error(t("errors.noRunIdReturned"));
}
// Fetch the full RunHandle for the new run; fall back to attachToRun if fetch fails
try {
const handle = await api.run.get(laneStartResult.lane.run_id);
setHandle(handle);
refreshList();
} catch (attachErr: unknown) {
// Run started but we can't fetch the handle. Attach to the run via the existing path.
try {
await attachToRun(laneStartResult.lane.run_id);
refreshList();
} catch (fallbackErr: unknown) {
// Even attach failed. Refresh lanes and report the attach error.
await refreshLanes();
const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
throw new Error(t("errors.runStartedButNotAttached", { message: attachMsg }));
}
}
} catch (err: unknown) {
const m = err instanceof Error ? err.message : "unknown";
setError(t("errors.startFailed", { message: m }));
} finally {
setBusy(null);
}
}, [
prompt,
cwd,
model,
permissionMode,
busy,
refreshList,
t,
resumeSession,
selectedLaneId,
lanes,
]);
const attachToRun = useCallback(
async (id: string) => {
if (busy) return;
setBusy("attach");
setError(null);
try {
const fetched = await api.run.get(id);
setHandle(fetched);
} catch (err: unknown) {
const m = err instanceof Error ? err.message : "unknown";
setError(t("errors.attachFailed", { message: m }));
} finally {
setBusy(null);
}
},
[busy, t]
);
// Honor `?session=<id>` deep-links from /sessions and /sessions/:id -
// map the session id to a live run handle and attach to it instead of
// dropping the user on the new-run config card. Strip the param once
// consumed so a refresh of the Run page doesn't keep re-attaching.
const attachAttemptedRef = useRef<Set<string>>(new Set());
useEffect(() => {
const sid = searchParams.get("session");
if (!sid) return;
if (handle && handle.sessionId === sid) {
// Already attached to this session - just clean the URL.
const next = new URLSearchParams(searchParams);
next.delete("session");
setSearchParams(next, { replace: true });
return;
}
if (attachAttemptedRef.current.has(sid)) return;
attachAttemptedRef.current.add(sid);
api.run
.list()
.then((list) => {
const target = list.items.find((h) => h.sessionId === sid && h.status === "running");
if (target) {
void attachToRun(target.id);
} else {
setError(
t(
"errors.sessionRunNotFound",
"No active dashboard run is driving this session right now."
)
);
}
})
.catch(() => undefined)
.finally(() => {
const next = new URLSearchParams(searchParams);
next.delete("session");
setSearchParams(next, { replace: true });
});
}, [searchParams, setSearchParams, handle, attachToRun, t]);
// Prefill the prompt box from `?prompt=<text>` (e.g. Tabby's Ask handoff).
// Apply once, then strip the param so a later refresh doesn't overwrite edits
// the user has since made to the prompt. When `?autostart=1` is also present
// (Tabby's "ask" path), arm a pending flag so the run fires automatically
// once preflight is ready - see the autostart effect below.
const promptPrefilledRef = useRef(false);
const pendingAutostartRef = useRef(false);
useEffect(() => {
if (promptPrefilledRef.current) return;
const p = searchParams.get("prompt");
if (!p) return;
promptPrefilledRef.current = true;
if (searchParams.get("autostart") === "1") pendingAutostartRef.current = true;
setPrompt(p);
const next = new URLSearchParams(searchParams);
next.delete("prompt");
next.delete("autostart");
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
// Autostart a deep-linked prompt once preflight has settled. We wait for the
// binary probe (can't spawn without `claude`), the prefilled prompt, and the
// defaulted cwd so the spawn matches exactly what the manual Start button
// would do. Fires at most once; if `claude` isn't found or a run is already
// in flight, it disarms and leaves the prompt prefilled for a manual Start.
useEffect(() => {
if (!pendingAutostartRef.current) return;
if (binaryStatus === null) return; // probe still pending
if (!binaryStatus.found) {
pendingAutostartRef.current = false;
return;
}
if (busy || handle) {
pendingAutostartRef.current = false;
return;
}
if (!prompt.trim() || !cwd) return; // wait for prefill + cwd default
pendingAutostartRef.current = false;
void start();
}, [binaryStatus, prompt, cwd, busy, handle, start]);
const onStartFromSetup = useCallback(
async (args: RunStartArgs) => {
if (busy) return;
setBusy("start");
setError(null);
try {
const effectiveCwd = args.cwd || undefined;
if (!effectiveCwd) {
throw new Error(t("errors.cwdRequired"));
}
// Resolve the lane from the cwd the user actually typed, not from
// args.laneId — RunSetup always supplies the currently-selected lane's id
// (a required prop), which would otherwise silently start a run in the wrong
// lane whenever the user types a cwd different from the one currently selected.
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd);
let targetLaneId: number;
if (ownedLane) {
targetLaneId = ownedLane.id;
if (ownedLane.id !== args.laneId) setSelectedLaneId(ownedLane.id);
} else {
try {
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
targetLaneId = ensureResult.lane.id;
setSelectedLaneId(ensureResult.lane.id);
setLanes((prev) => {
const exists = prev.some((l) => l.id === ensureResult.lane.id);
return exists ? prev : [...prev, ensureResult.lane];
});
} catch (err) {
throw new Error(
t("errors.laneCreateFailed", {
message: err instanceof Error ? err.message : "unknown",
})
);
}
}
if (!targetLaneId) {
throw new Error(t("errors.noLaneSelected"));
}
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: args.initialPrompt || "",
model: args.model || undefined,
permissionMode: args.permissionMode,
resumeSessionId: args.resumeSessionId,
effort: args.effort || undefined,
});
} catch (laneErr: unknown) {
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
await refreshLanes();
if (updatedLane?.run_id) {
await attachToRun(updatedLane.run_id);
return;
}
}
throw laneErr;
}
if (!laneStartResult.lane?.run_id) {
throw new Error(t("errors.noRunIdReturned"));
}
try {
const handle = await api.run.get(laneStartResult.lane.run_id);
setHandle(handle);
refreshList();
} catch (attachErr: unknown) {
try {
await attachToRun(laneStartResult.lane.run_id);
refreshList();
} catch (fallbackErr: unknown) {
await refreshLanes();
const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
throw new Error(t("errors.runStartedButNotAttached", { message: attachMsg }));
}
}
} catch (err: unknown) {
const m = err instanceof Error ? err.message : "unknown";
setError(t("errors.startFailed", { message: m }));
} finally {
setBusy(null);
}
},
[busy, t, lanes, selectedLaneId, refreshLanes, attachToRun, refreshList, prompt]
);
const newRun = useCallback(() => {
setHandle(null);
setPrompt("");
setResumeSession(null);
setError(null);
}, []);
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
// Feature list follows the selected lane, resets the viewer on lane switch.
@@ -696,15 +260,10 @@ export function Workspace() {
? proofFeatures.find((f) => f.slug === activeFeatureSlug)
: null;
// Only lock the page to the viewport when we're showing a live run session.
// The config-card screen needs normal page flow so the form is fully
// reachable on short windows. The run-session screen, however, owns the
// chat panel and we want long chats to scroll inside the panel - never the
// page - so we constrain only that case.
// Only a live console needs the viewport-locked shell that lets its chat
// panel scroll internally. Collapsed, the page is an ordinary scrolling
// document and the lane grid gets the whole height.
const viewportLocked = !!handle;
// Viewport locked only when a live run is showing (TerminalView needs locked
// viewport for chat scrolling). The config-card screen needs normal page flow
// so the form is fully reachable on short windows.
const viewportLocked = paneHasActiveRun;
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
setLaneActionError(null);
@@ -746,89 +305,6 @@ export function Workspace() {
}
};
const consoleSection = (
<>
{/* Always attached under the pipeline - no header, no collapse. The
pipeline panel above already names the lane; unmounting RunConsole
would throw away a live run's rendered history and scroll
position, so this stays mounted for the page's whole life. */}
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5">
<Header
activeRuns={activeRuns}
currentHandleId={handle?.id || null}
onAttach={attachToRun}
wsConnected={wsConnected}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onRefresh={refreshList}
/>
{binaryStatus && !binaryStatus.found && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{t("binary.missing")}</span>
</div>
)}
{error && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 break-all">{error}</span>
<button
onClick={() => setError(null)}
className="text-status-danger/70 hover:text-status-danger p-0.5"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
{!handle ? (
// Config card uses normal page flow - page scrolls if needed.
<RunSetup
laneId={currentLane?.id || 0}
prompt={prompt}
onPromptChange={setPrompt}
cwd={cwd}
onCwdChange={setCwd}
cwdSuggestions={cwdSuggestions}
model={model}
onModelChange={setModel}
permissionMode={permissionMode}
onPermissionModeChange={setPermissionMode}
effort={effort}
onEffortChange={setEffort}
binaryFound={binaryStatus?.found ?? true}
busy={busy === "start"}
onStart={onStartFromSetup}
activeRuns={activeRuns}
laneCwd={currentLane?.cwd}
resumeSession={resumeSession}
onResumeSessionChange={setResumeSession}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
/>
) : (
// Run session is wrapped in a flex container so the terminal panel
// can take all remaining viewport height.
<div className="flex-1 min-h-0 flex flex-col">
<TerminalView
runId={handle.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
/>
<button
onClick={newRun}
className="mt-3 px-3 py-1.5 text-sm rounded border border-border hover:border-border-light text-fg-secondary hover:text-fg-primary transition-colors"
>
{t("actions.newRun")}
</button>
</div>
)}
</div>
</>
);
return (
<div
className={
@@ -894,10 +370,7 @@ export function Workspace() {
key={l.id}
lane={l}
selected={selectedLaneId === l.id}
onSelect={() => {
setSelectedLaneId(l.id);
setCwd(l.cwd);
}}
onSelect={() => setSelectedLaneId(l.id)}
/>
))}
{!lanes.length && (
@@ -1041,7 +514,21 @@ export function Workspace() {
</div>
)}
<div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
{consoleSection}
<LaneConsolePane
lanes={lanes}
laneId={selectedLaneId}
showLaneSelector={false}
onLaneIdChange={(id) => setSelectedLaneId(id)}
onLaneCreated={(lane) =>
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={setPaneHasActiveRun}
/>
</div>
</section>
)}
@@ -1069,65 +556,24 @@ export function Workspace() {
{/* No lane selected (none exist, or nothing picked yet): the console has
nowhere to attach, so it falls back to page level. Without this the
start form would be unreachable on a fresh install. */}
{!currentLane && <div className="flex min-h-0 flex-col gap-2">{consoleSection}</div>}
{!currentLane && (
<div className="flex min-h-0 flex-col gap-2">
<LaneConsolePane
lanes={lanes}
laneId={selectedLaneId}
showLaneSelector={true}
onLaneIdChange={(id) => setSelectedLaneId(id)}
onLaneCreated={(lane) =>
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
/>
</div>
)}
</div>
);
}
// ── Header ────────────────────────────────────────────────────────────
function Header({
activeRuns,
currentHandleId,
onAttach,
wsConnected,
runHistory,
onResumeFromHistory,
onViewFromHistory,
onRefresh,
}: {
activeRuns: RunListResponse | null;
currentHandleId: string | null;
onAttach: (id: string) => void;
wsConnected: boolean;
runHistory: DashboardRunHistoryItem[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
onRefresh: () => void;
}) {
const { t } = useTranslation("run");
const { t: tCommon } = useTranslation("common");
return (
<header className="flex items-start gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
<Play className="w-4.5 h-4.5 text-accent" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{tCommon("live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{tCommon("offline")}
</span>
)}
</div>
<p className="text-xs text-fg-muted max-w-3xl">{t("subtitle")}</p>
</div>
<ActiveRunsSwitcher
activeRuns={activeRuns}
currentHandleId={currentHandleId}
onAttach={onAttach}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onRefresh={onRefresh}
/>
</header>
);
}