feat(server-mode): pane presence and typing audit (sharing phase 4)

A pane's tab shows the other people who have it open (pane-presence.js,
in memory, fed by the /ws-pty attach; GET /api/lanes/:id/presence and the
pane_presence broadcast to the owner and full collaborators) and says
"Billed to <name>" for a pane a collaborator opened (startedBy on the run).
The first real keystroke of each attach into a shared workspace's pane by
anyone but its owner is audited as `type into <run id>` (mouse, focus and
terminal replies to tmux do not count). The pty socket now pings, so a
vanished browser is ended at the next re-check and leaves the presence list.
This commit is contained in:
2026-11-06 11:32:25 +07:00
parent 879a6bb02d
commit 18e574a765
21 changed files with 568 additions and 8 deletions
+42 -3
View File
@@ -85,6 +85,8 @@ import {
slotOfKey,
} from "../../lib/paneLayout";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { useAuth } from "../AuthGate";
import { usePanePresence } from "../../hooks/usePanePresence";
type AiToolsState = ReturnType<typeof useAiTools>;
@@ -234,6 +236,19 @@ export function ConsoleTabs({
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
const menuRef = useRef<HTMLDivElement>(null);
const tabCtx = useContextMenu<Tab>();
// Server mode: who else has these panes open, and whose money a pane is.
const presence = usePanePresence(lane.id);
const { state: auth } = useAuth();
const meId = auth.mode === "server" ? auth.user?.id : undefined;
const othersIn = (tab: Tab) => {
const seen = new Map<number, { id: number; name: string }>();
for (const r of tab.runs) {
for (const u of presence[r.id] ?? []) if (u.id !== meId) seen.set(u.id, u);
}
return [...seen.values()];
};
const billedTo = (tab: Tab) =>
[...new Set(tab.runs.map((r) => r.startedBy?.name).filter(Boolean))] as string[];
// A group whose runs the list has not caught up with yet is not drawn.
const tabs = useMemo<Tab[]>(
@@ -532,6 +547,7 @@ export function ConsoleTabs({
{tabs.map((tab, i) => {
const active = !settings?.active && tab.index === groups.active;
const single = tab.runs.length === 1 ? tab.focus : null;
const others = othersIn(tab);
const state = tabState(tab);
if (single && renaming === single.id) {
return (
@@ -606,7 +622,7 @@ export function ConsoleTabs({
// Ctrl/Cmd+click brings a one-pane tab into the tab on screen.
onClick={(e) => onOpen(tab.focus.id, !!single && (e.ctrlKey || e.metaKey))}
onDoubleClick={() => single && startRename(single)}
title={
title={[
single
? [
toolLabel(single),
@@ -617,8 +633,14 @@ export function ConsoleTabs({
]
.filter(Boolean)
.join("\n")
: tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n")
}
: tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n"),
...billedTo(tab).map((name) => t("tabs.billedTo", { name })),
others.length
? t("tabs.here", { names: others.map((u) => u.name).join(", ") })
: null,
]
.filter(Boolean)
.join("\n")}
className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm"
>
<span className="flex shrink-0 items-center gap-1">
@@ -627,6 +649,23 @@ export function ConsoleTabs({
))}
</span>
<span className="truncate">{tabLabel(tab)}</span>
{others.length > 0 && (
<span
data-testid={`console-tab-people-${tab.key}`}
className="flex shrink-0 -space-x-1"
aria-label={t("tabs.here", { names: others.map((u) => u.name).join(", ") })}
>
{others.slice(0, 3).map(({ id, name }) => (
<span
key={id}
aria-hidden
className="flex h-4 w-4 items-center justify-center rounded-full border border-surface-1 bg-accent-solid text-[9px] font-semibold uppercase text-white"
>
{name.slice(0, 1)}
</span>
))}
</span>
)}
{state && <RunDot state={state} />}
</button>
{tab.runs.some(canSleep) && (
@@ -0,0 +1,79 @@
/**
* @file ConsoleTabs.presence.test.tsx
* @description Workspace sharing phase 4 on the tab bar: a tab shows who ELSE
* has its panes open (never the viewer), and its tooltip says whose money a
* pane a collaborator opened is.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import "../../../i18n";
import type { RunHandle } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
import { pane, type PaneGroups } from "../../../lib/paneLayout";
import { ConsoleTabs } from "../ConsoleTabs";
vi.mock("../../../lib/api", () => ({
api: {
aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
},
},
}));
vi.mock("../../AuthGate", () => ({
useAuth: () => ({ state: { mode: "server", user: { id: 1, username: "alice" } } }),
usePermissions: () => ({ local: false, isAdmin: false }),
}));
vi.mock("../../../hooks/usePanePresence", () => ({
usePanePresence: () => ({
"agenthub-lane-1": [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
}),
}));
const LANE = { id: 1, tool: "claude", cwd: "/w", title: "w" } as unknown as Lane;
const RUN: RunHandle = {
id: "agenthub-lane-1",
laneId: 1,
slot: 0,
status: "running",
cwd: "/w",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
startedBy: { id: 2, name: "Bob" },
tool: "claude",
};
const GROUPS: PaneGroups = { groups: [{ root: pane(0), focus: 0 }], active: 0 };
describe("ConsoleTabs presence", () => {
it("shows who else is in a pane, and whose money it is", () => {
render(
<MemoryRouter>
<ConsoleTabs
lane={LANE}
tabs={[RUN]}
groups={GROUPS}
onGroupsChange={vi.fn()}
onOpen={vi.fn()}
onClosed={vi.fn()}
/>
</MemoryRouter>
);
const people = screen.getAllByTestId(/^console-tab-people-/)[0]!;
expect(people.textContent).toBe("B");
expect(people.getAttribute("aria-label")).toBe("Also here: Bob");
const tab = screen.getAllByRole("tab")[0]!;
expect(tab.getAttribute("title")).toContain("Billed to Bob");
expect(tab.getAttribute("title")).not.toContain("Alice");
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* @file usePanePresence.ts
* @description Who has each of a workspace's panes open (server mode, workspace
* sharing phase 4): `GET /api/lanes/:id/presence` once, then every
* `pane_presence` frame for that workspace, and a re-read on a WebSocket
* reconnect (frames missed while away are never replayed). Local mode has
* one user and reads nothing. Returns `{runId: [{id, name}]}`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { usePermissions } from "../components/AuthGate";
export type PanePeople = Record<string, { id: number; name: string }[]>;
export function usePanePresence(laneId: number | null | undefined): PanePeople {
const { local } = usePermissions();
const [panes, setPanes] = useState<PanePeople>({});
useEffect(() => {
setPanes({});
if (local || laneId == null) return;
let alive = true;
const load = () =>
Promise.resolve()
.then(() => api.lanes.presence(laneId))
.then((r) => alive && setPanes(r.panes))
.catch(() => {
/* presence is a nicety: no list, no chips */
});
void load();
const offMsg = eventBus.subscribe((msg) => {
if (msg.type !== "pane_presence") return;
const d = msg.data as { laneId?: number; runId?: string; users?: PanePeople[string] };
if (d.laneId !== laneId || !d.runId) return;
setPanes((prev) => {
const next = { ...prev };
if (d.users?.length) next[d.runId!] = d.users;
else delete next[d.runId!];
return next;
});
});
const offConn = eventBus.onConnection((up) => {
if (up) void load();
});
return () => {
alive = false;
offMsg();
offConn();
};
}, [laneId, local]);
return panes;
}
+2
View File
@@ -409,6 +409,8 @@
"share.sessionWhat": "People you share it with see this session and the workspace's changes, join its open tab, type into it, send it messages (on their own account, billed to them) and commit — nothing else of the workspace. Only you share it. On this server they work as YOUR account inside its terminal, so they can still reach the rest of your home from there.",
"sessionMenu.share": "Share…",
"shared.sessionOnly": "Only a session of this workspace is shared with you: open it from the list to work in it.",
"tabs.billedTo": "Billed to {{name}}",
"tabs.here": "Also here: {{names}}",
"shared.badge": "Shared with you",
"panel.changes": "Changes",
"panel.analyst": "Analyst",
+2
View File
@@ -409,6 +409,8 @@
"share.sessionWhat": "Người được chia sẻ thấy session này và các thay đổi của workspace, vào tab đang mở, gõ, gửi tin nhắn cho nó (bằng tài khoản của họ, tính tiền cho họ) và commit — không gì khác của workspace. Chỉ bạn chia sẻ được. Trên server này họ làm việc bằng TÀI KHOẢN CỦA BẠN trong terminal của nó, nên từ đó họ vẫn với tới được phần còn lại trong home của bạn.",
"sessionMenu.share": "Chia sẻ…",
"shared.sessionOnly": "Chỉ một session của workspace này được chia sẻ với bạn: mở nó từ danh sách để làm việc.",
"tabs.billedTo": "Tính tiền cho {{name}}",
"tabs.here": "Cũng đang mở: {{names}}",
"shared.badge": "Được chia sẻ với bạn",
"panel.changes": "Thay đổi",
"panel.analyst": "Analyst",
+8
View File
@@ -5113,6 +5113,11 @@ export const api = {
method: "PUT",
body: JSON.stringify({ userIds }),
}),
/** GET /api/lanes/:id/presence — who has which pane open (server mode). */
presence: (id: number) =>
request<{ panes: Record<string, { id: number; name: string }[]> }>(
`/workspaces/${id}/presence`
),
/** GET /api/lanes/:id/sessions/:sid/shares — the users one session is shared with. */
sessionShares: (id: number, sid: string) =>
request<{ shares: LaneShare[]; canManage: boolean }>(
@@ -6525,6 +6530,9 @@ export interface RunHandle {
startedAt: string | null;
/** Initial prompt preview (first 500 chars), null when not provided. */
promptPreview: string | null;
/** Server mode: a user the workspace is shared with opened it — they pay
* for it. Null/absent = the workspace's owner. */
startedBy?: { id: number; name: string } | null;
tool: LaneTool;
}
+2
View File
@@ -1944,6 +1944,8 @@ export interface WSMessage {
| "lane_update"
/** A workspace was shared with / taken back from the users named (`userIds`). */
| "lane_access"
/** Who has a pane open changed: `{laneId, runId, users}` (pane-presence.js). */
| "pane_presence"
/** One output line from a lane's profile hook, while it runs. */
| "lane_hook_output"
/** A lane's stack finished coming up (or failed to), with fresh facts. */