Skip to content
Open
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
161 changes: 124 additions & 37 deletions components/AIMode/AIModeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,100 @@ import {
import dynamic from 'next/dynamic';
import { usePathname, useSearchParams } from 'next/navigation';

/** `?ai=1` opens the overlay; `?ai=1&aiChat=<id>` selects a conversation. */
/**
* `?ai=1` opens the workspace. `aiChat=<id>` selects a conversation;
* `aiNote=<id>` opens a document, with `aiChat` then naming the chat on that
* document; `aiView=doc` puts the document in the main pane.
*/
export const AI_MODE_OPEN_PARAM = 'ai';
export const AI_MODE_CHAT_PARAM = 'aiChat';
export const AI_MODE_NOTE_PARAM = 'aiNote';
export const AI_MODE_VIEW_PARAM = 'aiView';
const DOCUMENT_VIEW = 'doc';

/**
* What the workspace is open on: one of the user's conversations (null = the
* new-conversation screen), or a document with a chat scoped to it (null =
* a chat not yet started).
*/
export type WorkspaceTarget =
| { readonly kind: 'conversation'; readonly chatId: number | null }
| { readonly kind: 'document'; readonly noteId: number; readonly chatId: number | null };

/** Which pane is the main one; the other sits at a fixed width beside it. */
export type WorkspaceLayout = 'chat' | 'document';

interface AIModeUrlState {
isOpen: boolean;
chatId: number | null;
readonly isOpen: boolean;
readonly target: WorkspaceTarget;
readonly layout: WorkspaceLayout;
}

const NEW_CONVERSATION: WorkspaceTarget = { kind: 'conversation', chatId: null };
const CLOSED: AIModeUrlState = { isOpen: false, target: NEW_CONVERSATION, layout: 'chat' };

export interface AIModeContextValue extends AIModeUrlState {
/** Open on the last selected conversation, or the new-conversation screen. */
/** Open on the last target, or the new-conversation screen. */
open: () => void;
close: () => void;
toggle: () => void;
/** Open on a target, laid out as its kind reads best unless told otherwise. */
selectTarget: (target: WorkspaceTarget, layout?: WorkspaceLayout) => void;
/** Select a conversation (null = the new-conversation screen), opening if needed. */
selectChat: (chatId: number | null) => void;
/** Open a document with a fresh chat beside it. */
selectDocument: (noteId: number) => void;
/** Swap which pane is the main one; nothing else changes. */
setLayout: (layout: WorkspaceLayout) => void;
}

const AIModeContext = createContext<AIModeContextValue | null>(null);

function parseChatId(raw: string | null): number | null {
function parseId(raw: string | null): number | null {
if (raw == null) return null;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}

/** The layout a target opens in when nothing says otherwise. */
const defaultLayout = (target: WorkspaceTarget): WorkspaceLayout =>
target.kind === 'document' ? 'document' : 'chat';

function readUrlState(params: URLSearchParams): AIModeUrlState {
if (params.get(AI_MODE_OPEN_PARAM) !== '1') return CLOSED;
const chatId = parseId(params.get(AI_MODE_CHAT_PARAM));
const noteId = parseId(params.get(AI_MODE_NOTE_PARAM));
const target: WorkspaceTarget =
noteId != null ? { kind: 'document', noteId, chatId } : { kind: 'conversation', chatId };
// Only a document can be the main pane.
const layout: WorkspaceLayout =
noteId != null && params.get(AI_MODE_VIEW_PARAM) === DOCUMENT_VIEW ? 'document' : 'chat';
return { isOpen: true, target, layout };
}

function writeUrlState(params: URLSearchParams, state: AIModeUrlState): void {
for (const key of [
AI_MODE_OPEN_PARAM,
AI_MODE_CHAT_PARAM,
AI_MODE_NOTE_PARAM,
AI_MODE_VIEW_PARAM,
]) {
params.delete(key);
}
if (!state.isOpen) return;
params.set(AI_MODE_OPEN_PARAM, '1');
if (state.target.chatId != null) params.set(AI_MODE_CHAT_PARAM, String(state.target.chatId));
if (state.target.kind === 'document') {
params.set(AI_MODE_NOTE_PARAM, String(state.target.noteId));
if (state.layout === 'document') params.set(AI_MODE_VIEW_PARAM, DOCUMENT_VIEW);
}
}

const sameTarget = (a: WorkspaceTarget, b: WorkspaceTarget): boolean =>
a.kind === b.kind &&
a.chatId === b.chatId &&
(a.kind !== 'document' || b.kind !== 'document' || a.noteId === b.noteId);

/**
* Reads the overlay's URL state. Isolated behind Suspense because
* `useSearchParams` de-opts a statically rendered page up to the nearest
Expand All @@ -48,11 +116,19 @@ function parseChatId(raw: string | null): number | null {
*/
function AIModeUrlSync({ onChange }: { readonly onChange: (state: AIModeUrlState) => void }) {
const searchParams = useSearchParams();
const isOpen = searchParams.get(AI_MODE_OPEN_PARAM) === '1';
const chatId = isOpen ? parseChatId(searchParams.get(AI_MODE_CHAT_PARAM)) : null;
const { isOpen, layout, target } = readUrlState(searchParams);
const { chatId } = target;
const noteId = target.kind === 'document' ? target.noteId : null;
// Rebuilt from its parts so the effect runs on a change of state, not on
// every render's fresh object.
useEffect(() => {
onChange({ isOpen, chatId });
}, [isOpen, chatId, onChange]);
onChange({
isOpen,
layout,
target:
noteId != null ? { kind: 'document', noteId, chatId } : { kind: 'conversation', chatId },
});
}, [isOpen, layout, chatId, noteId, onChange]);
return null;
}

