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) => -
+ {initials(person.name)}
+ {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; }