From 8a9af31d6c9f8a74dfe8bddb83270f4a646c281a Mon Sep 17 00:00:00 2001 From: DebelToni Date: Sat, 31 Oct 2026 10:11:36 +0200 Subject: [PATCH] Make online collaborators visible on tap and click --- apps/web/src/pages/WorkspacePage.test.tsx | 48 +++++++++++++++++++++-- apps/web/src/pages/WorkspacePage.tsx | 13 +++++- apps/web/src/styles.css | 10 +++++ 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/WorkspacePage.test.tsx b/apps/web/src/pages/WorkspacePage.test.tsx index c1cf7d3..981791b 100644 --- a/apps/web/src/pages/WorkspacePage.test.tsx +++ b/apps/web/src/pages/WorkspacePage.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { WorkspacePage } from './WorkspacePage'; @@ -7,20 +7,22 @@ const harness = vi.hoisted(() => ({ api: { getProject: vi.fn(), listFiles: vi.fn(), latestCompile: vi.fn(), getFile: vi.fn(), createFile: vi.fn(), renameFile: vi.fn(), compile: vi.fn() }, flush: vi.fn(async () => {}), user: { id: 'writer', username: 'writer', displayName: 'Writer', isAdmin: false, createdAt: '' }, + onPresence: null as ((users: { clientId: number; name: string; color: string }[]) => void) | null, onEvent: null as ((event: { type: string; fileId: string }) => void) | null })); vi.mock('../lib/session', () => ({ useSession: () => ({ api: harness.api, user: harness.user, clearSession: vi.fn() }) })); vi.mock('../hooks/useTheme', () => ({ useTheme: () => ({ preference: 'light', resolved: 'light', setPreference: vi.fn() }) })); vi.mock('../hooks/useProjectEvents', () => ({ useProjectEvents: (_api: unknown, _hash: string, handler: typeof harness.onEvent) => { harness.onEvent = handler; } })); vi.mock('../components/PdfViewer', () => ({ PdfViewer: () =>
PDF test preview
})); -vi.mock('../components/Modal', () => ({ Modal: ({ open, children }: { open: boolean; children: import('react').ReactNode }) => open ?
{children}
: null })); +vi.mock('../components/Modal', () => ({ Modal: ({ open, title, onClose, children }: { open: boolean; title: string; onClose: () => void; children: import('react').ReactNode }) => open ?
{children}
: null })); vi.mock('../components/WorkspaceDialogs', async (importOriginal) => { const { NewFileDialog, FileActionsDialog } = await importOriginal(); return { AgentDialog: () => null, CompileLogDrawer: () => null, FileActionsDialog, HistoryDrawer: () => null, NewFileDialog, ProjectSettingsDialog: () => null, ShareDialog: () => null }; }); vi.mock('../components/ManuscriptPane', async () => { const { useEffect } = await import('react'); - return { ManuscriptPane: function Pane({ onFlushReady }: { onFlushReady: (flush: (() => Promise) | null) => void }) { + return { ManuscriptPane: function Pane({ onFlushReady, onPresence }: { onFlushReady: (flush: (() => Promise) | null) => void; onPresence: NonNullable }) { + useEffect(() => { harness.onPresence = onPresence; }, [onPresence]); useEffect(() => { onFlushReady(() => harness.flush()); return () => onFlushReady(null); }, [onFlushReady]); return
Manuscript test editor
; } }; @@ -72,6 +74,46 @@ function startFileMutation(operation: string, path: string, content: string, ren } } +describe('online collaborators', () => { + const people = ['Alice Smith', 'Alice Stone', 'Bo', 'Charlie', 'Dana', 'Eli'].map((name, clientId) => ({ clientId, name, color: '#2583e9' })); + + it('opens every full name on click, including matching initials and overflow avatars', async () => { + await openWorkspace(); + act(() => harness.onPresence!(people)); + const trigger = screen.getByRole('button', { name: "Who's online (6)" }); + expect(trigger).toHaveAttribute('aria-haspopup', 'dialog'); + expect(trigger).toHaveTextContent('+2'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(trigger); + const list = screen.getByRole('list', { name: 'Online collaborators' }); + expect(within(list).getAllByRole('listitem')).toHaveLength(6); + for (const person of people) expect(within(list).getByText(person.name)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Close dialog' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('updates the open list as collaborators join and leave', async () => { + await openWorkspace(); + act(() => harness.onPresence!(people.slice(0, 1))); + fireEvent.click(screen.getByRole('button', { name: "Who's online (1)" })); + act(() => harness.onPresence!(people.slice(1, 3))); + const list = screen.getByRole('list', { name: 'Online collaborators' }); + expect(within(list).queryByText('Alice Smith')).not.toBeInTheDocument(); + expect(within(list).getByText('Alice Stone')).toBeInTheDocument(); + expect(within(list).getByText('Bo')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: "Who's online (2)" })).toBeInTheDocument(); + act(() => harness.onPresence!([])); + expect(screen.getByText('No active collaborators.')).toBeInTheDocument(); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + }); + + it('can open the empty list before presence arrives', async () => { + await openWorkspace(); + fireEvent.click(screen.getByRole('button', { name: "Who's online (0)" })); + expect(screen.getByRole('dialog', { name: "Who's online" })).toHaveTextContent('No active collaborators.'); + }); +}); + it('compiles the open file rather than the stored project default', async () => { harness.api.compile.mockResolvedValue({ job: { id: 'job', status: 'queued', createdAt: new Date().toISOString() } }); await openWorkspace(); diff --git a/apps/web/src/pages/WorkspacePage.tsx b/apps/web/src/pages/WorkspacePage.tsx index 26acb19..11ebefe 100644 --- a/apps/web/src/pages/WorkspacePage.tsx +++ b/apps/web/src/pages/WorkspacePage.tsx @@ -56,6 +56,7 @@ function ProjectWorkspace({ hash }: { hash: string }) { const [pdfError, setPdfError] = useState(null); const [activity, setActivity] = useState(null); const [presence, setPresence] = useState([]); + const [presenceOpen, setPresenceOpen] = useState(false); const [historyKey, setHistoryKey] = useState(0); const [treeOpen, setTreeOpen] = useState(loadTreeOpen); const [mobilePane, setMobilePane] = useState<'files' | 'editor' | 'pdf'>('editor'); @@ -399,7 +400,11 @@ function ProjectWorkspace({ hash }: { hash: string }) {
{project.name}{project.role.toUpperCase()}
-
{presence.slice(0, 4).map((person) => {initials(person.name)})}{presence.length > 4 && +{presence.length - 4}}
+ {activity &&
{activity}
}
@@ -453,6 +458,12 @@ function ProjectWorkspace({ hash }: { hash: string }) { {project.canManage && setSettingsOpen(false)} api={api} project={project} onChanged={setProject} onDeleted={() => navigate('/')}/>} setHistoryOpen(false)} api={api} projectHash={hash} file={editorMode === 'manuscript' ? files.find((file) => file.path === sourceLocation?.path) ?? selected : selected} canWrite={project.canWrite} refreshKey={historyKey}/> setLogOpen(false)} status={job?.status ?? 'Not compiled'} log={job?.log ?? ''} error={job?.error ?? null}/> + setPresenceOpen(false)} title="Who's online" size="small"> + {presence.length ?
    {presence.map((person) =>
  • + + {person.name} +
  • )}
:

No active collaborators.

} +
setMobileMenuOpen(false)} title={project.name} eyebrow="Workspace actions" size="small">
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index a155ab0..31405c7 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -252,9 +252,16 @@ button:disabled { cursor: default; opacity: .42; } .project-crumb span { font-size: 13px; font-weight: 600; } .project-crumb small { margin-top: 4px; color: var(--muted); font: 650 11px/1 var(--mono); letter-spacing: .06em; } .workspace-actions { min-width: 0; margin-left: auto; display: flex; align-items: center; gap: 8px; } +.presence-trigger { min-height: 44px; flex-shrink: 0; display: flex; align-items: center; gap: 7px; padding: 0 6px; border: 0; background: transparent; color: var(--muted); font-size: 12px; cursor: pointer; } +.presence-trigger:hover { background: var(--surface); color: var(--ink); } +.presence-mobile-icon { display: none; } .presence-stack { display: flex; align-items: center; padding-left: 8px; } .presence-avatar, .presence-more { width: 28px; height: 28px; display: grid; place-items: center; margin-left: -7px; border: 2px solid var(--paper); border-radius: 50%; background: var(--person-color, var(--surface-2)); color: white; font: 700 11px/1 var(--sans); } .presence-more { background: var(--surface-2); color: var(--muted); } +.presence-list { display: grid; gap: 16px; margin: 0; padding: 0; list-style: none; } +.presence-list li { min-width: 0; display: flex; align-items: center; gap: 12px; overflow-wrap: anywhere; } +.presence-list .presence-avatar { flex: 0 0 28px; margin: 0; } +.presence-list li > span:last-child { min-width: 0; } .live-activity { min-width: 0; max-width: 240px; display: flex; align-items: center; gap: 6px; overflow: hidden; color: var(--accent); font: 680 11px/1 var(--mono); text-overflow: ellipsis; white-space: nowrap; } .compile-progress { height: 2px; background: var(--surface-2); overflow: hidden; } .compile-progress i { display: block; width: 0; height: 100%; background: var(--accent); } @@ -443,6 +450,9 @@ button:disabled { cursor: default; opacity: .42; } .workspace-head .project-crumb span { font-size: 12px; } .workspace-head .project-crumb small { font-size: 10px; } .workspace-actions { gap: 4px; } + .presence-trigger { min-width: 44px; gap: 4px; } + .presence-mobile-icon { display: block; } + .presence-online-label { display: none; } .compile-button { min-width: 90px; height: 44px; min-height: 44px; } .mobile-tabs { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--line); } .mobile-tabs button { position: relative; display: flex; align-items: center; justify-content: center; gap: 6px; border: 0; border-radius: 0; background: var(--paper); color: var(--muted); font-size: 11px; font-weight: 700; }