Files
AgentHub/client/src/components/lanes/SessionMenu.tsx
T
nntrivi2001 879a6bb02d feat(server-mode): share one session; scope collaborators' pane tokens (sharing phase 3)
A workspace's owner (or an admin) can share ONE session from its right-click
menu (GET/PUT /api/lanes/:id/sessions/:sid/shares, table session_shares,
audited). The user sees the workspace (access: "session") with only that
session, joins the pane holding it, resumes it on their own account in a
free slot, and uses the workspace's git and Files reads and git writes;
everything else of the workspace answers 403 ESESSIONSHARE (scope.js
sessionShareAllows in scopeGuard). Hook/runtime broadcasts stay with full
collaborators.

A collaborator's pane token (readable by the owner's Linux account) now acts
as them inside that workspace only, and never starts or bills a session
(auth.js paneLaneAllows, 403 EPANESCOPE); a Terminal tab the owner or an
admin opens acts as the owner again. Docs say plainly that under S3 a
session share hands over the owner's account inside the terminal.
2026-11-06 11:25:40 +07:00

118 lines
4.1 KiB
TypeScript

/**
* @file SessionMenu — a session row's right-click menu (sidebar, the workspace
* home's Recents, the archived fold), and a bare live tab's (`session` null —
* the sidebar's **New session** row). Every item is something the row already
* does: open it the way its click does (a live pane attaches, an ended session
* opens its conversation — never a resume), open a live one in a split,
* rename / pin / unpin / archive / unarchive when the list offers them, copy its id, open
* its analysis page, and end its live tab as the tab's buttons do (`endRuns`,
* after the same confirm for an AI tab, which may be mid-turn): **Sleep** stops
* it and keeps the session listed to resume later, **Close tab** stops it and
* archives the session.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import type { RunHandle } from "../../lib/api";
import type { Session } from "../../lib/types";
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "../ContextMenu";
import { copyText } from "../../lib/clipboard";
import { canSleep, endRuns } from "../../lib/stopSession";
export function SessionMenu({
laneId,
session,
run,
x,
y,
onClose,
onRename,
onArchive,
onUnarchive,
onPin,
onShare,
}: MenuPoint & {
laneId: number;
session: Session | null;
/** The live pane holding it, if any. */
run: RunHandle | null;
onClose: () => void;
onRename?: () => void;
onArchive?: () => void;
onUnarchive?: () => void;
/** Pin or unpin (by `session.pinned_at`), where the list offers it. */
onPin?: () => void;
/** Share this session on its own (server mode, the workspace's owner). */
onShare?: () => void;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const runUrl = run && `/workspace?lane=${laneId}&run=${encodeURIComponent(run.id)}`;
const pick = (fn: () => unknown) => () => {
onClose();
fn();
};
// Stop archives the session; Sleep keeps it listed to resume later
// (`endRuns` asks first for an AI pane, which may be mid-turn).
const endTab = (sleep: boolean) => {
if (!run) return;
onClose();
void endRuns([run], { sleep, name: session?.name || t("tree.newSession"), t });
};
return (
<ContextMenu x={x} y={y} onClose={onClose} testId={`session-menu-${session?.id ?? run?.id}`}>
<MenuItem
onSelect={pick(() =>
navigate(
runUrl ?? `/workspace?lane=${laneId}&session=${encodeURIComponent(session?.id ?? "")}`
)
)}
>
{t("sessionMenu.open")}
</MenuItem>
{runUrl && (
<MenuItem onSelect={pick(() => navigate(runUrl, { state: { split: true } }))}>
{t("sessionMenu.openSplit")}
</MenuItem>
)}
{onRename && <MenuItem onSelect={pick(onRename)}>{t("menu.rename")}</MenuItem>}
{onPin && session && (
<MenuItem onSelect={pick(onPin)}>
{t(session.pinned_at ? "sessionMenu.unpin" : "sessionMenu.pin")}
</MenuItem>
)}
{onShare && session && (
<MenuItem testId="session-menu-share" onSelect={pick(onShare)}>
{t("sessionMenu.share")}
</MenuItem>
)}
{session && (
<>
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
{t("sessionMenu.details")}
</MenuItem>
<MenuItem onSelect={pick(() => void copyText(session.id))}>
{t("sessionMenu.copyId")}
</MenuItem>
</>
)}
{(run || onArchive || onUnarchive) && <MenuSeparator />}
{onUnarchive && <MenuItem onSelect={pick(onUnarchive)}>{t("tree.unarchive")}</MenuItem>}
{onArchive && <MenuItem onSelect={pick(onArchive)}>{t("sessionMenu.archive")}</MenuItem>}
{canSleep(run) && (
<MenuItem testId="session-menu-sleep" onSelect={() => endTab(true)}>
{t("tabs.sleep")}
</MenuItem>
)}
{run && (
<MenuItem danger onSelect={() => endTab(false)}>
{t("tabs.close")}
</MenuItem>
)}
</ContextMenu>
);
}