From d088fb1a224ccdabaab311f652f7c9730c6deb1c Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Tue, 6 Oct 2026 17:10:42 -0400 Subject: [PATCH 1/2] A new draft's document is gray from the first message, not after a flash A sent message now counts as the assistant working from the moment it leaves: while its chat is being created, while it waits for that chat to open, and while it is posted. An empty document shows "Starting the document" from then on, even before the note has loaded, instead of flashing a skeleton and the empty page until the turn shows up. It stays until the draft starts streaming in; a turn that writes nothing leaves the empty document. Co-Authored-By: Claude Opus 5.5 --- components/AIMode/WorkspacePage.tsx | 1 + components/AIMode/document/DocumentPane.tsx | 12 +++---- .../AIMode/document/useAIModeDocument.ts | 31 +++++++++++++++++-- hooks/useChatSession.ts | 6 ++++ 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/components/AIMode/WorkspacePage.tsx b/components/AIMode/WorkspacePage.tsx index 961a0bdaa..938aa83fa 100644 --- a/components/AIMode/WorkspacePage.tsx +++ b/components/AIMode/WorkspacePage.tsx @@ -80,6 +80,7 @@ function Workspace() { chat: state.chat.chat, latestExecution, assistantWorking: turnActive || state.anyTurnActive, + messagePending: state.messagePending, }); // ---- the document's width: it drags, the chat takes the rest ---- diff --git a/components/AIMode/document/DocumentPane.tsx b/components/AIMode/document/DocumentPane.tsx index 722ace455..2b3b4a472 100644 --- a/components/AIMode/document/DocumentPane.tsx +++ b/components/AIMode/document/DocumentPane.tsx @@ -163,10 +163,10 @@ export function DocumentPane({ // mid-edit would make its next edit_note stale and the review jumpy. const locked = writing || editorLostContent; - // The assistant is writing the first version: nothing to show in the - // editor yet, so the whole pane becomes the progress screen. - const startingDocument = - status === 'working' && !document.hasWrittenVersion && review.review == null; + // The assistant is about to write the first version: nothing to show in + // the editor yet, so the whole pane is the progress screen from the moment + // the message is sent, even before the note has loaded. + const startingDocument = document.starting && review.review == null; const openDetails = useCallback(() => onViewChange('details'), [onViewChange]); @@ -270,12 +270,12 @@ export function DocumentPane({ Try again + ) : startingDocument ? ( + ) : loading || content == null ? (
- ) : startingDocument ? ( - ) : (
(null); @@ -213,13 +224,26 @@ export function useAIModeDocument({ '' ).trim(); + // A sent message counts as work from the moment it leaves: the turn it + // starts takes a moment to show up, and the document must not read as + // idle (or empty) in between. + const working = turnActive || messagePending; const status: DocumentStatus = useMemo(() => { if (noteId == null) return 'absent'; if (draftText != null) return 'drafting'; - if (turnActive) return 'working'; + if (working) return 'working'; if (content != null && !hasWrittenVersion) return 'empty'; return 'settled'; - }, [noteId, draftText, turnActive, content, hasWrittenVersion]); + }, [noteId, draftText, working, content, hasWrittenVersion]); + + // Before the note has loaded, only a message sent from here says it is + // about to be written: a turn found running on a document just opened may + // be editing one that already has text. + const starting = + noteId != null && + draftText == null && + !hasWrittenVersion && + (content == null ? messagePending : working); return { note, @@ -232,6 +256,7 @@ export function useAIModeDocument({ error, missing, status, + starting, hasWrittenVersion, draftText, draftBlocks, diff --git a/hooks/useChatSession.ts b/hooks/useChatSession.ts index 7d793d4ed..bfe35c52e 100644 --- a/hooks/useChatSession.ts +++ b/hooks/useChatSession.ts @@ -73,6 +73,11 @@ export interface ChatSession { readonly clearNotice: () => void; /** A brand-new chat is being created for the first message. */ readonly creatingChat: boolean; + /** + * A message is on its way and its turn has not shown up yet: its chat is + * being created, it waits for that chat to open, or it is being posted. + */ + readonly messagePending: boolean; /** * Sending would be refused: the allowance is unknown or spent, or a tier * that picks its model has no catalog yet to pick from. @@ -385,6 +390,7 @@ export function useChatSession({ notice, clearNotice, creatingChat, + messagePending: creatingChat || queuedMessage != null || chat.pendingSend !== null, sendBlocked, composerBusy, canStop, From 64b9467cbc4749169d0b0d8e6c184f8c746fa605 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Tue, 6 Oct 2026 21:43:53 -0400 Subject: [PATCH 2/2] Workspace feedback: hamburger menu, Publish hover, phone document and publish dialog fixes Co-Authored-By: Claude Opus 5.5 --- app/endowment/components/EndowmentTopBar.tsx | 11 +- app/endowment/layout.tsx | 8 +- app/globals.css | 6 + app/layouts/LeftSidebar.tsx | 27 ++-- app/layouts/Navigation.tsx | 21 +-- app/layouts/PageLayout.tsx | 6 +- app/layouts/PublishMenu.tsx | 6 +- app/layouts/TopBar.tsx | 25 ++-- .../components/LeftSidebarContainer.tsx | 35 +++-- app/layouts/components/MobileOverlay.tsx | 2 +- app/layouts/components/SidebarDocuments.tsx | 10 +- app/layouts/components/TopBarContainer.tsx | 4 +- components/AIMode/WorkspacePage.tsx | 75 +++++------ components/AIMode/chat/ChatMenu.tsx | 4 +- components/AIMode/chat/ChatPane.tsx | 27 +--- components/AIMode/document/DocumentPane.tsx | 111 +++------------- .../AIMode/document/PublishControls.tsx | 22 ---- components/AIMode/document/PublishDialog.tsx | 122 ++++++++---------- components/AIMode/document/PublishPill.tsx | 118 ++++++++++++++--- components/AIMode/shell/WorkspacePanes.tsx | 32 +---- components/AIMode/shell/WorkspaceTopBar.tsx | 43 +----- .../PublishingForm/PublishStatusPill.tsx | 93 ------------- .../components/EndDateSection.tsx | 8 +- .../GrantApplicationVisibilitySection.tsx | 7 +- .../PreregistrationPrivacySection.tsx | 5 +- components/Notebook/PublishingForm/index.tsx | 1 - components/ui/SwipeableDrawer.tsx | 21 +-- contexts/TopBarSlotContext.tsx | 11 +- tailwind.config.ts | 20 +++ 29 files changed, 333 insertions(+), 548 deletions(-) delete mode 100644 components/AIMode/document/PublishControls.tsx delete mode 100644 components/Notebook/PublishingForm/PublishStatusPill.tsx diff --git a/app/endowment/components/EndowmentTopBar.tsx b/app/endowment/components/EndowmentTopBar.tsx index 339e7193b..b74cd23d4 100644 --- a/app/endowment/components/EndowmentTopBar.tsx +++ b/app/endowment/components/EndowmentTopBar.tsx @@ -111,14 +111,9 @@ export function EndowmentTopBar() { opacity 0.25s ease, transform 0.25s ease; } - /* Clear the app nav rail, which is 70px from the tablet breakpoint and - 240px once it expands at sidebar-compact. Below tablet the rail is - off-canvas, so the bar spans the full width. */ - @media (min-width: 768px) { - .endowment-topbar { - left: 70px; - } - } + /* Clear the app's left column, which is 240px from sidebar-compact + up. Narrower, the sidebar is off-canvas, so the bar spans the full + width. */ @media (min-width: 1240px) { .endowment-topbar { left: 240px; diff --git a/app/endowment/layout.tsx b/app/endowment/layout.tsx index 6216c5233..11efc302f 100644 --- a/app/endowment/layout.tsx +++ b/app/endowment/layout.tsx @@ -19,10 +19,10 @@ export default function EndowmentLayout({ children }: Readonly<{ children: React return (
{/* The landing page keeps its own top bar and footer, so it stays outside - PageLayout; the nav rail is mounted directly to preserve continuity - with the rest of the app. Passing `isOpen={false}` leaves it - off-canvas below the tablet breakpoint, where there is no menu button - to reopen it. */} + PageLayout; the app's left column is mounted directly to preserve + continuity with the rest of the app. Passing `isOpen={false}` leaves + it off-canvas below 1240px, where this page has no menu button to + open it. */}
{children}
diff --git a/app/globals.css b/app/globals.css index ba79ec5ef..83e0a4558 100644 --- a/app/globals.css +++ b/app/globals.css @@ -183,6 +183,12 @@ mark { padding-top: calc(56px + var(--top-bar-height)) !important; } + /* The menu hangs from the bottom of the same stack. */ + .left-sidebar-with-promo-banner { + top: calc(56px + var(--top-bar-height)) !important; + height: calc(100vh - 56px - var(--top-bar-height)) !important; + } + /* Reserve space for the fixed MobileBottomNav (h-16 + safe-area inset), so the last of the page's content isn't stuck behind it. */ .page-layout-with-mobile-bottom-nav { diff --git a/app/layouts/LeftSidebar.tsx b/app/layouts/LeftSidebar.tsx index 7d6590b52..707f92df2 100644 --- a/app/layouts/LeftSidebar.tsx +++ b/app/layouts/LeftSidebar.tsx @@ -15,8 +15,8 @@ import { SidebarDocuments } from './components/SidebarDocuments'; interface LeftSidebarProps { forceMinimize?: boolean; /** - * Runs once a document or a new draft is picked: on a phone the column is a - * menu, and a pick that changes the workspace in place must still close it. + * Runs once something in it is picked: below 1240px the sidebar is a menu, + * and a pick that changes the page in place must still close it. */ onNavigate?: () => void; } @@ -49,39 +49,32 @@ export const LeftSidebar: React.FC = ({ forceMinimize = false, return (
-
- -
+
+ +
-
+
-
+
{/* The nav and the user's documents scroll together, so a long list - never squeezes the nav. The documents hide while the column is the - icon rail; the workspace has its own way to them there. */} + never squeezes the nav. A forced icon rail has no room for them. */}
- +
{/* The scroll area above is flex-1, so this sits at the bottom of the column. @@ -92,7 +85,7 @@ export const LeftSidebar: React.FC = ({ forceMinimize = false,
-
+
diff --git a/app/layouts/Navigation.tsx b/app/layouts/Navigation.tsx index 1917c5614..eb57e5165 100644 --- a/app/layouts/Navigation.tsx +++ b/app/layouts/Navigation.tsx @@ -38,6 +38,8 @@ interface NavigationProps { forceMinimize?: boolean; /** A parent scrolls the nav along with what follows it, so it neither grows nor scrolls itself. */ inScrollArea?: boolean; + /** Runs once a link is followed: the menu holding the nav closes. */ + onNavigate?: () => void; } const navIconMap: Record = { @@ -60,6 +62,7 @@ export const Navigation: React.FC = ({ onUnimplementedFeature, forceMinimize = false, inScrollArea = false, + onNavigate, }) => { const navigationItems: NavigationItem[] = [ { @@ -100,9 +103,7 @@ export const Navigation: React.FC = ({ const getButtonStyles = (isActive: boolean) => { return cn( 'flex w-full items-center rounded-lg px-3 py-2.5 text-[15px] font-medium transition-colors', - forceMinimize - ? '!justify-center !px-2' - : 'tablet:max-sidebar-compact:!justify-center tablet:max-sidebar-compact:!px-2', + forceMinimize && '!justify-center !px-2', isActive ? 'bg-primary-50 font-semibold text-primary-600' : 'font-medium text-gray-700 hover:bg-gray-50' @@ -156,21 +157,23 @@ export const Navigation: React.FC = ({ if (item.requiresAuth) { e.preventDefault(); - executeAuthenticatedAction(() => router.push(item.href)); + executeAuthenticatedAction(() => { + router.push(item.href); + onNavigate?.(); + }); return; } + onNavigate?.(); }; const isHomeIcon = item.isFontAwesome && item.iconKey === 'home'; const iconContainerClass = cn( 'flex h-[26px] w-[26px] flex-shrink-0 items-center justify-center', - forceMinimize ? 'mr-0' : 'mr-3.5 tablet:max-sidebar-compact:!mr-0' + forceMinimize ? 'mr-0' : 'mr-3.5' ); - const textContainerClass = forceMinimize - ? 'hidden' - : 'flex w-full min-w-0 items-center tablet:max-sidebar-compact:!hidden'; + const textContainerClass = forceMinimize ? 'hidden' : 'flex w-full min-w-0 items-center'; const label = {item.label}; @@ -215,7 +218,7 @@ export const Navigation: React.FC = ({ className={cn( 'px-3 pt-6', !inScrollArea && 'flex-1 overflow-y-auto', - forceMinimize ? '!px-2' : 'tablet:max-sidebar-compact:!px-2' + forceMinimize && '!px-2' )} >
diff --git a/app/layouts/PageLayout.tsx b/app/layouts/PageLayout.tsx index e650654cf..f1ff73560 100644 --- a/app/layouts/PageLayout.tsx +++ b/app/layouts/PageLayout.tsx @@ -109,7 +109,11 @@ function PageLayoutInner({ - + {fullBleed ? ( // Offset from the fixed top bar exactly as the scrollport below is, diff --git a/app/layouts/PublishMenu.tsx b/app/layouts/PublishMenu.tsx index d34699fc9..9c65b8f66 100644 --- a/app/layouts/PublishMenu.tsx +++ b/app/layouts/PublishMenu.tsx @@ -47,7 +47,7 @@ export const PublishMenu: React.FC = ({ // Regular trigger for standard mode const standardTrigger = ( ); - // Compact trigger for minimized sidebar + // Compact trigger for a sidebar forced into the icon rail const compactTrigger = ( {leftSlot ? (
{leftSlot}
@@ -110,8 +115,6 @@ export function TopBar({ onMenuClick }: TopBarProps) { {showBackButton && } - {leading} - {pageInfo && ( void; + /** The phone's promo banner sits above the top bar, so the menu starts lower. */ + withPromoBanner?: boolean; } -export function LeftSidebarContainer({ isOpen, onClose }: LeftSidebarContainerProps) { +/** + * The app's left column from 1240px up. Narrower than that there is no + * column: the top bar's menu button slides the whole sidebar in, under the + * top bar, over the page. + */ +export function LeftSidebarContainer({ + isOpen, + onClose, + withPromoBanner = false, +}: LeftSidebarContainerProps) { return (
diff --git a/app/layouts/components/MobileOverlay.tsx b/app/layouts/components/MobileOverlay.tsx index e198ae091..e94ea84e0 100644 --- a/app/layouts/components/MobileOverlay.tsx +++ b/app/layouts/components/MobileOverlay.tsx @@ -13,7 +13,7 @@ export function MobileOverlay({ show, visible, onClose }: MobileOverlayProps) {
); diff --git a/app/layouts/components/SidebarDocuments.tsx b/app/layouts/components/SidebarDocuments.tsx index a2c5e63af..9cfba53cd 100644 --- a/app/layouts/components/SidebarDocuments.tsx +++ b/app/layouts/components/SidebarDocuments.tsx @@ -19,8 +19,6 @@ const PUBLISHED_SHOWN = 5; interface SidebarDocumentsProps { /** Runs after any choice that should put a drawer or an overlay away. */ readonly onNavigate?: () => void; - /** A rule above the first heading, setting the documents apart from the nav. */ - readonly divider?: boolean; readonly className?: string; } @@ -29,7 +27,7 @@ interface SidebarDocumentsProps { * still a draft, with a "+" to start another, and what has been published. * Each opens in the workspace; the one open there is highlighted. */ -export function SidebarDocuments({ onNavigate, divider = true, className }: SidebarDocumentsProps) { +export function SidebarDocuments({ onNavigate, className }: SidebarDocumentsProps) { const { drafts, published, status, refresh } = useFundingDocuments(); const { startNew, openDraft } = useFundingDrafting(); const target = useOptionalAIMode()?.target; @@ -61,11 +59,7 @@ export function SidebarDocuments({ onNavigate, divider = true, className }: Side return (
- {divider ? ( -