@@ -13,6 +13,10 @@ import { useRouter } from 'next/navigation'
1313import { useSession } from '@/lib/auth/auth-client'
1414import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
1515import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref'
16+ import {
17+ headingTextFromName ,
18+ isUntitledName ,
19+ } from '@/app/workspace/[workspaceId]/files/untitled-title'
1620import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files'
1721import type { SaveStatus } from '@/hooks/use-autosave'
1822import { useFileContentSource } from '@/hooks/use-file-content-source'
@@ -41,6 +45,7 @@ import { LinkHoverCard } from './menus/link-hover-card'
4145import { TableBubbleMenu } from './menus/table-menu'
4246import { normalizeMarkdownContent } from './normalize-content'
4347import { isRoundTripSafe } from './round-trip-safety'
48+ import { firstHeadingTitle , titleHeadingNode } from './title-heading'
4449import '@sim/emcn/components/code/code.css'
4550import './rich-markdown-editor.css'
4651
@@ -55,6 +60,9 @@ const EXTENSIONS = createMarkdownEditorExtensions({
5560const STREAM_REPARSE_THROTTLE_THRESHOLD = 40_000
5661const STREAM_REPARSE_THROTTLE_MS = 120
5762
63+ /** Debounce before naming a still-untitled file after its leading heading, so it fires once typing settles. */
64+ const DERIVE_TITLE_DEBOUNCE_MS = 600
65+
5866interface RichMarkdownEditorProps {
5967 file : WorkspaceFileRecord
6068 workspaceId : string
@@ -83,6 +91,12 @@ interface RichMarkdownEditorProps {
8391 * construction (they cannot both drive one editor and corrupt the shared doc).
8492 */
8593 collaborative ?: boolean
94+ /**
95+ * Called (debounced) with the document's leading-heading text while the file is still untitled, so the
96+ * caller can name the file after it. Omitted on read-only/non-editable surfaces. See
97+ * {@link isUntitledName}.
98+ */
99+ onDeriveTitleFromHeading ?: ( headingText : string ) => void
86100}
87101
88102/** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */
@@ -102,6 +116,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
102116 previewContextKey,
103117 disableTagging,
104118 collaborative = false ,
119+ onDeriveTitleFromHeading,
105120} : RichMarkdownEditorProps ) {
106121 const { data : session , isPending : isSessionPending } = useSession ( )
107122 const userId = session ?. user ?. id ?? ''
@@ -168,6 +183,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
168183 onChange = { setDraftContent }
169184 onSaveShortcut = { saveImmediately }
170185 onCollabReadyChange = { setCollabReady }
186+ onDeriveTitleFromHeading = { onDeriveTitleFromHeading }
171187 />
172188 )
173189} )
@@ -194,6 +210,8 @@ interface LoadedRichMarkdownEditorProps {
194210 onSaveShortcut : ( ) => Promise < void >
195211 /** Reports whether the collaborative document is synced+seeded (autosave gate). */
196212 onCollabReadyChange : ( ready : boolean ) => void
213+ /** See {@link RichMarkdownEditorProps.onDeriveTitleFromHeading}. */
214+ onDeriveTitleFromHeading ?: ( headingText : string ) => void
197215}
198216
199217interface SettledContent {
@@ -223,6 +241,7 @@ export function LoadedRichMarkdownEditor({
223241 onChange,
224242 onSaveShortcut,
225243 onCollabReadyChange,
244+ onDeriveTitleFromHeading,
226245} : LoadedRichMarkdownEditorProps ) {
227246 /** Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle. */
228247 const streamingAtMountRef = useRef ( isStreaming )
@@ -288,6 +307,19 @@ export function LoadedRichMarkdownEditor({
288307 onChangeRef . current = onChange
289308 const onSaveShortcutRef = useRef ( onSaveShortcut )
290309 onSaveShortcutRef . current = onSaveShortcut
310+
311+ /**
312+ * Untitled ⇄ heading coupling, active only while the file is unnamed. `onDeriveTitleFromHeading` is
313+ * called (debounced) so the caller can name the file after its leading heading; `fileNameRef` lets the
314+ * onUpdate handler read the current name without re-subscribing, and `prevFileNameRef` lets the
315+ * name→heading seed detect the untitled→named transition. See {@link isUntitledName}.
316+ */
317+ const onDeriveTitleFromHeadingRef = useRef ( onDeriveTitleFromHeading )
318+ onDeriveTitleFromHeadingRef . current = onDeriveTitleFromHeading
319+ const fileNameRef = useRef ( file . name )
320+ fileNameRef . current = file . name
321+ const prevFileNameRef = useRef ( file . name )
322+ const deriveTitleTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null )
291323 /**
292324 * Read in the RAF tick so an already-scheduled tick still sees the latest edit kind (it can change
293325 * between sessions within one turn, e.g. an append followed by a rewrite).
@@ -546,10 +578,52 @@ export function LoadedRichMarkdownEditor({
546578 const md = postProcessSerializedMarkdown ( editor . getMarkdown ( ) )
547579 lastSyncedBodyRef . current = md
548580 onChangeRef . current ( applyFrontmatter ( settledRef . current ?. frontmatter ?? '' , md ) )
581+ // While the file is still untitled, name it after its leading heading once typing settles. Always
582+ // clear any pending timer first, so deleting/rewriting the heading before it fires cancels the
583+ // stale rename; the timer re-derives the title from the live doc rather than a value captured at
584+ // schedule time, so it can never name the file after a heading the user has since changed or removed.
585+ if ( deriveTitleTimerRef . current ) clearTimeout ( deriveTitleTimerRef . current )
586+ if ( ! isUntitledName ( fileNameRef . current ) || firstHeadingTitle ( editor . state . doc ) === null )
587+ return
588+ deriveTitleTimerRef . current = setTimeout ( ( ) => {
589+ const liveEditor = editorInstanceRef . current
590+ if ( ! liveEditor || ! isUntitledName ( fileNameRef . current ) ) return
591+ const title = firstHeadingTitle ( liveEditor . state . doc )
592+ if ( title ) onDeriveTitleFromHeadingRef . current ?.( title )
593+ } , DERIVE_TITLE_DEBOUNCE_MS )
549594 } ,
550595 } )
551596 editorInstanceRef . current = editor
552597
598+ useEffect (
599+ ( ) => ( ) => {
600+ if ( deriveTitleTimerRef . current ) clearTimeout ( deriveTitleTimerRef . current )
601+ } ,
602+ [ ]
603+ )
604+
605+ /**
606+ * Name→heading seed: when a still-untitled file is given a real name, seed the document's leading
607+ * heading from that name — but only into a document with no heading of its own yet, so existing
608+ * content is never clobbered. One-shot by construction: it fires only on the untitled→named transition.
609+ */
610+ useEffect ( ( ) => {
611+ // Wait for a render with a live, editable editor before consuming the transition — advancing
612+ // prevFileNameRef while editor is still null would record the untitled→named change as "seen" and
613+ // silently skip the seed on the later render when the editor is ready.
614+ if ( ! editor || ! isEditable ) return
615+ const prevName = prevFileNameRef . current
616+ prevFileNameRef . current = file . name
617+ if ( ! isUntitledName ( prevName ) || isUntitledName ( file . name ) ) return
618+ if ( firstHeadingTitle ( editor . state . doc ) !== null ) return
619+ const title = headingTextFromName ( file . name )
620+ if ( ! title ) return
621+ // Always prepend, never replace: an empty doc becomes `# Title` + a body line, while any existing
622+ // content — including structure-only scaffold (an empty list/blockquote/code block that carries no
623+ // text) — is preserved above rather than clobbered.
624+ editor . commands . insertContentAt ( 0 , titleHeadingNode ( title ) )
625+ } , [ file . name , editor , isEditable ] )
626+
553627 /**
554628 * The loaded markdown to seed the shared doc from, held by pointer so the parse
555629 * runs once at seed time rather than every render.
0 commit comments