From 4ed9b69aab9c98c301f517bfa843b034be17bf8f Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Wed, 30 Sep 2026 22:46:40 +0800 Subject: [PATCH] fix(session-sharing): show participant presence in member terminals (#1504) --- .../src/frontend/ParticipantsOverlay.tsx | 41 +++++++++++++++++ .../src/frontend/SharedSessionView.tsx | 2 +- .../session-sharing/src/frontend/index.tsx | 10 ++++ .../tests/frontend/activate.test.tsx | 46 +++++++++++++++++++ .../src/backend/session-manager.ts | 12 +++-- .../tests/backend/session-manager.test.ts | 8 ++++ 6 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 plugins/session-sharing/src/frontend/ParticipantsOverlay.tsx diff --git a/plugins/session-sharing/src/frontend/ParticipantsOverlay.tsx b/plugins/session-sharing/src/frontend/ParticipantsOverlay.tsx new file mode 100644 index 000000000..751b46237 --- /dev/null +++ b/plugins/session-sharing/src/frontend/ParticipantsOverlay.tsx @@ -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( + [], + ); + + 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 ( + + ); +} diff --git a/plugins/session-sharing/src/frontend/SharedSessionView.tsx b/plugins/session-sharing/src/frontend/SharedSessionView.tsx index b8164e64c..329492583 100644 --- a/plugins/session-sharing/src/frontend/SharedSessionView.tsx +++ b/plugins/session-sharing/src/frontend/SharedSessionView.tsx @@ -33,7 +33,7 @@ export interface SessionParticipantInfo { label: string | null; } -function ParticipantsBadge({ +export function ParticipantsBadge({ participants, ownerLabel, }: { diff --git a/plugins/session-sharing/src/frontend/index.tsx b/plugins/session-sharing/src/frontend/index.tsx index da3715229..523985cb6 100644 --- a/plugins/session-sharing/src/frontend/index.tsx +++ b/plugins/session-sharing/src/frontend/index.tsx @@ -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 + >, + }); + app.registerSlotContribution("shell.overlay", { actionId: "session-sharing.shareDialog", titleKey: "sessionSharing.shareButton", diff --git a/plugins/session-sharing/tests/frontend/activate.test.tsx b/plugins/session-sharing/tests/frontend/activate.test.tsx index c5cd8df5a..21d204d55 100644 --- a/plugins/session-sharing/tests/frontend/activate.test.tsx +++ b/plugins/session-sharing/tests/frontend/activate.test.tsx @@ -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, diff --git a/plugins/ssh-terminal/src/backend/session-manager.ts b/plugins/ssh-terminal/src/backend/session-manager.ts index 3fcc7d21d..e3c6e0d92 100644 --- a/plugins/ssh-terminal/src/backend/session-manager.ts +++ b/plugins/ssh-terminal/src/backend/session-manager.ts @@ -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, diff --git a/plugins/ssh-terminal/tests/backend/session-manager.test.ts b/plugins/ssh-terminal/tests/backend/session-manager.test.ts index eb35d7216..75428987d 100644 --- a/plugins/ssh-terminal/tests/backend/session-manager.test.ts +++ b/plugins/ssh-terminal/tests/backend/session-manager.test.ts @@ -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).mock.lastCall![0] as string, + ), + ).toMatchObject({ + type: "participants", + participants: [{ isOwner: true }], + }); sessionManager.destroySession(id); });