fix(session-sharing): show participant presence in member terminals (#1504)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-30 09:46:40 -05:00
1 parent 0f6e48a95c
commit 4ed9b69aab
6 files changed
+114 -5

No files matched your search

@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
ParticipantsBadge,
type SessionParticipantInfo,
} from "./SharedSessionView";
interface ParticipantsOverlayProps {
subscribe: (
listener: (message: { type: string; [key: string]: unknown }) => void,
) => () => void;
}
export function ParticipantsOverlay({ subscribe }: ParticipantsOverlayProps) {
const { t } = useTranslation();
const [participants, setParticipants] = useState<SessionParticipantInfo[]>(
[],
);
useEffect(
() =>
subscribe((message) => {
if (
message.type === "participants" &&
Array.isArray(message.participants)
) {
setParticipants(message.participants as SessionParticipantInfo[]);
} else if (message.type === "session_closed") {
setParticipants([]);
}
}),
[subscribe],
);
return (
<ParticipantsBadge
participants={participants}
ownerLabel={t("sessionSharing.guestView.ownerLabel")}
/>
);
}
@@ -33,7 +33,7 @@ export interface SessionParticipantInfo {
label: string | null;
}
function ParticipantsBadge({
export function ParticipantsBadge({
participants,
ownerLabel,
}: {
@@ -17,6 +17,7 @@ import CollabGuestView from "./CollabGuestView";
import { CollabPanel } from "./CollabPanel";
import { CollabRoomTab } from "./CollabRoomTab";
import SharedSessionView from "./SharedSessionView";
import { ParticipantsOverlay } from "./ParticipantsOverlay";
import {
ShareDialogHost,
closeShareDialog,
@@ -224,6 +225,15 @@ export function activate(app: TermixApp): void {
},
});
app.registerSlotContribution("terminal.overlay", {
actionId: "session-sharing.participants",
titleKey: "collab.members",
kind: "component",
component: ParticipantsOverlay as unknown as ComponentType<
Record<string, unknown>
>,
});
app.registerSlotContribution("shell.overlay", {
actionId: "session-sharing.shareDialog",
titleKey: "sessionSharing.shareButton",
@@ -75,6 +75,52 @@ describe("session-sharing activate", () => {
]);
});
it("shows terminal participants and clears them after the last guest leaves or the socket closes", async () => {
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: stubApi(),
});
expect(rendered.registered.slot("terminal.overlay")).toEqual([
"session-sharing.participants",
]);
let receive: (message: {
type: string;
[key: string]: unknown;
}) => void = () => {};
const unsubscribe = vi.fn();
rendered.renderSlot("terminal.overlay", {
subscribe: (listener: typeof receive) => {
receive = listener;
return unsubscribe;
},
});
const owner = { isOwner: true, permissionLevel: "read-write", label: null };
const guest = {
isOwner: false,
permissionLevel: "read-only",
label: "Guest 1",
};
await act(() =>
receive({ type: "participants", participants: [owner, guest] }),
);
expect(
screen.getByTitle(
`${locales.sessionSharing.guestView.ownerLabel}, Guest 1`,
).textContent,
).toBe("2");
await act(() => receive({ type: "participants", participants: [owner] }));
expect(screen.queryByText("2")).toBeNull();
await act(() =>
receive({ type: "participants", participants: [owner, guest] }),
);
await act(() => receive({ type: "session_closed" }));
expect(screen.queryByText("2")).toBeNull();
await rendered.deactivate();
expect(unsubscribe).toHaveBeenCalled();
rendered = null;
});
it("registers only the guest views on a guest page", async () => {
rendered = await renderWithApp(plugin, {
manifest,
@@ -471,7 +471,7 @@ export class TerminalSessionManager {
* leave, and only while someone besides the owner is (or just was) in the
* room - a solo owner never receives presence traffic.
*/
private broadcastParticipants(sessionId: string): void {
private broadcastParticipants(sessionId: string, includeSolo = false): void {
const session = this.sessions.get(sessionId);
if (!session) return;
const participants = Array.from(session.participants.values()).map(
@@ -481,7 +481,11 @@ export class TerminalSessionManager {
label: participant.displayName ?? participant.guestLabel ?? null,
}),
);
if (participants.every((participant) => participant.isOwner)) return;
if (
!includeSolo &&
participants.every((participant) => participant.isOwner)
)
return;
this.broadcast(sessionId, { type: "participants", participants });
}
@@ -545,7 +549,7 @@ export class TerminalSessionManager {
}
}
}
if (disconnected > 0) this.broadcastParticipants(sessionId);
if (disconnected > 0) this.broadcastParticipants(sessionId, true);
return disconnected;
}
@@ -585,7 +589,7 @@ export class TerminalSessionManager {
for (const [id, participant] of session.participants.entries()) {
if (participant.ws === ws && !participant.isOwner) {
session.participants.delete(id);
this.broadcastParticipants(sessionId);
this.broadcastParticipants(sessionId, true);
this.log.info("Participant left shared session", {
operation: "session_leave_participant",
sessionId,
@@ -417,6 +417,14 @@ describe("TerminalSessionManager - multiplayer participants", () => {
expect(session?.detachTimeout).toBeNull();
expect(session?.participants.size).toBe(1);
expect(sessionManager.getParticipantForWs(session!, guestWs)).toBeNull();
expect(
JSON.parse(
(ownerWs.send as ReturnType<typeof vi.fn>).mock.lastCall![0] as string,
),
).toMatchObject({
type: "participants",
participants: [{ isOwner: true }],
});
sessionManager.destroySession(id);
});