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
13 changes: 4 additions & 9 deletions app/layouts/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,15 +133,10 @@ function PageLayoutInner({
{children}
</div>
) : (
/* Scrollable content area.
Mobile: top padding clears the fixed top bar (needed so content
can scroll under the hide-on-scroll bar).
Tablet+: top margin instead, so the scrollport starts below the bar.
When the EndowmentPromoBanner is visible above the TopBar on mobile
we add extra top padding to clear the banner + topbar stack.
The banner itself is hidden at >= 768px (tablet:!hidden) so the
offset is reset by the inner media query below.
Bottom padding on mobile clears the fixed MobileBottomNav. */
/* The scrollport. Padding clears the fixed top bar on phones, where it
hides on scroll; a margin does from the tablet breakpoint up. The
`page-layout-*` classes reserve room for the promo banner and the
bottom nav where those show. */
<div
ref={scrollContainerRef}
className={cn(
Expand Down
11 changes: 10 additions & 1 deletion app/layouts/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,13 @@ export function TopBar({ onMenuClick }: TopBarProps) {
// default breadcrumb.
const topBarSlot = useTopBarSlot();
const leftSlot = topBarSlot?.leftSlot;
// Or keep the back arrow and name what the page has open instead of the page.
const openTitle = topBarSlot?.title ?? null;
const leading = topBarSlot?.leading;

const pageInfo = getPageInfo(pathname);
const routePageInfo = getPageInfo(pathname);
const pageInfo =
routePageInfo && openTitle != null ? { ...routePageInfo, title: openTitle } : routePageInfo;
const showBackButton = pageInfo && !isRootNavigationPage(pathname);

const profilePercent = useCallback(() => {
Expand Down Expand Up @@ -105,10 +110,14 @@ export function TopBar({ onMenuClick }: TopBarProps) {

{showBackButton && <TopBarBackButton onClick={goBack} variant="desktop" />}

{leading}

{pageInfo && (
<TopBarBreadcrumb
pageInfo={showTopBarFundTabs ? { ...pageInfo, title: 'Fund' } : pageInfo}
variant="desktop"
truncateTitle={openTitle != null}
size={openTitle != null ? 'md' : 'lg'}
/>
)}
</>
Expand Down
9 changes: 8 additions & 1 deletion app/layouts/topbar/TopBarBreadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ interface TopBarBreadcrumbProps {
variant: 'mobile' | 'desktop';
/** Ellipsize a long title so sibling controls (e.g. sticky tab pills) stay visible. */
truncateTitle?: boolean;
/** `lg` (26px) for a page's own name; `md` (20px) for the longer title of what a page has open. */
size?: 'lg' | 'md';
}

export const TopBarBreadcrumb = ({
pageInfo,
variant,
truncateTitle = false,
size = 'lg',
}: TopBarBreadcrumbProps) => {
const isMobile = variant === 'mobile';

Expand All @@ -26,7 +29,11 @@ export const TopBarBreadcrumb = ({
truncateTitle ? 'min-w-0 truncate' : 'flex-shrink-0'
);

const titleStyle = isMobile ? undefined : { fontSize: '26px', letterSpacing: '-0.5px' };
const desktopTitleStyle =
size === 'md'
? { fontSize: '20px', letterSpacing: '-0.3px' }
: { fontSize: '26px', letterSpacing: '-0.5px' };
const titleStyle = isMobile ? undefined : desktopTitleStyle;

return (
<div className={containerClass}>
Expand Down
59 changes: 29 additions & 30 deletions components/AIMode/WorkspacePage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PanelRight } from 'lucide-react';
import { PanelRightOpen } from 'lucide-react';
import { PageLayout } from '@/app/layouts/PageLayout';
import { cn } from '@/utils/styles';
import { useMediaQuery } from '@/hooks/useMediaQuery';
Expand All @@ -12,17 +12,15 @@
import { conversationTitleFor } from './chat/conversationTitle';
import { DocumentPane, type DocumentPaneView } from './document/DocumentPane';
import { useAIModeDocument } from './document/useAIModeDocument';
import { AIModeHeader } from './shell/AIModeHeader';
import { WorkspacePanes } from './shell/WorkspacePanes';
import { WorkspaceTopBar } from './shell/WorkspaceTopBar';
import { WorkspaceSidebar } from './sidebar/WorkspaceSidebar';
import { useAIModeChat } from './useAIModeChat';
import { newConversationTitle } from './copy';
import { layoutFor } from './workspaceUrl';

const CHAT_MIN_WIDTH = 360;
const DOCUMENT_MIN_WIDTH = 420;
/** The details form needs more room than the document: authors, image, funding fields. */
const DETAILS_MIN_WIDTH = 560;
/** Share of the viewport the document opens at before the user drags it. */
const DOCUMENT_DEFAULT_SHARE = 0.55;

Expand Down Expand Up @@ -56,7 +54,7 @@
);
}

function Workspace() {

Check failure on line 57 in components/AIMode/WorkspacePage.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaEOoe1QSfSP5omTRyq8&open=AaEOoe1QSfSP5omTRyq8&pullRequest=1142
const state = useAIModeChat();
const { target } = state;
// A document opened from the sidebar comes first; a conversation's document sits beside it.
Expand All @@ -66,7 +64,7 @@
// being an icon rail (Tailwind's `sidebar-compact`). Below that they wait
// behind a button: a panel over the panes, or on a phone a screen of their own.
const listsInColumn = useMediaQuery('(min-width: 1240px)') === true;
// Tailwind's `tablet` breakpoint; the strip and the side-by-side panes only exist from it up.
// Tailwind's `tablet` breakpoint; the side-by-side panes only exist from it up.
const isBelowTablet = useMediaQuery('(max-width: 767px)') === true;
const [listOpen, setListOpen] = useState(false);
const closeList = useCallback(() => setListOpen(false), []);
Expand All @@ -84,13 +82,12 @@
// ---- pane widths: the side pane drags, the main pane takes the rest ----
const [panesEl, setPanesEl] = useState<HTMLDivElement | null>(null);
const panesWidth = useElementWidth(panesEl);
// Document or details in the document pane; details wants a wider floor.
// Document or details in the phone's drawer; the column only shows the document.
const [documentView, setDocumentView] = useState<DocumentPaneView>('document');
const documentMinWidth = documentView === 'details' ? DETAILS_MIN_WIDTH : DOCUMENT_MIN_WIDTH;
// The side pane is the document (chat first) or the chat (document first);
// it may grow until the main pane is down to its own minimum column.
const sideIsDocument = layout === 'chat';
const sideMinWidth = sideIsDocument ? documentMinWidth : CHAT_MIN_WIDTH;
const sideMinWidth = sideIsDocument ? DOCUMENT_MIN_WIDTH : CHAT_MIN_WIDTH;
const sideMaxWidth = Math.max(
sideMinWidth,
panesWidth - (sideIsDocument ? CHAT_MIN_WIDTH : DOCUMENT_MIN_WIDTH)
Expand Down Expand Up @@ -121,10 +118,16 @@
const openDocument = useCallback(() => setDocumentOpen(true), []);
const closeDocument = useCallback(() => setDocumentOpen(false), []);
const showDocument = noteId != null && documentOpen;
const documentTitle = doc.content?.title?.trim() || state.note?.title?.trim() || 'Document';
// The strip names what is open: the conversation when the chat is the main
// pane, the document when it is, and the new-conversation screen by what it
// is for — an RFP or a proposal.
// The Drafts list follows the open document's title as it is renamed.
const docTitle = doc.title;
const openNote = useMemo(
() => (noteId != null && docTitle ? { id: noteId, title: docTitle } : null),
[noteId, docTitle]
);
const documentTitle = doc.title || 'Document';
// The top bar names what is open: the conversation when the chat is the
// main pane, the document when it is, and the new-conversation screen by
// what it is for — an RFP or a proposal.
const { title: conversationTitle } = conversationTitleFor(state);
const headerTitle =
layout === 'document'
Expand All @@ -133,10 +136,6 @@
? conversationTitle
: newConversationTitle(state.intent);

// The document column puts its publish controls up in the strip, where
// they read as the workspace's, not the pane's. It renders into this slot.
const [publishControlsSlot, setPublishControlsSlot] = useState<HTMLDivElement | null>(null);

// The turn that created the document, for seating its card in the transcript.
const documentCardExecutionId = useMemo(() => {
if (noteId == null) return null;
Expand Down Expand Up @@ -170,7 +169,6 @@
view={documentView}
onViewChange={setDocumentView}
presentation={presentation}
publishControlsSlot={presentation === 'pane' ? publishControlsSlot : null}
readOnly={presentation === 'drawer'}
className={presentation === 'drawer' ? '-mx-4 -mt-2' : undefined}
/>
Expand All @@ -182,34 +180,35 @@
leftSidebarContent={
listsInColumn ? (
<div className="workspace-fade-in h-full">
<WorkspaceSidebar state={state} />
<WorkspaceSidebar state={state} openNote={openNote} />
</div>
) : null
}
>
<div ref={setPanesEl} className="workspace-fade-in flex min-h-0 flex-1 flex-col bg-gray-50">
{!isBelowTablet && (
<AIModeHeader
title={headerTitle}
publishControlsRef={setPublishControlsSlot}
listOpen={listOpen}
onToggleList={listsInColumn ? undefined : toggleList}
/>
)}
<WorkspaceTopBar
title={headerTitle}
listOpen={listOpen}
onToggleList={listsInColumn || isBelowTablet ? undefined : toggleList}
/>

<WorkspacePanes
layout={layout}
isBelowTablet={isBelowTablet}
sideWidth={{ ...sideWidth, min: sideMinWidth, max: sideMaxWidth }}
lists={listsInColumn ? null : <WorkspaceSidebar state={state} onNavigate={closeList} />}
lists={
listsInColumn ? null : (
<WorkspaceSidebar state={state} openNote={openNote} onNavigate={closeList} />
)
}
listOpen={listOpen}
onCloseList={closeList}
onCloseDocumentDrawer={closeDocument}
chat={
<ChatPane
state={state}
// From the tablet breakpoint up the strip names the chat when it is the main pane.
showTitle={isBelowTablet || layout !== 'chat'}
// The top bar names the chat when it is the main pane.
showTitle={layout !== 'chat'}
onOpenConversations={() => setListOpen(true)}
documentCard={documentCard}
documentCardExecutionId={documentCardExecutionId}
Expand All @@ -228,7 +227,7 @@
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-900'
)}
>
<PanelRight className="h-4 w-4" aria-hidden="true" />
<PanelRightOpen className="h-4 w-4" aria-hidden="true" />
</button>
)
}
Expand Down
10 changes: 5 additions & 5 deletions components/AIMode/chat/ChatPane.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { useEffect, useRef, useState, type ReactNode } from 'react';
import { PanelLeft } from 'lucide-react';
import { PanelLeftOpen } from 'lucide-react';
import { ChatComposer } from '@/components/AgentChat/ChatComposer';
import { ChatTranscript } from '@/components/AgentChat/ChatTranscript';
import { JumpToLatestButton } from '@/components/AgentChat/JumpToLatestButton';
Expand All @@ -28,8 +28,8 @@
interface ChatPaneProps {
readonly state: AIModeChatState;
/**
* Whether the pane's header names the chat. Not where the workspace's strip
* above already does: the chat as the main pane, from the tablet breakpoint up.
* Whether the pane's header names the chat. Not when the chat is the main
* pane: the app's top bar already names it there, at every width.
*/
readonly showTitle: boolean;
/** Header controls seated right of the title — the document toggle. */
Expand Down Expand Up @@ -161,7 +161,7 @@
aria-label="Show conversations and documents"
className="rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 tablet:!hidden"
>
<PanelLeft className="h-4 w-4" />
<PanelLeftOpen className="h-4 w-4" />
</button>
)}
{renaming && chatId != null ? (
Expand All @@ -175,16 +175,16 @@
if (next && next !== (currentTitle ?? '')) state.rename(chatId, next);
}}
/>
) : !showTitle ? (
// The strip names it; the header carries only the controls.
// The top bar names it; the header carries only the controls.
<span className="flex-1" />
) : titleLoading ? (
<div className="flex min-w-0 flex-1 items-center" aria-busy="true">
<div className="h-3.5 w-56 max-w-full animate-pulse rounded bg-gray-100" />
</div>
) : (
<h1 className="min-w-0 flex-1 truncate text-sm font-medium text-gray-800">{title}</h1>
)}

Check warning on line 187 in components/AIMode/chat/ChatPane.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaEOoe06SfSP5omTRyq7&open=AaEOoe06SfSP5omTRyq7&pullRequest=1142
{chatId != null && !renaming && (
<ConversationMenu
title={title}
Expand Down
5 changes: 2 additions & 3 deletions components/AIMode/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ import type { FundingIntent } from '@/components/Funding/fundingDirection';
export const AI_MODE_NAME = 'Workspace';

/**
* What the new-conversation screen is called, in the workspace's strip and
* on a phone in the chat's header: named for what it will produce, not for
* the chat.
* What the new-conversation screen is called, in the app's top bar: named
* for what it will produce, not for the chat.
*/
export const newConversationTitle = (intent: FundingIntent): string =>
intent === 'fund' ? 'New RFP' : 'New proposal';
Expand Down
Loading
Loading