From d5671a4a8b5e92d44a831839d842c8f8d2020bf2 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Mon, 5 Oct 2026 14:01:24 -0400 Subject: [PATCH] Workspace is a page at /workspace, not an overlay Conversations and drafts take the nav's place in the left column, and the chat and document fill the rest. Old ?ai=1 links redirect. Co-Authored-By: Claude Fable 5.1 --- app/globals.css | 22 ++ app/layouts/LeftSidebar.tsx | 50 ++- app/layouts/PageLayout.tsx | 130 +++++--- app/layouts/PublishMenu.tsx | 14 +- .../components/LeftSidebarContainer.tsx | 6 +- app/layouts/topbar/pageRoutes.tsx | 7 + app/workspace/WorkspaceClient.tsx | 30 ++ app/workspace/page.tsx | 11 + components/AIMode/AIModeContext.tsx | 284 +++--------------- components/AIMode/LegacyAIModeRedirect.tsx | 22 ++ .../{AIModeOverlay.tsx => WorkspacePage.tsx} | 211 +++++++------ components/AIMode/chat/ChatPane.tsx | 13 +- .../AIMode/chat/ConversationTitleField.tsx | 3 +- components/AIMode/copy.ts | 5 +- components/AIMode/document/DocumentPane.tsx | 9 +- components/AIMode/shell/AIModeHeader.tsx | 54 +++- components/AIMode/shell/WorkspacePanes.tsx | 90 +++--- .../AIMode/shell/useModalOverlayBehavior.ts | 78 ----- .../AIMode/sidebar/ConversationMenu.tsx | 1 - components/AIMode/sidebar/DraftsSection.tsx | 2 +- components/AIMode/sidebar/SidebarRow.tsx | 2 +- .../AIMode/sidebar/WorkspaceSidebar.tsx | 57 ++-- components/AIMode/useAIModeChat.ts | 31 +- components/AIMode/workspaceUrl.ts | 123 ++++++++ components/Funding/useFundingDrafting.ts | 39 ++- .../PublishingForm/PublishingFormProvider.tsx | 3 +- .../PublishingForm/usePublishAction.ts | 5 - components/modals/ConfirmPublishModal.tsx | 2 +- components/providers/ClientProviders.tsx | 13 +- components/ui/Popover.tsx | 2 +- components/ui/SwipeableDrawer.tsx | 26 +- components/ui/form/Modal.tsx | 2 +- contexts/PublishingHostContext.tsx | 6 - proxy.ts | 1 + 34 files changed, 679 insertions(+), 675 deletions(-) create mode 100644 app/workspace/WorkspaceClient.tsx create mode 100644 app/workspace/page.tsx create mode 100644 components/AIMode/LegacyAIModeRedirect.tsx rename components/AIMode/{AIModeOverlay.tsx => WorkspacePage.tsx} (53%) delete mode 100644 components/AIMode/shell/useModalOverlayBehavior.ts create mode 100644 components/AIMode/workspaceUrl.ts diff --git a/app/globals.css b/app/globals.css index 31dc8791b..ba79ec5ef 100644 --- a/app/globals.css +++ b/app/globals.css @@ -88,6 +88,28 @@ body { } } + /* The workspace page's lists and panes arrive by opacity alone, while the + logo, the Publish button and the top bar around them hold still. Not + `.animate-in`, which also slides. */ + .workspace-fade-in { + animation: workspaceFadeIn 0.2s ease-out; + } + + @keyframes workspaceFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @media (prefers-reduced-motion: reduce) { + .workspace-fade-in { + animation: none; + } + } + /* Hide scrollbar for Chrome, Safari and Opera */ .scrollbar-hide::-webkit-scrollbar { display: none; diff --git a/app/layouts/LeftSidebar.tsx b/app/layouts/LeftSidebar.tsx index 647a5d1ff..a781c7f2c 100644 --- a/app/layouts/LeftSidebar.tsx +++ b/app/layouts/LeftSidebar.tsx @@ -13,9 +13,16 @@ import { FundingPowerRailButton } from '@/components/Funding/FundingPowerRailBut interface LeftSidebarProps { forceMinimize?: boolean; + /** + * Takes the place of the nav items, the footer links and the funding power + * button: a page whose own lists belong in this column. Hidden while the + * column is the icon rail, where the page shows them some other way; pass + * null to keep the area empty. + */ + content?: React.ReactNode; } -export const LeftSidebar: React.FC = ({ forceMinimize = false }) => { +export const LeftSidebar: React.FC = ({ forceMinimize = false, content }) => { const pathname = usePathname(); const handleUnimplementedFeature = (featureName: string) => { @@ -40,6 +47,7 @@ export const LeftSidebar: React.FC = ({ forceMinimize = false // Create minimized classes based on either responsive design or forced minimization const minimizeClass = forceMinimize ? 'minimized-sidebar' : ''; + const hasContent = content !== undefined; return (
@@ -62,23 +70,33 @@ export const LeftSidebar: React.FC = ({ forceMinimize = false
- + {hasContent ? ( +
+ {content} +
+ ) : ( + <> + - {/* Navigation above is flex-1, so this sits at the bottom of the column. - Only covers 768px to the right sidebar's breakpoint: below that the - bar is docked over the mobile bottom nav, and above it the funding - power card is in the right sidebar. */} -
- -
+ {/* Navigation above is flex-1, so this sits at the bottom of the column. + Only covers 768px to the right sidebar's breakpoint: below that the + bar is docked over the mobile bottom nav, and above it the funding + power card is in the right sidebar. */} +
+ +
-
- -
+
+ +
+ + )} ); }; diff --git a/app/layouts/PageLayout.tsx b/app/layouts/PageLayout.tsx index ff4608d01..6e9c62d6b 100644 --- a/app/layouts/PageLayout.tsx +++ b/app/layouts/PageLayout.tsx @@ -52,6 +52,17 @@ interface PageLayoutProps { * of empty space beside the content. */ wideRow?: boolean; + /** + * Shown in the left column in place of the nav items: a page whose own + * lists live there. See `LeftSidebar`'s `content`. + */ + leftSidebarContent?: ReactNode; + /** + * The page is an app surface, not a document: it fills the area beside the + * left column and under the top bar edge to edge and manages its own + * scrolling. No page container, padding, right sidebar or mobile bottom nav. + */ + fullBleed?: boolean; } function PageLayoutInner({ @@ -65,6 +76,8 @@ function PageLayoutInner({ rightSidebarFill = false, contentWidth = 'default', wideRow = false, + leftSidebarContent, + fullBleed = false, }: PageLayoutProps) { const isNarrow = contentWidth === 'narrow'; const pathname = usePathname() || ''; @@ -102,65 +115,86 @@ function PageLayoutInner({ - - - {/* 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. */} -
- {topBanner &&
{topBanner}
} + + {fullBleed ? ( + // Offset from the fixed top bar exactly as the scrollport below is, + // but nothing here scrolls: the page lays out its own panes in the + // height that is left, so the top bar never hides on a phone.
-
+ ) : ( + /* 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. */ +
+ {topBanner &&
{topBanner}
} + +
-
- {children} -
-
+
+ {children} +
+ - {rightSidebar && ( - - )} -
+ {rightSidebar && ( + + )} +
- - + + + )} ); diff --git a/app/layouts/PublishMenu.tsx b/app/layouts/PublishMenu.tsx index e4dd2e581..cce1d9c97 100644 --- a/app/layouts/PublishMenu.tsx +++ b/app/layouts/PublishMenu.tsx @@ -22,6 +22,8 @@ import { useState } from 'react'; interface PublishMenuProps { forceMinimize?: boolean; + /** Runs once an item is picked, for a host that should then get out of the way. */ + onItemSelected?: () => void; } interface PublishMenuItem { @@ -93,11 +95,14 @@ const MenuItemContent: React.FC = ({ icon, title, descript /** * The sidebar's Publish button: a Request for Proposal or a Proposal. For a - * user the workspace admits, either opens it straight onto a conversation - * for that side of the money; everyone else gets the notebook's opening - * modal and editor, as before. + * user the workspace admits, either leads straight to a new conversation + * there for that side of the money; everyone else gets the notebook's + * opening modal and editor, as before. */ -export const PublishMenu: React.FC = ({ forceMinimize = false }) => { +export const PublishMenu: React.FC = ({ + forceMinimize = false, + onItemSelected, +}) => { const router = useRouter(); const { executeAuthenticatedAction } = useAuthenticatedAction(); const { inWorkspace, startNew } = useFundingDrafting(); @@ -131,6 +136,7 @@ export const PublishMenu: React.FC = ({ forceMinimize = false if (smAndDown) { setIsMobileDrawerOpen(false); } + onItemSelected?.(); }; // Regular trigger for standard mode diff --git a/app/layouts/components/LeftSidebarContainer.tsx b/app/layouts/components/LeftSidebarContainer.tsx index 1cbb75de7..a07e832a8 100644 --- a/app/layouts/components/LeftSidebarContainer.tsx +++ b/app/layouts/components/LeftSidebarContainer.tsx @@ -5,9 +5,11 @@ import { LeftSidebar } from '../LeftSidebar'; interface LeftSidebarContainerProps { isOpen: boolean; + /** Replaces the nav items in the column; see `LeftSidebar`. */ + content?: React.ReactNode; } -export function LeftSidebarContainer({ isOpen }: LeftSidebarContainerProps) { +export function LeftSidebarContainer({ isOpen, content }: LeftSidebarContainerProps) { return (
- +
); } diff --git a/app/layouts/topbar/pageRoutes.tsx b/app/layouts/topbar/pageRoutes.tsx index dc3f5306e..d71d1c3d3 100644 --- a/app/layouts/topbar/pageRoutes.tsx +++ b/app/layouts/topbar/pageRoutes.tsx @@ -12,6 +12,7 @@ import { getTopicEmoji } from '@/components/Topic/TopicEmojis'; import { toTitleCase } from '@/utils/stringUtils'; import { getSourceLogo, getPreprintDisplayName } from '@/utils/preprintUtil'; import { HOME_TAB_PATHS, isHomeTabPath } from '@/hooks/useFundTabs'; +import { AI_MODE_NAME } from '@/components/AIMode/copy'; export interface PageInfo { title: string; @@ -61,6 +62,12 @@ const ROUTE_RULES: RouteRule[] = [ icon: , }), }, + { + // Not a root navigation page, so the top bar gives it the back arrow: + // that arrow is the way out of the workspace. + match: (p) => p === '/workspace', + getInfo: () => ({ title: AI_MODE_NAME }), + }, { match: (p) => p === '/notifications', getInfo: () => ({ diff --git a/app/workspace/WorkspaceClient.tsx b/app/workspace/WorkspaceClient.tsx new file mode 100644 index 000000000..f53523452 --- /dev/null +++ b/app/workspace/WorkspaceClient.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { Suspense } from 'react'; +import dynamic from 'next/dynamic'; +import { PageLayout } from '@/app/layouts/PageLayout'; + +/** The app's shell with the workspace's areas empty, until the workspace itself has loaded. */ +function WorkspaceShell() { + return ( + +
+ + ); +} + +// Client-only: the workspace sizes its panes from the window and from widths +// remembered in the browser, none of which a server render can know. +const WorkspacePage = dynamic( + () => import('@/components/AIMode/WorkspacePage').then((module) => module.WorkspacePage), + { ssr: false, loading: () => } +); + +/** Behind Suspense because the workspace reads what it is open on from the URL's search params. */ +export function WorkspaceClient() { + return ( + }> + + + ); +} diff --git a/app/workspace/page.tsx b/app/workspace/page.tsx new file mode 100644 index 000000000..1498ec77e --- /dev/null +++ b/app/workspace/page.tsx @@ -0,0 +1,11 @@ +import { Metadata } from 'next'; +import { WorkspaceClient } from './WorkspaceClient'; + +export const metadata: Metadata = { + title: 'Workspace', + robots: { index: false, follow: false }, +}; + +export default function WorkspaceRoute() { + return ; +} diff --git a/components/AIMode/AIModeContext.tsx b/components/AIMode/AIModeContext.tsx index a47aac2d9..4860b646a 100644 --- a/components/AIMode/AIModeContext.tsx +++ b/components/AIMode/AIModeContext.tsx @@ -1,77 +1,20 @@ 'use client'; +import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from 'react'; +import { useSearchParams } from 'next/navigation'; import { - createContext, - Suspense, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from 'react'; -import dynamic from 'next/dynamic'; -import { usePathname, useSearchParams } from 'next/navigation'; -import type { FundingIntent } from '@/components/Funding/fundingDirection'; - -/** - * `?ai=1` opens the workspace. `aiChat=` selects a conversation; - * `aiNote=` opens a document, with `aiChat` then naming the chat on that - * document. `aiView=chat` puts that chat, not the document, in the main pane - * (a conversation opened from the list); absent, the document comes first. - */ -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'; - -/** Which pane is the main one; the other sits at a fixed width beside it. */ -export type WorkspaceLayout = 'chat' | 'document'; - -/** - * 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). A document target remembers how it was reached: - * opened as a document it comes first, opened as a conversation its chat does. - */ -export type WorkspaceTarget = - | { readonly kind: 'conversation'; readonly chatId: number | null } - | { - readonly kind: 'document'; - readonly noteId: number; - readonly chatId: number | null; - readonly layout: WorkspaceLayout; - }; - -export const layoutFor = (target: WorkspaceTarget): WorkspaceLayout => - target.kind === 'document' ? target.layout : 'chat'; - -interface AIModeUrlState { - readonly isOpen: boolean; + documentTarget, + readWorkspaceTarget, + sameTarget, + workspaceHref, + type WorkspaceTarget, +} from './workspaceUrl'; + +export interface AIModeContextValue { + /** What the workspace is open on, as the URL says. */ readonly target: WorkspaceTarget; -} - -const NEW_CONVERSATION: WorkspaceTarget = { kind: 'conversation', chatId: null }; -const CLOSED: AIModeUrlState = { isOpen: false, target: NEW_CONVERSATION }; - -export interface AIModeContextValue extends AIModeUrlState { - /** Open on the last target, or the new-conversation screen. */ - open: () => void; - /** - * Open on a fresh conversation for one side of the money. The door the - * user came through — a Publish menu item, a New RFP or New proposal - * button — says what the assistant will draft, so the start screen - * does not ask. - */ - openFor: (intent: FundingIntent) => void; - /** The intent handed over by {@link openFor}, once; null after. */ - takePendingIntent: () => FundingIntent | null; - close: () => void; - toggle: () => void; - /** Open on a target. */ selectTarget: (target: WorkspaceTarget) => void; - /** Select a conversation (null = the new-conversation screen), opening if needed. */ + /** Select a conversation (null = the new-conversation screen). */ selectChat: (chatId: number | null) => void; /** Open a document with a fresh chat beside it. */ selectDocument: (noteId: number) => void; @@ -79,199 +22,46 @@ export interface AIModeContextValue extends AIModeUrlState { const AIModeContext = createContext(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; -} - -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 layout: WorkspaceLayout = params.get(AI_MODE_VIEW_PARAM) === 'chat' ? 'chat' : 'document'; - const target: WorkspaceTarget = - noteId != null - ? { kind: 'document', noteId, chatId, layout } - : { kind: 'conversation', chatId }; - return { isOpen: true, target }; -} - -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.target.layout === 'chat') params.set(AI_MODE_VIEW_PARAM, 'chat'); - } -} - -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 && a.layout === b.layout)); - -/** - * Reads the overlay's URL state. Isolated behind Suspense because - * `useSearchParams` de-opts a statically rendered page up to the nearest - * boundary; the provider itself stays synchronous so nothing above it is - * affected. - */ -function AIModeUrlSync({ onChange }: { readonly onChange: (state: AIModeUrlState) => void }) { - const searchParams = useSearchParams(); - const { isOpen, target } = readUrlState(searchParams); - const { chatId } = target; - const noteId = target.kind === 'document' ? target.noteId : null; - const layout = target.kind === 'document' ? target.layout : null; - // Rebuilt from its parts so the effect runs on a change of state, not on - // every render's fresh object. - useEffect(() => { - onChange({ - isOpen, - target: - noteId != null && layout != null - ? { kind: 'document', noteId, chatId, layout } - : { kind: 'conversation', chatId }, - }); - }, [isOpen, chatId, noteId, layout, onChange]); - return null; -} - -const AIModeOverlay = dynamic( - () => import('./AIModeOverlay').then((module) => module.AIModeOverlay), - { ssr: false } -); - /** - * 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. + * Owns what the workspace page is open on. It lives in the URL, so a reload + * or a shared link lands on the same conversation or document. * - * Mounted once, globally. The overlay body is lazy-loaded so a session that - * never opens it pays nothing. + * Mounted by the `/workspace` page, not globally: the doors elsewhere in the + * app are plain links built with `workspaceHref`. Reads the URL with + * `useSearchParams`, so it needs a Suspense boundary above it. */ export function AIModeProvider({ children }: { readonly children: ReactNode }) { - const pathname = usePathname(); - const [state, setState] = useState(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(null); - if (state.isOpen && !sameTarget(state.target, NEW_CONVERSATION)) { - lastRef.current = state.target; - } - - const pathnameRef = useRef(pathname); - pathnameRef.current = pathname; - - const navigate = useCallback((next: AIModeUrlState) => { - // Event-handler only, so window is available; keeps every unrelated - // query param the page already carries. - const params = new URLSearchParams(window.location.search); - writeUrlState(params, next); - const query = params.toString(); - const hash = window.location.hash; - // Native history, not router.replace: the app router keeps - // useSearchParams in sync with it, and unlike a router navigation it - // neither re-fetches nor re-renders the page behind the overlay. - window.history.replaceState( - window.history.state, - '', - `${pathnameRef.current}${query ? `?${query}` : ''}${hash}` - ); - setState(next); + const searchParams = useSearchParams(); + // The same object until the target itself changes, so effects keyed on it + // do not re-run with every render or unrelated param. + const parsed = readWorkspaceTarget(searchParams); + const targetRef = useRef(parsed); + if (!sameTarget(targetRef.current, parsed)) targetRef.current = parsed; + const target = targetRef.current; + + const selectTarget = useCallback((next: WorkspaceTarget) => { + if (sameTarget(next, targetRef.current)) return; + // Replaced, not pushed: the workspace is one history entry however many + // conversations are opened in it, so Back leaves in a single step. Native + // history with no state of our own, which the app router picks up and + // feeds back through useSearchParams without fetching anything. + window.history.replaceState(null, '', `${workspaceHref(next)}${window.location.hash}`); }, []); - - const selectTarget = useCallback( - (target: WorkspaceTarget) => { - if (sameTarget(target, NEW_CONVERSATION)) lastRef.current = null; - navigate({ isOpen: true, target }); - }, - [navigate] - ); - const open = useCallback(() => { - navigate({ isOpen: true, target: lastRef.current ?? NEW_CONVERSATION }); - }, [navigate]); - const close = useCallback(() => navigate(CLOSED), [navigate]); const selectChat = useCallback( (chatId: number | null) => selectTarget({ kind: 'conversation', chatId }), [selectTarget] ); const selectDocument = useCallback( - (noteId: number) => - selectTarget({ kind: 'document', noteId, chatId: null, layout: 'document' }), + (noteId: number) => selectTarget(documentTarget(noteId)), [selectTarget] ); - // Held until the workspace's chat hook mounts and asks for it, so the door - // that opens a conversation needs no chat state of its own. - const pendingIntentRef = useRef(null); - const openFor = useCallback( - (intent: FundingIntent) => { - pendingIntentRef.current = intent; - selectTarget(NEW_CONVERSATION); - }, - [selectTarget] - ); - const takePendingIntent = useCallback(() => { - const intent = pendingIntentRef.current; - pendingIntentRef.current = null; - return intent; - }, []); - - const stateRef = useRef(state); - stateRef.current = state; - const toggle = useCallback(() => { - if (stateRef.current.isOpen) close(); - else open(); - }, [open, close]); - const value = useMemo( - () => ({ - ...state, - open, - close, - toggle, - selectTarget, - selectChat, - selectDocument, - openFor, - takePendingIntent, - }), - [ - state, - open, - close, - toggle, - selectTarget, - selectChat, - selectDocument, - openFor, - takePendingIntent, - ] + () => ({ target, selectTarget, selectChat, selectDocument }), + [target, selectTarget, selectChat, selectDocument] ); - return ( - - {children} - - - - {state.isOpen && } - - ); + return {children}; } export function useAIMode(): AIModeContextValue { @@ -282,7 +72,7 @@ export function useAIMode(): AIModeContextValue { return context; } -/** Same as {@link useAIMode} but tolerates rendering outside the provider. */ +/** Same as {@link useAIMode} but null anywhere other than the workspace page. */ export function useOptionalAIMode(): AIModeContextValue | null { return useContext(AIModeContext); } diff --git a/components/AIMode/LegacyAIModeRedirect.tsx b/components/AIMode/LegacyAIModeRedirect.tsx new file mode 100644 index 000000000..c867ea22a --- /dev/null +++ b/components/AIMode/LegacyAIModeRedirect.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { legacyWorkspaceHref } from './workspaceUrl'; + +/** + * The workspace used to open over any page with `?ai=1`. A link from then + * still lands on that page; this sends it on to the workspace page, open on + * the same conversation or document. Reads the URL with `useSearchParams`, + * so it needs a Suspense boundary above it. + * + * Temporary: delete once such links have aged out. + */ +export function LegacyAIModeRedirect() { + const router = useRouter(); + const href = legacyWorkspaceHref(useSearchParams()); + useEffect(() => { + if (href) router.replace(href); + }, [href, router]); + return null; +} diff --git a/components/AIMode/AIModeOverlay.tsx b/components/AIMode/WorkspacePage.tsx similarity index 53% rename from components/AIMode/AIModeOverlay.tsx rename to components/AIMode/WorkspacePage.tsx index bf43974ca..c64fbb457 100644 --- a/components/AIMode/AIModeOverlay.tsx +++ b/components/AIMode/WorkspacePage.tsx @@ -1,27 +1,24 @@ 'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; import { PanelRight } from 'lucide-react'; +import { PageLayout } from '@/app/layouts/PageLayout'; import { cn } from '@/utils/styles'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useResizableWidth } from '@/hooks/useResizableWidth'; -import { layoutFor, useAIMode } from './AIModeContext'; +import { AIModeProvider } from './AIModeContext'; import { ChatPane } from './chat/ChatPane'; import { DocumentCard } from './chat/DocumentCard'; import { conversationTitleFor } from './chat/conversationTitle'; import { DocumentPane, type DocumentPaneView } from './document/DocumentPane'; import { useAIModeDocument } from './document/useAIModeDocument'; import { AIModeHeader } from './shell/AIModeHeader'; -import { useModalOverlayBehavior } from './shell/useModalOverlayBehavior'; import { WorkspacePanes } from './shell/WorkspacePanes'; import { WorkspaceSidebar } from './sidebar/WorkspaceSidebar'; import { useAIModeChat } from './useAIModeChat'; -import { AI_MODE_NAME, newConversationTitle } from './copy'; +import { newConversationTitle } from './copy'; +import { layoutFor } from './workspaceUrl'; -const LIST_MIN_WIDTH = 200; -const LIST_MAX_WIDTH = 440; -const LIST_DEFAULT_WIDTH = 264; const CHAT_MIN_WIDTH = 360; const DOCUMENT_MIN_WIDTH = 420; /** The details form needs more room than the document: authors, image, funding fields. */ @@ -29,21 +26,54 @@ const DETAILS_MIN_WIDTH = 560; /** Share of the viewport the document opens at before the user drags it. */ const DOCUMENT_DEFAULT_SHARE = 0.55; +/** An element's width as it resizes; the viewport's until it has mounted. */ +function useElementWidth(element: HTMLElement | null): number { + const [width, setWidth] = useState(() => + typeof window === 'undefined' ? 1440 : window.innerWidth + ); + useEffect(() => { + if (!element) return; + const update = () => setWidth(Math.round(element.getBoundingClientRect().width)); + update(); + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(update); + observer.observe(element); + return () => observer.disconnect(); + }, [element]); + return width; +} + /** - * The full-viewport workspace: header, sidebar, chat, document. Sits below - * BaseModal (9999) and Tooltip (10000) so real modals and tooltips opened - * from inside it still render on top. + * The `/workspace` page: the app's shell with the user's conversations and + * drafts in the left column where the nav items normally are, and the chat + * and the document filling the rest. What it is open on lives in the URL. */ -export function AIModeOverlay() { - const { close } = useAIMode(); +export function WorkspacePage() { + return ( + + + + ); +} + +function Workspace() { const state = useAIModeChat(); const { target } = state; // A document opened from the sidebar comes first; a conversation's document sits beside it. const layout = layoutFor(target); - // Below the tablet breakpoint the sidebar is a screen of its own, opened - // from the chat header. + + // The lists sit in the app's left column from the width at which it stops + // 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. + const isBelowTablet = useMediaQuery('(max-width: 767px)') === true; const [listOpen, setListOpen] = useState(false); const closeList = useCallback(() => setListOpen(false), []); + const toggleList = useCallback(() => setListOpen((open) => !open), []); + useEffect(() => { + if (listsInColumn) setListOpen(false); + }, [listsInColumn]); const doc = useAIModeDocument({ note: state.note, @@ -51,26 +81,9 @@ export function AIModeOverlay() { latestExecution: state.chat.latestExecution, }); - // Tailwind's `tablet` breakpoint; the drawers only exist below it. - const isBelowTablet = useMediaQuery('(max-width: 767px)') === true; - - // ---- pane widths, claude.ai style: both side panes drag, the chat takes the rest ---- - const [viewportWidth, setViewportWidth] = useState(() => - typeof window === 'undefined' ? 1440 : window.innerWidth - ); - useEffect(() => { - const update = () => setViewportWidth(window.innerWidth); - update(); - window.addEventListener('resize', update); - return () => window.removeEventListener('resize', update); - }, []); - const listWidth = useResizableWidth({ - storageKey: 'ai-mode:list-width', - min: LIST_MIN_WIDTH, - max: LIST_MAX_WIDTH, - defaultWidth: LIST_DEFAULT_WIDTH, - anchor: 'left', - }); + // ---- pane widths: the side pane drags, the main pane takes the rest ---- + const [panesEl, setPanesEl] = useState(null); + const panesWidth = useElementWidth(panesEl); // Document or details in the document pane; details wants a wider floor. const [documentView, setDocumentView] = useState('document'); const documentMinWidth = documentView === 'details' ? DETAILS_MIN_WIDTH : DOCUMENT_MIN_WIDTH; @@ -80,7 +93,7 @@ export function AIModeOverlay() { const sideMinWidth = sideIsDocument ? documentMinWidth : CHAT_MIN_WIDTH; const sideMaxWidth = Math.max( sideMinWidth, - viewportWidth - listWidth.width - (sideIsDocument ? CHAT_MIN_WIDTH : DOCUMENT_MIN_WIDTH) + panesWidth - (sideIsDocument ? CHAT_MIN_WIDTH : DOCUMENT_MIN_WIDTH) ); const sideWidth = useResizableWidth({ storageKey: 'ai-mode:document-width', @@ -109,9 +122,9 @@ export function AIModeOverlay() { const closeDocument = useCallback(() => setDocumentOpen(false), []); const showDocument = noteId != null && documentOpen; const documentTitle = doc.content?.title?.trim() || state.note?.title?.trim() || 'Document'; - // The top 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 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. const { title: conversationTitle } = conversationTitleFor(state); const headerTitle = layout === 'document' @@ -120,7 +133,7 @@ export function AIModeOverlay() { ? conversationTitle : newConversationTitle(state.intent); - // The document column puts its publish controls up in the header, where + // 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(null); @@ -150,9 +163,6 @@ export function AIModeOverlay() { /> ) : null; - const [rootEl, setRootEl] = useState(null); - useModalOverlayBehavior({ rootEl, onEscape: close }); - const documentPane = (presentation: 'pane' | 'drawer') => ( ); - return createPortal( - + ) : null + } > - - - } - chat={ - setListOpen(true)} - documentCard={documentCard} - documentCardExecutionId={documentCardExecutionId} - headerActions={ - noteId != null && ( - - ) - } +
+ {!isBelowTablet && ( + - } - document={showDocument ? documentPane('pane') : null} - documentDrawer={showDocument ? documentPane('drawer') : null} - /> -
, - document.body + )} + + } + listOpen={listOpen} + onCloseList={closeList} + onCloseDocumentDrawer={closeDocument} + chat={ + setListOpen(true)} + documentCard={documentCard} + documentCardExecutionId={documentCardExecutionId} + headerActions={ + noteId != null && ( + + ) + } + /> + } + document={showDocument ? documentPane('pane') : null} + documentDrawer={showDocument ? documentPane('drawer') : null} + /> +
+
); } diff --git a/components/AIMode/chat/ChatPane.tsx b/components/AIMode/chat/ChatPane.tsx index ebd06e062..0589581d7 100644 --- a/components/AIMode/chat/ChatPane.tsx +++ b/components/AIMode/chat/ChatPane.tsx @@ -13,7 +13,6 @@ import { conversationTitleFor } from './conversationTitle'; import { Button } from '@/components/ui/Button'; import { ChatTranscriptSkeleton } from '@/components/skeletons/AIModeSkeleton'; import { cn } from '@/utils/styles'; -import { layoutFor } from '../AIModeContext'; import type { AIModeChatState } from '../useAIModeChat'; import { AI_MODE_GREETING } from '../copy'; import { StartContextChips } from '../start/StartContextChips'; @@ -28,6 +27,11 @@ import { DocumentChatEmptyState } from './DocumentChatEmptyState'; 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. + */ + readonly showTitle: boolean; /** Header controls seated right of the title — the document toggle. */ readonly headerActions?: ReactNode; /** Below the tablet breakpoint the list is a drawer; this opens it. */ @@ -43,6 +47,7 @@ interface ChatPaneProps { /** The middle pane: transcript, live progress, and the composer. */ export function ChatPane({ state, + showTitle, headerActions, onOpenConversations, documentCard, @@ -96,9 +101,6 @@ export function ChatPane({ listBlocked || chatUnavailable || (chatId != null && chat.access === 'loading'); const { currentTitle, title, loading: titleLoading } = conversationTitleFor(state); - // With the chat as the main pane the workspace's top strip already names - // it, so the pane's own header carries only the controls. - const chatIsMain = layoutFor(state.target) === 'chat'; const modelControls = ( - ) : chatIsMain ? ( + ) : !showTitle ? ( + // The strip names it; the header carries only the controls. ) : titleLoading ? (
diff --git a/components/AIMode/chat/ConversationTitleField.tsx b/components/AIMode/chat/ConversationTitleField.tsx index fc72c4f23..b214b9bb4 100644 --- a/components/AIMode/chat/ConversationTitleField.tsx +++ b/components/AIMode/chat/ConversationTitleField.tsx @@ -13,7 +13,8 @@ interface ConversationTitleFieldProps { /** * Inline title editor: Enter or blur commits, Escape cancels. Escape is - * claimed here so the overlay's own Esc handler doesn't close it. + * claimed here so nothing around the field — the lists' panel, a menu — also + * acts on it. */ export function ConversationTitleField({ initialValue, diff --git a/components/AIMode/copy.ts b/components/AIMode/copy.ts index b6c3c9463..5b02eb24f 100644 --- a/components/AIMode/copy.ts +++ b/components/AIMode/copy.ts @@ -7,8 +7,9 @@ import type { FundingIntent } from '@/components/Funding/fundingDirection'; export const AI_MODE_NAME = 'Workspace'; /** - * What the new-conversation screen is called, in the top strip and on the - * sidebar's button: named for what it will produce, not for the chat. + * 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. */ export const newConversationTitle = (intent: FundingIntent): string => intent === 'fund' ? 'New RFP' : 'New proposal'; diff --git a/components/AIMode/document/DocumentPane.tsx b/components/AIMode/document/DocumentPane.tsx index aba3672d8..0ce27956f 100644 --- a/components/AIMode/document/DocumentPane.tsx +++ b/components/AIMode/document/DocumentPane.tsx @@ -13,7 +13,6 @@ import { import { ButtonGroup } from '@/components/ui/ButtonGroup'; import { PublishingHostProvider, type PublishingHost } from '@/contexts/PublishingHostContext'; import { useNoteDetailsSaver } from '@/hooks/useNoteDetailsSaver'; -import { useAIMode } from '../AIModeContext'; import { NoteReviewControls } from '@/components/Notebook/NoteReview/NoteReviewControls'; import { noteDiffPersistableDoc } from '@/components/Notebook/NoteReview/noteDiffOverlay'; import { useNoteAgentReview } from '@/components/Notebook/NoteReview/useNoteAgentReview'; @@ -49,7 +48,7 @@ interface DocumentPaneProps { readonly presentation?: 'pane' | 'drawer'; /** * Where the pane places its publish controls when it is a column: an - * element in the workspace header. The drawer keeps them inline. + * element in the workspace's strip. The drawer keeps them inline. */ readonly publishControlsSlot?: HTMLElement | null; /** The editor is never editable — the mobile drawer. */ @@ -126,9 +125,6 @@ export function DocumentPane({ // the live editor and the note's single details writer through the host // seam rather than the notebook context. const { saveDetailsSoon, saveDetailsNow } = useNoteDetailsSaver(noteId ?? undefined); - // Publishing leaves for the work's page; the workspace gets out of the way - // first, so the page is what the user sees arrive. - const { close: closeWorkspace } = useAIMode(); const publishingHost = useMemo( () => ({ note: content, @@ -136,9 +132,8 @@ export function DocumentPane({ isLoading: loading, saveDetailsSoon, saveDetailsNow, - onPublished: closeWorkspace, }), - [content, editor, loading, saveDetailsSoon, saveDetailsNow, closeWorkspace] + [content, editor, loading, saveDetailsSoon, saveDetailsNow] ); const persistEditorState = useCallback(async () => { diff --git a/components/AIMode/shell/AIModeHeader.tsx b/components/AIMode/shell/AIModeHeader.tsx index cb7c03f68..cc4e85acc 100644 --- a/components/AIMode/shell/AIModeHeader.tsx +++ b/components/AIMode/shell/AIModeHeader.tsx @@ -1,7 +1,7 @@ 'use client'; -import { TopBarBackButton } from '@/app/layouts/topbar/TopBarBackButton'; -import { AI_MODE_NAME } from '../copy'; +import { PanelLeft } from 'lucide-react'; +import { cn } from '@/utils/styles'; interface AIModeHeaderProps { /** @@ -9,23 +9,51 @@ interface AIModeHeaderProps { * the document's when it is, "New RFP" or "New proposal" on the start screen. */ readonly title: string; + /** Receives the element the document pane renders its publish controls into. */ + readonly publishControlsRef?: (element: HTMLDivElement | null) => void; /** - * Receives the element the document pane renders its publish controls - * into. Absent below the tablet breakpoint, where the drawer keeps them. + * Shows or hides the conversations and drafts while the app's left column + * is an icon rail with no room for them; absent once the column holds them. */ - readonly publishControlsRef?: (element: HTMLDivElement | null) => void; - /** Leaves the workspace for the page it opened over. */ - readonly onBack: () => void; + readonly onToggleList?: () => void; + readonly listOpen?: boolean; } -/** The workspace's top strip: the way back, what is open, and the open document's publishing state. */ -export function AIModeHeader({ title, publishControlsRef, onBack }: AIModeHeaderProps) { - const backLabel = `Leave ${AI_MODE_NAME}`; +/** + * The workspace's strip, under the app's top bar: what is open, and the open + * document's publishing state. The way out is the top bar's back arrow. + */ +export function AIModeHeader({ + title, + publishControlsRef, + onToggleList, + listOpen = false, +}: AIModeHeaderProps) { + const listLabel = listOpen + ? 'Hide conversations and documents' + : 'Show conversations and documents'; return ( -
+ // The top bar's gutters, so the title sits under its back arrow and the + // publish controls under its user controls. +
- - + {onToggleList && ( + + )}

{title}

diff --git a/components/AIMode/shell/WorkspacePanes.tsx b/components/AIMode/shell/WorkspacePanes.tsx index ae05fe773..c0111066c 100644 --- a/components/AIMode/shell/WorkspacePanes.tsx +++ b/components/AIMode/shell/WorkspacePanes.tsx @@ -2,82 +2,63 @@ import type { ReactNode } from 'react'; import { PanelLeft } from 'lucide-react'; +import { PublishMenu } from '@/app/layouts/PublishMenu'; import { ResizeHandle } from '@/components/ui/ResizeHandle'; import { SwipeableDrawer } from '@/components/ui/SwipeableDrawer'; import type { useResizableWidth } from '@/hooks/useResizableWidth'; import { cn } from '@/utils/styles'; -import type { WorkspaceLayout } from '../AIModeContext'; - -/** Above the overlay (9500), below BaseModal (9999). */ -const AI_MODE_DRAWER_Z_INDEX = 9600; +import type { WorkspaceLayout } from '../workspaceUrl'; type ResizableWidth = ReturnType; interface WorkspacePanesProps { readonly layout: WorkspaceLayout; - readonly sidebar: ReactNode; readonly chat: ReactNode; /** The document column; null while no document is open. */ readonly document: ReactNode | null; /** The document as the drawer shows it below the tablet breakpoint. */ readonly documentDrawer: ReactNode | null; readonly isBelowTablet: boolean; - readonly sidebarWidth: ResizableWidth & { readonly min: number; readonly max: number }; readonly sideWidth: ResizableWidth & { readonly min: number; readonly max: number }; - /** Below the tablet breakpoint the sidebar is a screen of its own; this shows it. */ + /** + * The conversations and drafts, for the widths at which the app's left + * column has no room for them; null once it holds them. + */ + readonly lists: ReactNode | null; + /** Shows the lists: a panel over the panes, or below the tablet breakpoint the whole screen. */ readonly listOpen: boolean; readonly onCloseList: () => void; readonly onCloseDocumentDrawer: () => void; - /** The overlay's root, which the drawers render inside so they stay live. */ - readonly container: HTMLElement | null; } /** - * The three columns: sidebar, chat, document. The chat and the document keep - * their places in the tree whichever is the main pane — swapping them would - * remount the editor and the transcript — and trade places on screen with - * flex order. Both side panes drag; the main pane takes the rest. Below the - * tablet breakpoint the sidebar takes the whole screen when asked for and - * the document lives in a bottom drawer. + * The chat and the document, side by side. They keep their places in the + * tree whichever is the main pane — swapping them would remount the editor + * and the transcript — and trade places on screen with flex order. The side + * pane drags; the main pane takes the rest. Below the tablet breakpoint the + * document lives in a bottom drawer. Where the app's left column cannot hold + * the lists they open over the panes. */ export function WorkspacePanes({ layout, - sidebar, chat, document, documentDrawer, isBelowTablet, - sidebarWidth, sideWidth, + lists, listOpen, onCloseList, onCloseDocumentDrawer, - container, }: WorkspacePanesProps) { const documentOpen = document != null; - // With no document there is nothing to trade places with: the chat fills the space. - const chatIsMain = layout === 'chat' || !documentOpen; + // With no document beside it there is nothing to trade places with: the chat fills the space. + const chatIsMain = layout === 'chat' || !documentOpen || isBelowTablet; + const showLists = lists != null && listOpen; return ( <>
- -
)} - {listOpen && isBelowTablet && ( + {showLists && isBelowTablet && (
-
+
-
{sidebar}
+ {/* A phone has no left column, so the way to start something new leads the lists. */} +
+ +
+
{lists}
)} + + {/* Between the tablet breakpoint and the width at which the left + column holds the lists, they slide over the panes from its edge. + The strip's button, which stays clear of the scrim, also closes it. */} + {showLists && !isBelowTablet && ( + <> + - {/* The drawer portals to the body, so it needs to stack above this - overlay (z-9500) while staying under BaseModal (9999). */} {documentOpen && isBelowTablet && documentDrawer} diff --git a/components/AIMode/shell/useModalOverlayBehavior.ts b/components/AIMode/shell/useModalOverlayBehavior.ts deleted file mode 100644 index 913fcdc51..000000000 --- a/components/AIMode/shell/useModalOverlayBehavior.ts +++ /dev/null @@ -1,78 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; - -/** - * A modal that portals outside the overlay (BaseModal, a drawer) is showing. - * Closed drawers stay mounted off-screen with `role="dialog"`, so presence in - * the DOM is not enough — the box has to intersect the viewport. - */ -function isForeignDialogOpen(overlay: HTMLElement): boolean { - return Array.from(document.querySelectorAll('[role="dialog"]')).some((el) => { - if (overlay.contains(el)) return false; - const rect = el.getBoundingClientRect(); - return ( - rect.width > 0 && - rect.height > 0 && - rect.bottom > 0 && - rect.right > 0 && - rect.top < window.innerHeight && - rect.left < window.innerWidth - ); - }); -} - -interface ModalOverlayBehaviorOptions { - /** The overlay's root; nothing happens until it is mounted. */ - readonly rootEl: HTMLElement | null; - readonly onEscape: () => void; -} - -/** - * What makes a full-viewport overlay a real modal: it takes focus, everything - * else at the top level goes inert while it is open (so nothing behind it — - * a notebook editor that autofocuses late, say — can take focus or keys), - * the page behind it stops scrolling, and Esc closes it unless something - * inside already claimed the key (a menu, a modal that portals outside). - * Layers that mount later (menus, tooltips, modals) append after and stay - * live; the overlay's own drawers render inside it. - */ -export function useModalOverlayBehavior({ rootEl, onEscape }: ModalOverlayBehaviorOptions) { - useEffect(() => { - if (!rootEl) return; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Escape' || event.defaultPrevented) return; - if (isForeignDialogOpen(rootEl)) return; - onEscape(); - }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [rootEl, onEscape]); - - useEffect(() => { - if (!rootEl) return; - // Something inside may already have taken focus — the start screen's - // composer — and keeps it; the root is only the fallback. - if (!rootEl.contains(document.activeElement)) rootEl.focus(); - const inerted: Element[] = []; - for (const child of Array.from(document.body.children)) { - if (child === rootEl || child.tagName === 'SCRIPT' || child.tagName === 'NEXTJS-PORTAL') { - continue; - } - if (child.hasAttribute('inert')) continue; - child.setAttribute('inert', ''); - inerted.push(child); - } - return () => { - for (const child of inerted) child.removeAttribute('inert'); - }; - }, [rootEl]); - - useEffect(() => { - const previous = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - return () => { - document.body.style.overflow = previous; - }; - }, []); -} diff --git a/components/AIMode/sidebar/ConversationMenu.tsx b/components/AIMode/sidebar/ConversationMenu.tsx index d0e3ed824..c3eb6bc27 100644 --- a/components/AIMode/sidebar/ConversationMenu.tsx +++ b/components/AIMode/sidebar/ConversationMenu.tsx @@ -84,7 +84,6 @@ export function ConversationMenu({ - {/* BaseModal, not Modal: it stacks at 9999, above the AI Mode overlay. */} setConfirming(false)} diff --git a/components/AIMode/sidebar/DraftsSection.tsx b/components/AIMode/sidebar/DraftsSection.tsx index 957ea0cfb..c3ae0ad23 100644 --- a/components/AIMode/sidebar/DraftsSection.tsx +++ b/components/AIMode/sidebar/DraftsSection.tsx @@ -26,7 +26,7 @@ function DraftIcon({ note }: { readonly note: Note }) {