Expand All @@ -62,21 +138,24 @@ const AIModeOverlay = dynamic(
);

/**
* Owns the AI Mode overlay: its open/selected state lives in the URL, so a
* reload or a shared link lands on the same conversation, and any client-side
* navigation to another page naturally drops the params and closes it.
* Owns the AI Mode overlay: what it is open on lives in the URL, so a reload
* or a shared link lands on the same conversation or document, and any
* client-side navigation to another page naturally drops the params and
* closes it.
*
* Mounted once, globally. The overlay body is lazy-loaded so a session that
* never opens it pays nothing.
*/
export function AIModeProvider({ children }: { readonly children: ReactNode }) {
const pathname = usePathname();
const [state, setState] = useState<AIModeUrlState>({ isOpen: false, chatId: null });
// Closing drops the chat from the URL; reopening from the sidebar in the same
// page session should still return to it. In memory only — a reload starts
// from whatever the URL says.
const lastChatIdRef = useRef<number | null>(null);
if (state.chatId != null) lastChatIdRef.current = state.chatId;
const [state, setState] = useState<AIModeUrlState>(CLOSED);
// Closing drops the target from the URL; reopening from the sidebar in the
// same page session should still return to it. In memory only — a reload
// starts from whatever the URL says.
const lastRef = useRef<Pick<AIModeUrlState, 'target' | 'layout'> | null>(null);
if (state.isOpen && !sameTarget(state.target, NEW_CONVERSATION)) {
lastRef.current = { target: state.target, layout: state.layout };
}

const pathnameRef = useRef(pathname);
pathnameRef.current = pathname;
Expand All @@ -85,16 +164,7 @@ export function AIModeProvider({ children }: { readonly children: ReactNode }) {
// Event-handler only, so window is available; keeps every unrelated
// query param the page already carries.
const params = new URLSearchParams(window.location.search);
if (next.isOpen) {
params.set(AI_MODE_OPEN_PARAM, '1');
} else {
params.delete(AI_MODE_OPEN_PARAM);
}
if (next.isOpen && next.chatId != null) {
params.set(AI_MODE_CHAT_PARAM, String(next.chatId));
} else {
params.delete(AI_MODE_CHAT_PARAM);
}
writeUrlState(params, next);
const query = params.toString();
const hash = window.location.hash;
// Native history, not router.replace: the app router keeps
Expand All @@ -108,28 +178,45 @@ export function AIModeProvider({ children }: { readonly children: ReactNode }) {
setState(next);
}, []);

const selectTarget = useCallback(
(target: WorkspaceTarget, layout: WorkspaceLayout = defaultLayout(target)) => {
if (sameTarget(target, NEW_CONVERSATION)) lastRef.current = null;
navigate({ isOpen: true, target, layout });
},
[navigate]
);
const open = useCallback(() => {
navigate({ isOpen: true, chatId: lastChatIdRef.current });
const last = lastRef.current;
navigate(
last
? { isOpen: true, target: last.target, layout: last.layout }
: { isOpen: true, target: NEW_CONVERSATION, layout: 'chat' }
);
}, [navigate]);
const close = useCallback(() => navigate({ isOpen: false, chatId: null }), [navigate]);
const close = useCallback(() => navigate(CLOSED), [navigate]);
const selectChat = useCallback(
(chatId: number | null) => {
if (chatId == null) lastChatIdRef.current = null;
navigate({ isOpen: true, chatId });
},
[navigate]
(chatId: number | null) => selectTarget({ kind: 'conversation', chatId }),
[selectTarget]
);
const selectDocument = useCallback(
(noteId: number) => selectTarget({ kind: 'document', noteId, chatId: null }),
[selectTarget]
);

const stateRef = useRef(state);
stateRef.current = state;
const setLayout = useCallback(
(layout: WorkspaceLayout) => navigate({ ...stateRef.current, isOpen: true, layout }),
[navigate]
);
const toggle = useCallback(() => {
if (stateRef.current.isOpen) close();
else open();
}, [open, close]);

const value = useMemo<AIModeContextValue>(
() => ({ ...state, open, close, toggle, selectChat }),
[state, open, close, toggle, selectChat]
() => ({ ...state, open, close, toggle, selectTarget, selectChat, selectDocument, setLayout }),
[state, open, close, toggle, selectTarget, selectChat, selectDocument, setLayout]
);

return (
Expand Down
43 changes: 26 additions & 17 deletions components/AIMode/AIModeOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ const DOCUMENT_DEFAULT_SHARE = 0.55;
* from inside it still render on top.
*/
export function AIModeOverlay() {
const { close } = useAIMode();
const { close, layout, setLayout } = useAIMode();
const state = useAIModeChat();
const { target } = state;
// Below the tablet breakpoint the sidebar lives in a bottom drawer.
const [listDrawerOpen, setListDrawerOpen] = useState(false);
const closeListDrawer = useCallback(() => setListDrawerOpen(false), []);
Expand Down Expand Up @@ -66,34 +67,39 @@ export function AIModeOverlay() {
defaultWidth: LIST_DEFAULT_WIDTH,
anchor: 'left',
});
// Document or details in the right pane; details wants a wider floor.
// Document or details in the document pane; details wants a wider floor.
const [documentView, setDocumentView] = useState<DocumentPaneView>('document');
const documentMinWidth = documentView === 'details' ? DETAILS_MIN_WIDTH : DOCUMENT_MIN_WIDTH;
// The document may grow until the chat is down to its minimum column.
const documentMaxWidth = Math.max(
documentMinWidth,
viewportWidth - listWidth.width - CHAT_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 sideMaxWidth = Math.max(
sideMinWidth,
viewportWidth - listWidth.width - (sideIsDocument ? CHAT_MIN_WIDTH : DOCUMENT_MIN_WIDTH)
);
const documentWidth = useResizableWidth({
const sideWidth = useResizableWidth({
storageKey: 'ai-mode:document-width',
min: documentMinWidth,
max: documentMaxWidth,
min: sideMinWidth,
max: sideMaxWidth,
defaultWidth: (width) => width * DOCUMENT_DEFAULT_SHARE,
anchor: 'right',
});
const isBelowTabletRef = useRef(isBelowTablet);
isBelowTabletRef.current = isBelowTablet;

// On desktop the document pane opens by itself the moment a conversation
// gains a note. On mobile it never opens by itself — the card in the
// transcript is the way in, and it opens a drawer. Either way the user can
// close it and reopen it from the card or the chat header.
// gains a note, and a document opened from the sidebar shows at once
// everywhere. Otherwise on mobile the card in the transcript is the way in,
// and it opens a drawer. Either way the user can close it and reopen it
// from the card or the chat header.
const noteId = state.note?.id ?? null;
const onDocument = target.kind === 'document';
const [documentOpen, setDocumentOpen] = useState(false);
useEffect(() => {
setDocumentOpen(noteId != null && !isBelowTabletRef.current);
setDocumentOpen(noteId != null && (onDocument || !isBelowTabletRef.current));
setDocumentView('document');
}, [noteId]);
}, [noteId, onDocument]);

const openDocument = useCallback(() => setDocumentOpen(true), []);
const closeDocument = useCallback(() => setDocumentOpen(false), []);
Expand All @@ -119,8 +125,9 @@ export function AIModeOverlay() {
}
return null;
}, [noteId, state.chat.chat]);
// A document opened from the sidebar is the main pane; no card needed.
const documentCard =
noteId != null ? (
noteId != null && !onDocument ? (
<DocumentCard
title={documentTitle}
status={doc.status}
Expand Down Expand Up @@ -158,14 +165,16 @@ export function AIModeOverlay() {
<AIModeHeader
documentTitle={showDocument ? documentTitle : null}
publishControlsRef={isBelowTablet ? undefined : setPublishControlsSlot}
layout={showDocument && !isBelowTablet ? layout : undefined}
onLayoutChange={setLayout}
onClose={close}
/>

<WorkspacePanes
layout="chat"
layout={layout}
isBelowTablet={isBelowTablet}
sidebarWidth={{ ...listWidth, min: LIST_MIN_WIDTH, max: LIST_MAX_WIDTH }}
sideWidth={{ ...documentWidth, min: documentMinWidth, max: documentMaxWidth }}
sideWidth={{ ...sideWidth, min: sideMinWidth, max: sideMaxWidth }}
listDrawerOpen={listDrawerOpen}
onCloseListDrawer={closeListDrawer}
onCloseDocumentDrawer={closeDocument}
Expand Down
21 changes: 16 additions & 5 deletions components/AIMode/chat/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import type { AIModeChatState } from '../useAIModeChat';
import { aiModeGreeting, INTENT_COPY } from '../copy';
import { StartScreen } from '../start/StartScreen';
import { DocumentChatEmptyState } from './DocumentChatEmptyState';
import { useUser } from '@/contexts/UserContext';

interface ChatPaneProps {
Expand Down Expand Up @@ -84,8 +85,13 @@
chatId == null ? null : state.titleFor(chatId, chat.chat?.title ?? listedTitle);
const titleLoading =
chatId != null && currentTitle == null && (chat.chat == null || list.access === 'loading');
const onDocument = state.target.kind === 'document';
const title =
chatId == null ? 'New conversation' : (currentTitle?.trim() ?? '') || 'Untitled conversation';
chatId == null
? onDocument
? 'New chat'
: 'New conversation'

Check warning on line 93 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=AaDIYBWPdIIktb-R7prs&open=AaDIYBWPdIIktb-R7prs&pullRequest=1122
: (currentTitle?.trim() ?? '') || 'Untitled conversation';

const composer = (
<ChatComposer
Expand All @@ -100,7 +106,9 @@
sendDisabled={state.sendBlocked}
notice={notice}
className="border-t-0 bg-gray-50"
placeholder={chatId == null ? INTENT_COPY[state.intent].placeholder : undefined}
placeholder={
chatId == null && !onDocument ? INTENT_COPY[state.intent].placeholder : undefined
}
toolbar={
<ModelControls
models={modelSelection.models}
Expand Down Expand Up @@ -164,49 +172,51 @@
<div ref={contentRef} className="mx-auto w-full max-w-[760px] px-4 py-5 tablet:!px-6">
{listBlocked ? (
<AccessBlocked detail={list.accessDetail} />
) : chatId == null && onDocument ? (
<DocumentChatEmptyState />
) : chatId == null ? (
<StartScreen
composer={composer}
greeting={aiModeGreeting(user?.firstName)}
intent={state.intent}
onIntentChange={state.setIntent}
selectedGrant={state.selectedGrant}
onSelectGrant={state.setSelectedGrant}
/>
) : chat.access === 'loading' && chat.chat == null ? (
<ChatTranscriptSkeleton />
) : chat.access === 'not_found' ? (
<p className="py-16 text-center text-sm text-gray-600">
This conversation is no longer available.
</p>
) : chat.access === 'unauthorized' ? (
<AccessBlocked detail={null} />
) : chat.access === 'error' && chat.chat == null ? (
<div className="flex flex-col items-center gap-3 py-16 text-center">
<p className="text-sm text-gray-600">Couldn’t load this conversation.</p>
<Button variant="outlined" size="sm" onClick={chat.refetch}>
Try again
</Button>
</div>
) : chat.chat ? (
<div className="animate-in fade-in duration-300">
<ChatTranscript
chat={chat.chat}
pendingSend={chat.pendingSend}
renderExecutionExtra={
documentCard && documentCardExecutionId != null
? (execution) =>
execution.id === documentCardExecutionId ? (
<div className="pt-1">{documentCard}</div>
) : null
: undefined
}
/>
{documentCard && documentCardExecutionId == null && (
<div className="mt-5">{documentCard}</div>
)}
</div>
) : null}

Check warning on line 219 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=AaDIYBWPdIIktb-R7prt&open=AaDIYBWPdIIktb-R7prt&pullRequest=1122
</div>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-3 flex justify-center">
Expand All @@ -218,9 +228,10 @@
</div>
</div>

{/* A conversation keeps the composer docked at the bottom; the
new-conversation screen seats it in the middle with the starters. */}
{chatId != null && (
{/* A conversation keeps the composer docked at the bottom, as does a
document's chat before it starts; the new-conversation screen seats
it in the middle with the intent toggle. */}
{(chatId != null || onDocument) && (
<div className="shrink-0 border-t border-gray-200 bg-gray-50">
<div className={cn('mx-auto w-full max-w-[760px] px-3 py-3 tablet:!px-5')}>
{composer}
Expand Down
Loading
Loading