// Primus IQ — Project Detail Screen
const PROJECT_FILES_PAGE_SIZE = 50; // rows rendered per "page" — windowed like Library so a big project doesn't mount every file at once
const ScreenProjectDetail = ({ setRoute, onStartChat, chatSessions = [], onOpenChat, openTagging, openSharePoint, openTextInput, openUrlInput, openInvite, openEditProject, onDeleteProject, uploadedFiles, removeFile, downloadFile, editFile, viewFile, activeProject, projectMembers, maintenance = false }) => {
const [dragHover, setDragHover] = React.useState(false);
const [pendingCount, setPendingCount] = React.useState(0);
const [tab, setTab] = React.useState('chats'); // 'chats' | 'sources'
const [composerVal, setComposerVal] = React.useState('');
const [composerFocused, setComposerFocused] = React.useState(false);
const [composerFile, setComposerFile] = React.useState(null); // doc attached to the launching chat
const composerFileRef = React.useRef(null);
const [filter, setFilter] = React.useState('');
const [visibleFiles, setVisibleFiles] = React.useState(PROJECT_FILES_PAGE_SIZE);
const [menuOpen, setMenuOpen] = React.useState(false); // project actions dropdown
const menuRef = React.useRef(null);
React.useEffect(() => {
if (!menuOpen) return;
const handler = (e) => { if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [menuOpen]);
const projectName = activeProject?.name || 'Project';
const projectPractice = activeProject?.practice || '';
const fq = filter.trim().toLowerCase();
// Real chat sessions scoped to this project (single source of truth = app sidebar list).
const projectChats = chatSessions.filter(s => s.project_id === activeProject?.id);
const filteredChats = fq ? projectChats.filter(c => (c.title || '').toLowerCase().includes(fq)) : projectChats;
const filteredFiles = fq ? uploadedFiles.filter(f => (f.name || '').toLowerCase().includes(fq)) : uploadedFiles;
// Windowed rendering — reset to the first page whenever the file search changes.
React.useEffect(() => { setVisibleFiles(PROJECT_FILES_PAGE_SIZE); }, [fq]);
const shownFiles = filteredFiles.slice(0, visibleFiles);
const hasMoreFiles = filteredFiles.length > visibleFiles;
React.useEffect(() => {
if (!activeProject?.id) return;
PrimusAPI.listJoinRequests(activeProject.id)
.then(data => setPendingCount((data || []).length))
.catch(() => setPendingCount(0));
}, [activeProject?.id]);
const submitComposer = () => {
if (maintenance) return; // chat is greyed out in maintenance mode
const q = composerVal.trim();
if (!q) return;
const file = composerFile;
setComposerVal('');
setComposerFile(null);
// Open a real, project-scoped chat in the main chat screen (sends project_id) and
// carry any attached doc so it's saved to that new session as chat context.
onStartChat && onStartChat(activeProject?.id, q, file);
};
const canSend = !maintenance && !!composerVal.trim();
return (
{/* Crumb */}
/
{projectName}
{/* Header */}
{projectName}
{projectPractice && {projectPractice}}
· {uploadedFiles.length} files
{projectMembers && projectMembers.length > 0 && (
{projectMembers.slice(0, 3).map((m, i) => (
{(m.full_name || '?')[0].toUpperCase()}
))}
{projectMembers.length > 3 && (
+{projectMembers.length - 3}
)}
)}
{menuOpen && (
)}
{/* TOP ROW: New chat composer (left) + Add sources (right) — sized to content */}
{/* LEFT: New chat in
*/}
New chat in {projectName}
Ask anything — this project's files are referenced automatically.
{['Summarize the latest doc', 'Compare these proposals', 'Draft a status memo'].map(s => (
))}
{maintenance && (
Chat is coming soon — temporarily unavailable.
)}
{/* RIGHT: dropzone + source quick-buttons */}
Add to this project
Upload or connect a source. You'll set confidentiality and practice next.
Or add from
} label="Upload" onClick={() => {
const inp = document.createElement('input');
inp.type = 'file'; inp.multiple = true;
inp.onchange = e => openTagging(Array.from(e.target.files));
inp.click();
}}/>
} label="Text input" onClick={() => openTextInput && openTextInput()}/>
} label="URL" onClick={() => openUrlInput && openUrlInput()}/>
} label="SharePoint" onClick={() => openSharePoint && openSharePoint()}/>
{/* TABS + LIST PANEL — bounded height, scrolls internally */}
setTab('chats')} count={projectChats.length}>Chats
setTab('sources')} count={uploadedFiles.length}>Sources
{tab === 'chats' ? (
{filteredChats.length === 0 ? (
{projectChats.length === 0 ? 'No chats yet' : 'No chats match your search'}
{projectChats.length === 0
? <>Start one from the composer above — chats in {projectName} live here.>
: <>Try a different term.>}
) : filteredChats.map(c => (
))}
) : (
{uploadedFiles.length === 0 ? (
No files yet
Drop or pick a source on the right to begin.
) : filteredFiles.length === 0 ? (
No files match your search
Try a different term.
) : shownFiles.map(f => (
removeFile(f.id)}
onDownload={downloadFile ? () => downloadFile(f.id) : null}
onEdit={editFile ? () => editFile(f) : null}
onView={viewFile ? () => viewFile(f) : null}
/>
))}
{hasMoreFiles && (
)}
{fq ? `${filteredFiles.length} of ${uploadedFiles.length}` : uploadedFiles.length} files in this project
)}
);
};
const panelCard = {
background: 'var(--bg-card)', border: '1px solid var(--line)', borderRadius: 16,
display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0,
};
const panelHeader = {
padding: '14px 20px', borderBottom: '1px solid var(--line)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
};
// ── Project Detail — supporting bits ──────────────────────────────────────────
const PdTab = ({ active, onClick, children, count }) => (
);
const pdComposerIconBtn = {
width: 28, height: 28, borderRadius: 7,
background: 'transparent', border: '1px solid var(--line-2)',
cursor: 'pointer', display: 'grid', placeItems: 'center',
};
const pdComposerChip = {
height: 28, padding: '0 10px', borderRadius: 7,
background: 'transparent', border: '1px solid var(--line-2)',
cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6,
fontSize: 12, color: 'var(--ink-2)', fontFamily: 'inherit', fontWeight: 500,
};
const pdChip = {
padding: '6px 12px', borderRadius: 999,
background: 'var(--bg-card)', border: '1px solid var(--line)',
fontSize: 12, color: 'var(--ink-2)', fontFamily: 'inherit', fontWeight: 500,
cursor: 'pointer',
};
const pdChatRow = {
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 14px', borderRadius: 10,
background: 'transparent', border: 'none', cursor: 'pointer',
width: '100%', fontFamily: 'inherit',
transition: 'background 0.15s',
};
// ── Full-screen chat view, opened from inside a project ──────────────────────
const ProjectChatView = ({ chat, projectName, onBack, onSend }) => {
const [val, setVal] = React.useState('');
const [focused, setFocused] = React.useState(false);
const scrollRef = React.useRef(null);
React.useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [chat?.messages?.length]);
const send = () => {
const t = val.trim();
if (!t) return;
onSend(t);
setVal('');
};
return (
{/* Header: back arrow + project crumb signalling the chat origin */}
{/* Messages */}
{(chat.messages || []).map((m, i) => (
{m.text}
))}
{(chat.messages || []).length === 1 && (
Primus IQ is offline in this preview — responses won't generate. UI shell only.
)}
{/* Composer */}
);
};
const FileRow = ({ f, onRemove, onDownload, onEdit, onView }) => {
const [menuOpen, setMenuOpen] = React.useState(false);
const menuRef = React.useRef(null);
React.useEffect(() => {
if (!menuOpen) return;
const handler = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [menuOpen]);
return (
onView && onView()} style={{
padding: '10px 12px', borderRadius: 10, display: 'flex', alignItems: 'center', gap: 12,
cursor: onView ? 'pointer' : 'default', transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-soft)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
{f.name}
{f.size}
·
{f.added || 'just now'}
{f.status && (
<>
·
{
f.status === 'graph_complete' ? 'Published to AI'
: f.status === 'dirty_ready' ? 'AI Search Ready'
: f.status === 'complete' ? 'File Saved'
: f.status === 'failed' ? 'Save Failed'
: f.status
}
>
)}
{f.conf &&
{CONF_LABELS[f.conf] || f.conf} }
{menuOpen && (
{onDownload && (
)}
{onEdit && (
)}
{onRemove && (
)}
)}
);
};
const crumbBtn = {
background: 'transparent', border: 'none', cursor: 'pointer',
fontSize: 13, fontFamily: 'inherit', color: 'var(--mute)', padding: 0,
};
Object.assign(window, {
ScreenProjectDetail,
ProjectChatView,
FileRow,
crumbBtn,
});