From e3bff64b8572a63c8d3d8dcef52b9ffedbab0638 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Mon, 21 Sep 2026 22:27:31 -0400 Subject: [PATCH] Workspace review fixes: one-line sidebar rows, no layout toggle, no notebook link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The All details block no longer wraps: its width allows for the label, the folded count and the tally on one line each, and no block label wraps any more. - The Chat first / Document first toggle is gone. A document opened from the sidebar is always document first; a conversation is chat first. The ?aiView parameter goes with it. - "Open in notebook" is gone from the document pane: the workspace is meant to replace the notebook, not lead back to it. - Sidebar rows show their title only — no timestamp, kind or status under it — in both groupings. Verified with type-check, eslint and prettier only. Co-Authored-By: Claude Fable 5.1 --- components/AIMode/AIModeContext.tsx | 70 ++++++------------- components/AIMode/AIModeOverlay.tsx | 8 +-- components/AIMode/document/DocumentPane.tsx | 25 +------ .../document/details/AllDetailsBlock.tsx | 11 +-- .../AIMode/document/details/DetailBlock.tsx | 4 +- .../document/details/DetailBlockStrip.tsx | 16 +---- .../AIMode/document/useAIModeDocument.ts | 7 -- components/AIMode/shell/AIModeHeader.tsx | 43 +----------- .../AIMode/sidebar/ConversationsSection.tsx | 2 - .../AIMode/sidebar/DocumentsSection.tsx | 25 +++---- components/AIMode/sidebar/SidebarRow.tsx | 16 ++--- components/AIMode/useAIModeChat.ts | 13 ++-- 12 files changed, 59 insertions(+), 181 deletions(-) diff --git a/components/AIMode/AIModeContext.tsx b/components/AIMode/AIModeContext.tsx index d76b232ff..72fa8fee9 100644 --- a/components/AIMode/AIModeContext.tsx +++ b/components/AIMode/AIModeContext.tsx @@ -17,13 +17,11 @@ import { usePathname, useSearchParams } from 'next/navigation'; /** * `?ai=1` opens the workspace. `aiChat=` selects a conversation; * `aiNote=` opens a document, with `aiChat` then naming the chat on that - * document; `aiView=doc` puts the document in the main pane. + * document. */ 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 @@ -34,31 +32,34 @@ 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. */ +/** + * Which pane is the main one; the other sits at a fixed width beside it. A + * document target puts the document first, a conversation the chat. + */ export type WorkspaceLayout = 'chat' | 'document'; +export const layoutFor = (target: WorkspaceTarget): WorkspaceLayout => + target.kind === 'document' ? 'document' : 'chat'; + interface AIModeUrlState { 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' }; +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; close: () => void; toggle: () => void; - /** Open on a target, laid out as its kind reads best unless told otherwise. */ - selectTarget: (target: WorkspaceTarget, layout?: WorkspaceLayout) => void; + /** Open on a target. */ + selectTarget: (target: WorkspaceTarget) => 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(null); @@ -69,38 +70,23 @@ function parseId(raw: string | null): number | null { 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 }; + 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, - ]) { + for (const key of [AI_MODE_OPEN_PARAM, AI_MODE_CHAT_PARAM, AI_MODE_NOTE_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); - } + if (state.target.kind === 'document') params.set(AI_MODE_NOTE_PARAM, String(state.target.noteId)); } const sameTarget = (a: WorkspaceTarget, b: WorkspaceTarget): boolean => @@ -116,7 +102,7 @@ const sameTarget = (a: WorkspaceTarget, b: WorkspaceTarget): boolean => */ function AIModeUrlSync({ onChange }: { readonly onChange: (state: AIModeUrlState) => void }) { const searchParams = useSearchParams(); - const { isOpen, layout, target } = readUrlState(searchParams); + const { isOpen, 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 @@ -124,11 +110,10 @@ function AIModeUrlSync({ onChange }: { readonly onChange: (state: AIModeUrlState useEffect(() => { onChange({ isOpen, - layout, target: noteId != null ? { kind: 'document', noteId, chatId } : { kind: 'conversation', chatId }, }); - }, [isOpen, layout, chatId, noteId, onChange]); + }, [isOpen, chatId, noteId, onChange]); return null; } @@ -152,9 +137,9 @@ export function AIModeProvider({ children }: { readonly children: ReactNode }) { // 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>(null); + const lastRef = useRef(null); if (state.isOpen && !sameTarget(state.target, NEW_CONVERSATION)) { - lastRef.current = { target: state.target, layout: state.layout }; + lastRef.current = state.target; } const pathnameRef = useRef(pathname); @@ -179,19 +164,14 @@ export function AIModeProvider({ children }: { readonly children: ReactNode }) { }, []); const selectTarget = useCallback( - (target: WorkspaceTarget, layout: WorkspaceLayout = defaultLayout(target)) => { + (target: WorkspaceTarget) => { if (sameTarget(target, NEW_CONVERSATION)) lastRef.current = null; - navigate({ isOpen: true, target, layout }); + navigate({ isOpen: true, target }); }, [navigate] ); const open = useCallback(() => { - const last = lastRef.current; - navigate( - last - ? { isOpen: true, target: last.target, layout: last.layout } - : { isOpen: true, target: NEW_CONVERSATION, layout: 'chat' } - ); + navigate({ isOpen: true, target: lastRef.current ?? NEW_CONVERSATION }); }, [navigate]); const close = useCallback(() => navigate(CLOSED), [navigate]); const selectChat = useCallback( @@ -205,18 +185,14 @@ export function AIModeProvider({ children }: { readonly children: ReactNode }) { 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( - () => ({ ...state, open, close, toggle, selectTarget, selectChat, selectDocument, setLayout }), - [state, open, close, toggle, selectTarget, selectChat, selectDocument, setLayout] + () => ({ ...state, open, close, toggle, selectTarget, selectChat, selectDocument }), + [state, open, close, toggle, selectTarget, selectChat, selectDocument] ); return ( diff --git a/components/AIMode/AIModeOverlay.tsx b/components/AIMode/AIModeOverlay.tsx index a7f3bf826..009f75f29 100644 --- a/components/AIMode/AIModeOverlay.tsx +++ b/components/AIMode/AIModeOverlay.tsx @@ -6,7 +6,7 @@ import { PanelRight } from 'lucide-react'; import { cn } from '@/utils/styles'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useResizableWidth } from '@/hooks/useResizableWidth'; -import { useAIMode } from './AIModeContext'; +import { layoutFor, useAIMode } from './AIModeContext'; import { ChatPane } from './chat/ChatPane'; import { DocumentCard } from './chat/DocumentCard'; import { DocumentPane, type DocumentPaneView } from './document/DocumentPane'; @@ -34,9 +34,11 @@ const DOCUMENT_DEFAULT_SHARE = 0.55; * from inside it still render on top. */ export function AIModeOverlay() { - const { close, layout, setLayout } = useAIMode(); + const { close } = useAIMode(); 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. const [listOpen, setListOpen] = useState(false); @@ -166,8 +168,6 @@ export function AIModeOverlay() { diff --git a/components/AIMode/document/DocumentPane.tsx b/components/AIMode/document/DocumentPane.tsx index 8f7e92243..f218122c4 100644 --- a/components/AIMode/document/DocumentPane.tsx +++ b/components/AIMode/document/DocumentPane.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import type { Editor } from '@tiptap/react'; -import { ExternalLink } from 'lucide-react'; import { BlockEditorClientWrapper } from '@/components/Editor/components/BlockEditor/components/BlockEditorClientWrapper'; import { NoteReviewBanner } from '@/components/Notebook/NoteReview/NoteReviewBanner'; import { @@ -31,9 +30,6 @@ import type { AIModeDocument } from './useAIModeDocument'; /** The document itself, or the full publishing details form. */ export type DocumentPaneView = 'document' | 'details'; -/** Room the "Open in notebook" button takes at the end of the block strip. */ -const NOTEBOOK_LINK_WIDTH = 30; - /** The page column: shared by the skeleton and the document so they line up. */ const DOCUMENT_PAGE_CLASS = 'ai-mode-document mx-auto w-full max-w-[860px] px-5 py-6 tablet:!px-8 tablet:!py-8'; @@ -172,20 +168,6 @@ export function DocumentPane({ [onViewChange, view] ); - const notebookLink = document.notebookHref && ( - - - ); - return ( {/* The form lives as long as the note does, whichever view is showing: @@ -195,12 +177,7 @@ export function DocumentPane({
{presentation === 'pane' ? ( - + ) : ( )} diff --git a/components/AIMode/document/details/AllDetailsBlock.tsx b/components/AIMode/document/details/AllDetailsBlock.tsx index 1142c0057..4b32d505f 100644 --- a/components/AIMode/document/details/AllDetailsBlock.tsx +++ b/components/AIMode/document/details/AllDetailsBlock.tsx @@ -3,8 +3,9 @@ import { ListChecks } from 'lucide-react'; import { cn } from '@/utils/styles'; -/** Fixed, so the strip can count how many other blocks fit beside it. */ -export const ALL_DETAILS_BLOCK_WIDTH = 116; +/** Fixed, so the strip can count how many other blocks fit beside it. Wide + * enough for the label, the folded count and the tally on one line each. */ +export const ALL_DETAILS_BLOCK_WIDTH = 140; interface AllDetailsBlockProps { readonly done: number; @@ -37,7 +38,7 @@ export function AllDetailsBlock({ : 'border-gray-300 bg-gray-50 hover:bg-gray-100' )} > - + 0 && ( +{hiddenCount} )} - + {done} of {total} done diff --git a/components/AIMode/document/details/DetailBlock.tsx b/components/AIMode/document/details/DetailBlock.tsx index 247ee7302..ef6101097 100644 --- a/components/AIMode/document/details/DetailBlock.tsx +++ b/components/AIMode/document/details/DetailBlock.tsx @@ -88,10 +88,10 @@ function DetailBlockFace({ const Icon = config.icon; return ( <> - + diff --git a/components/AIMode/document/details/DetailBlockStrip.tsx b/components/AIMode/document/details/DetailBlockStrip.tsx index d796d4379..c4e67210a 100644 --- a/components/AIMode/document/details/DetailBlockStrip.tsx +++ b/components/AIMode/document/details/DetailBlockStrip.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState, type ReactNode } from 'react'; +import { useMemo, useState } from 'react'; import { useWatch } from 'react-hook-form'; import { usePublishingController } from '@/components/Notebook/PublishingForm'; import type { PublishingFormData } from '@/components/Notebook/PublishingForm/schema'; @@ -15,10 +15,6 @@ interface DetailBlockStripProps { /** The full details form is showing instead of the document. */ readonly detailsOpen: boolean; readonly onToggleDetails: () => void; - /** Controls kept at the strip's end, after the blocks. */ - readonly trailing?: ReactNode; - /** Width `trailing` takes, so the count of blocks that fit allows for it. */ - readonly trailingWidth?: number; } /** @@ -27,12 +23,7 @@ interface DetailBlockStripProps { * room fold into the last block, which opens the full form and says how * many it holds. One editor is open at a time. */ -export function DetailBlockStrip({ - detailsOpen, - onToggleDetails, - trailing, - trailingWidth = 0, -}: DetailBlockStripProps) { +export function DetailBlockStrip({ detailsOpen, onToggleDetails }: DetailBlockStripProps) { const { note, articleType } = usePublishingController(); const values = useWatch() as PublishingFormData; const [openBlockId, setOpenBlockId] = useState(null); @@ -48,7 +39,7 @@ export function DetailBlockStrip({ itemCount: blocks.length, minItemWidth: DETAIL_BLOCK_MIN_WIDTH, gap: GAP, - reservedWidth: ALL_DETAILS_BLOCK_WIDTH + GAP + (trailingWidth > 0 ? trailingWidth + GAP : 0), + reservedWidth: ALL_DETAILS_BLOCK_WIDTH + GAP, }); // A field is mounted once: the full form and a block's editor never show @@ -90,7 +81,6 @@ export function DetailBlockStrip({ onClick={openDetails} /> )} - {trailing}
); } diff --git a/components/AIMode/document/useAIModeDocument.ts b/components/AIMode/document/useAIModeDocument.ts index f2f8a7647..072b75dc0 100644 --- a/components/AIMode/document/useAIModeDocument.ts +++ b/components/AIMode/document/useAIModeDocument.ts @@ -47,7 +47,6 @@ export interface AIModeDocument { /** What the assistant is doing, for the in-progress row when there is no draft. */ readonly phaseLabel: string | null; /** Deep link to the note in the notebook, once its organization is known. */ - readonly notebookHref: string | null; readonly reload: () => void; } @@ -142,11 +141,6 @@ export function useAIModeDocument({ return 'settled'; }, [noteId, draftText, turnActive, content, hasWrittenVersion]); - const notebookHref = useMemo(() => { - const slug = content?.organization?.slug; - return slug && noteId != null ? `/notebook/${slug}/${noteId}` : null; - }, [content?.organization?.slug, noteId]); - return { note, content, @@ -158,7 +152,6 @@ export function useAIModeDocument({ draftBlocks, draftKey, phaseLabel, - notebookHref, reload: fetchNote, }; } diff --git a/components/AIMode/shell/AIModeHeader.tsx b/components/AIMode/shell/AIModeHeader.tsx index 63d8be270..1cccd8660 100644 --- a/components/AIMode/shell/AIModeHeader.tsx +++ b/components/AIMode/shell/AIModeHeader.tsx @@ -1,8 +1,6 @@ 'use client'; -import { FileText, MessageSquare, Sparkles, X } from 'lucide-react'; -import { ButtonGroup } from '@/components/ui/ButtonGroup'; -import type { WorkspaceLayout } from '../AIModeContext'; +import { Sparkles, X } from 'lucide-react'; import { AI_MODE_NAME } from '../copy'; interface AIModeHeaderProps { @@ -13,53 +11,16 @@ interface AIModeHeaderProps { * into. Absent below the tablet breakpoint, where the drawer keeps them. */ readonly publishControlsRef?: (element: HTMLDivElement | null) => void; - /** Which pane is the main one; absent when there is nothing to swap. */ - readonly layout?: WorkspaceLayout; - readonly onLayoutChange?: (layout: WorkspaceLayout) => void; readonly onClose: () => void; } /** The workspace's top strip: the name, the open document's publishing state, close. */ -export function AIModeHeader({ - documentTitle, - publishControlsRef, - layout, - onLayoutChange, - onClose, -}: AIModeHeaderProps) { +export function AIModeHeader({ documentTitle, publishControlsRef, onClose }: AIModeHeaderProps) { return (
{documentTitle != null && publishControlsRef && ( diff --git a/components/AIMode/sidebar/ConversationsSection.tsx b/components/AIMode/sidebar/ConversationsSection.tsx index b2a449d91..cfe8fe427 100644 --- a/components/AIMode/sidebar/ConversationsSection.tsx +++ b/components/AIMode/sidebar/ConversationsSection.tsx @@ -6,7 +6,6 @@ import { ConversationListSkeleton } from '@/components/skeletons/AIModeSkeleton' import { Button } from '@/components/ui/Button'; import type { ChatListAccess } from '@/hooks/useAgentChat'; import type { AgentChatListItem, ChatNoteRef } from '@/types/agentChat'; -import { formatTimeAgo } from '@/utils/date'; import { ConversationTitleField } from '../chat/ConversationTitleField'; import { ConversationMenu } from './ConversationMenu'; import { SidebarGroupHeading } from './SidebarGroupHeading'; @@ -90,7 +89,6 @@ export function ConversationsSection({ onSelect(item)} titleAdornment={ diff --git a/components/AIMode/sidebar/DocumentsSection.tsx b/components/AIMode/sidebar/DocumentsSection.tsx index f5d597639..d9af6946d 100644 --- a/components/AIMode/sidebar/DocumentsSection.tsx +++ b/components/AIMode/sidebar/DocumentsSection.tsx @@ -8,7 +8,7 @@ import { ConversationListSkeleton } from '@/components/skeletons/AIModeSkeleton' import { useOrganizationContext } from '@/contexts/OrganizationContext'; import { useUser } from '@/contexts/UserContext'; import { useOrganizationNotes } from '@/hooks/useOrganizationNotes'; -import { getNoteKind, isChangelogNote, NOTE_KIND_LABELS, type Note } from '@/types/note'; +import { getNoteKind, isChangelogNote, type Note } from '@/types/note'; import { SidebarGroupHeading } from './SidebarGroupHeading'; import { SidebarRow } from './SidebarRow'; @@ -90,20 +90,15 @@ export function DocumentsSection({ activeNoteId, recentNoteId, onSelect }: Docum

No documents yet.

)} - {rows.map((note) => { - const kind = getNoteKind(note); - const kindLabel = kind === 'other' ? 'Note' : NOTE_KIND_LABELS[kind]; - return ( - } - isActive={note.id === activeNoteId} - onSelect={() => onSelect(note.id)} - /> - ); - })} + {rows.map((note) => ( + } + isActive={note.id === activeNoteId} + onSelect={() => onSelect(note.id)} + /> + ))} {notes.hasMore && ( )} @@ -65,7 +57,7 @@ export function SidebarRow({ {!editing && menu && (
diff --git a/components/AIMode/useAIModeChat.ts b/components/AIMode/useAIModeChat.ts index 833f0ac83..07d69df49 100644 --- a/components/AIMode/useAIModeChat.ts +++ b/components/AIMode/useAIModeChat.ts @@ -60,7 +60,7 @@ export interface AIModeChatState extends ChatSession { * transport for a conversation and on the note's for a document. */ export function useAIModeChat(): AIModeChatState { - const { target, layout, selectTarget, selectChat } = useAIMode(); + const { target, selectTarget, selectChat } = useAIMode(); const { chatId } = target; const targetNoteId = target.kind === 'document' ? target.noteId : null; const transport = getChatTransport({ noteId: targetNoteId }); @@ -89,17 +89,12 @@ export function useAIModeChat(): AIModeChatState { const targetRef = useRef(target); targetRef.current = target; - const layoutRef = useRef(layout); - layoutRef.current = layout; const onChatCreated = useCallback( (created: AgentChat) => { const current = targetRef.current; if (current.kind === 'document') { - // The chat stays on its document, in whatever layout the user had. - selectTarget( - { kind: 'document', noteId: current.noteId, chatId: created.conversation_id }, - layoutRef.current - ); + // The chat stays on its document. + selectTarget({ kind: 'document', noteId: current.noteId, chatId: created.conversation_id }); return; } // The RFP went with the conversation it was picked for. @@ -125,7 +120,7 @@ export function useAIModeChat(): AIModeChatState { const selectConversation = useCallback( (item: AgentChatListItem) => { if (item.workflow === 'notebook_chat' && item.note) { - selectTarget({ kind: 'document', noteId: item.note.id, chatId: item.id }, 'chat'); + selectTarget({ kind: 'document', noteId: item.note.id, chatId: item.id }); } else { selectChat(item.id); }