Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions apps/web/src/pages/WorkspacePage.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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: () => <div>PDF test preview</div> }));
vi.mock('../components/Modal', () => ({ Modal: ({ open, children }: { open: boolean; children: import('react').ReactNode }) => open ? <div role="dialog">{children}</div> : null }));
vi.mock('../components/Modal', () => ({ Modal: ({ open, title, onClose, children }: { open: boolean; title: string; onClose: () => void; children: import('react').ReactNode }) => open ? <div role="dialog" aria-label={title}><button onClick={onClose}>Close dialog</button>{children}</div> : null }));
vi.mock('../components/WorkspaceDialogs', async (importOriginal) => {
const { NewFileDialog, FileActionsDialog } = await importOriginal<typeof import('../components/WorkspaceDialogs')>();
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<void>) | null) => void }) {
return { ManuscriptPane: function Pane({ onFlushReady, onPresence }: { onFlushReady: (flush: (() => Promise<void>) | null) => void; onPresence: NonNullable<typeof harness.onPresence> }) {
useEffect(() => { harness.onPresence = onPresence; }, [onPresence]);
useEffect(() => { onFlushReady(() => harness.flush()); return () => onFlushReady(null); }, [onFlushReady]);
return <div>Manuscript test editor</div>;
} };
Expand Down Expand Up @@ -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();
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/pages/WorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ function ProjectWorkspace({ hash }: { hash: string }) {
const [pdfError, setPdfError] = useState<string | null>(null);
const [activity, setActivity] = useState<string | null>(null);
const [presence, setPresence] = useState<PresenceUser[]>([]);
const [presenceOpen, setPresenceOpen] = useState(false);
const [historyKey, setHistoryKey] = useState(0);
const [treeOpen, setTreeOpen] = useState(loadTreeOpen);
const [mobilePane, setMobilePane] = useState<'files' | 'editor' | 'pdf'>('editor');
Expand Down Expand Up @@ -399,7 +400,11 @@ function ProjectWorkspace({ hash }: { hash: string }) {
<button className="icon-button workspace-back" type="button" onClick={() => navigate('/')} aria-label="Back to projects"><ArrowLeft size={17}/></button>
<Brand subtitle={project.name} compact/>
<div className="project-crumb"><span>{project.name}</span><small>{project.role.toUpperCase()}</small></div>
<div className="presence-stack" aria-label="Active collaborators">{presence.slice(0, 4).map((person) => <span key={person.clientId} className="presence-avatar" style={{ '--person-color': person.color } as React.CSSProperties} title={person.name}>{initials(person.name)}</span>)}{presence.length > 4 && <span className="presence-more">+{presence.length - 4}</span>}</div>
<button className="presence-trigger" type="button" aria-label={`Who's online (${presence.length})`} aria-haspopup="dialog" onClick={() => setPresenceOpen(true)}>
<span className="presence-stack" aria-hidden="true">{presence.slice(0, 4).map((person) => <span key={person.clientId} className="presence-avatar" style={{ '--person-color': person.color } as React.CSSProperties} title={person.name}>{initials(person.name)}</span>)}{presence.length > 4 && <span className="presence-more">+{presence.length - 4}</span>}{presence.length === 0 && <Users size={17}/>}</span>
<Users className="presence-mobile-icon" size={17} aria-hidden="true"/>
<span aria-hidden="true">{presence.length}<span className="presence-online-label"> online</span></span>
</button>
{activity && <div className="live-activity"><Bot size={13}/>{activity}</div>}
<div className="workspace-actions">
<button className="plain-button workspace-action-text" type="button" aria-label="History" title="History" onClick={() => setHistoryOpen(true)}><History size={14}/> History</button>
Expand Down Expand Up @@ -453,6 +458,12 @@ function ProjectWorkspace({ hash }: { hash: string }) {
{project.canManage && <ProjectSettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} api={api} project={project} onChanged={setProject} onDeleted={() => navigate('/')}/>}
<HistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} api={api} projectHash={hash} file={editorMode === 'manuscript' ? files.find((file) => file.path === sourceLocation?.path) ?? selected : selected} canWrite={project.canWrite} refreshKey={historyKey}/>
<CompileLogDrawer open={logOpen} onClose={() => setLogOpen(false)} status={job?.status ?? 'Not compiled'} log={job?.log ?? ''} error={job?.error ?? null}/>
<Modal open={presenceOpen} onClose={() => setPresenceOpen(false)} title="Who's online" size="small">
{presence.length ? <ul className="presence-list" aria-label="Online collaborators">{presence.map((person) => <li key={person.clientId}>
<span className="presence-avatar" style={{ '--person-color': person.color } as React.CSSProperties} aria-hidden="true">{initials(person.name)}</span>
<span>{person.name}</span>
</li>)}</ul> : <p>No active collaborators.</p>}
</Modal>
<Modal open={mobileMenuOpen} onClose={() => setMobileMenuOpen(false)} title={project.name} eyebrow="Workspace actions" size="small"><div className="mobile-action-list">
<button type="button" onClick={() => { setMobileMenuOpen(false); setHistoryOpen(true); }}><History size={16}/><span><strong>History</strong><small>Review and restore file revisions</small></span></button>
<button type="button" onClick={() => { setMobileMenuOpen(false); setShareOpen(true); }}><Users size={16}/><span><strong>Share</strong><small>Manage project collaborators</small></span></button>
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down Expand Up @@ -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; }
Expand Down
Loading