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
70 changes: 23 additions & 47 deletions components/AIMode/AIModeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,11 @@ import { usePathname, useSearchParams } from 'next/navigation';
/**
* `?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.
* 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
Expand All @@ -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<AIModeContextValue | null>(null);
Expand All @@ -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 =>
Expand All @@ -116,19 +102,18 @@ 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
// every render's fresh object.
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;
}

Expand All @@ -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<Pick<AIModeUrlState, 'target' | 'layout'> | null>(null);
const lastRef = useRef<WorkspaceTarget | null>(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);
Expand All @@ -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(
Expand All @@ -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<AIModeContextValue>(
() => ({ ...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 (
Expand Down
8 changes: 4 additions & 4 deletions components/AIMode/AIModeOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -166,8 +168,6 @@ export function AIModeOverlay() {
<AIModeHeader
documentTitle={showDocument ? documentTitle : null}
publishControlsRef={isBelowTablet ? undefined : setPublishControlsSlot}
layout={showDocument && !isBelowTablet ? layout : undefined}
onLayoutChange={setLayout}
onClose={close}
/>

Expand Down
25 changes: 1 addition & 24 deletions components/AIMode/document/DocumentPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -172,20 +168,6 @@ export function DocumentPane({
[onViewChange, view]
);

const notebookLink = document.notebookHref && (
<a
href={document.notebookHref}
target="_blank"
rel="noopener noreferrer"
aria-label="Open in notebook"
title="Open in notebook"
style={{ width: NOTEBOOK_LINK_WIDTH }}
className="inline-flex h-[30px] shrink-0 items-center justify-center rounded-lg text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
>
<ExternalLink className="h-[15px] w-[15px]" aria-hidden="true" />
</a>
);

return (
<PublishingHostProvider value={publishingHost}>
{/* The form lives as long as the note does, whichever view is showing:
Expand All @@ -195,12 +177,7 @@ export function DocumentPane({
<PublishingFormProvider>
<div className={cn('relative flex h-full min-h-0 flex-col bg-white', className)}>
{presentation === 'pane' ? (
<DetailBlockStrip
detailsOpen={view === 'details'}
onToggleDetails={toggleDetails}
trailing={notebookLink}
trailingWidth={document.notebookHref ? NOTEBOOK_LINK_WIDTH : 0}
/>
<DetailBlockStrip detailsOpen={view === 'details'} onToggleDetails={toggleDetails} />
) : (
<DrawerStrip view={view} onViewChange={onViewChange} onOpenDetails={openDetails} />
)}
Expand Down
11 changes: 6 additions & 5 deletions components/AIMode/document/details/AllDetailsBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -37,7 +38,7 @@ export function AllDetailsBlock({
: 'border-gray-300 bg-gray-50 hover:bg-gray-100'
)}
>
<span className="flex items-center justify-between gap-1.5">
<span className="flex items-center justify-between gap-1.5 whitespace-nowrap">
<span
className={cn(
'flex items-center gap-1.5 text-[11px] font-medium',
Expand All @@ -50,13 +51,13 @@ export function AllDetailsBlock({
{hiddenCount > 0 && (
<span
title={`${hiddenCount} more ${hiddenCount === 1 ? 'detail' : 'details'} inside`}
className="rounded-full bg-gray-200 px-1.5 text-[10px] font-bold text-gray-700"
className="shrink-0 rounded-full bg-gray-200 px-1.5 text-[10px] font-bold text-gray-700"
>
+{hiddenCount}
</span>
)}
</span>
<span className="truncate text-[13px] font-semibold text-gray-900">
<span className="truncate whitespace-nowrap text-[13px] font-semibold text-gray-900">
{done} of {total} done
</span>
</button>
Expand Down
4 changes: 2 additions & 2 deletions components/AIMode/document/details/DetailBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,10 @@ function DetailBlockFace({
const Icon = config.icon;
return (
<>
<span className="flex items-center justify-between gap-1.5">
<span className="flex items-center justify-between gap-1.5 whitespace-nowrap">
<span
className={cn(
'flex items-center gap-1.5 text-[11px] font-medium',
'flex min-w-0 items-center gap-1.5 truncate text-[11px] font-medium',
open ? 'text-primary-700' : 'text-gray-600'
)}
>
Expand Down
16 changes: 3 additions & 13 deletions components/AIMode/document/details/DetailBlockStrip.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
}

/**
Expand All @@ -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<PublishingFormData>() as PublishingFormData;
const [openBlockId, setOpenBlockId] = useState<string | null>(null);
Expand All @@ -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
Expand Down Expand Up @@ -90,7 +81,6 @@ export function DetailBlockStrip({
onClick={openDetails}
/>
)}
{trailing}
</div>
);
}
7 changes: 0 additions & 7 deletions components/AIMode/document/useAIModeDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
Expand All @@ -158,7 +152,6 @@ export function useAIModeDocument({
draftBlocks,
draftKey,
phaseLabel,
notebookHref,
reload: fetchNote,
};
}
Loading
Loading