/** * Notes panel — the slide-over surface listing every note, searching them, * and editing/creating notes. Insert/copy actions live on each list item. * @module dsh-web-notes/client/NotesPanel */ import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactElement } from 'react' import type { NotesApi, NoteView } from './api.ts' import { relativeTime, type NoteKey } from './locales.ts' import { detectMarkdown, renderMarkdown } from './markdown.ts' /** One staged editor draft. */ export interface NoteDraft { /** Present when editing an existing note. */ id?: string title: string content: string tags: string /** Capture origin for a brand-new note. */ source?: 'manual' | 'selection' | 'composer' /** Session binding for a brand-new note (null = global). */ sessionId?: string | null /** Editor checkbox: save to global (clear the session binding). */ global?: boolean } /** Scope tabs the panel offers while a session is current. */ export type NoteScope = 'all' | 'session' | 'global' /** Props of the notes panel. */ export interface NotesPanelProps { api: NotesApi t: (key: string, params?: Record) => string /** The current session context; null on the new-conversation screen. */ sessionId: string | null /** New notes default to global (settings preference). */ defaultGlobal: boolean /** Insert one note into the composer (or copy it when no session is open). Returns a toast copy. */ onInsert: (note: NoteView) => string /** Close the panel. */ onClose: () => void /** Refresh the dock badge after any note count change. */ onChanged?: (count: number) => void /** A draft to open the editor with (selection saves land here). */ seed?: NoteDraft | null /** Called after the seed draft has been consumed. */ onSeedConsumed?: () => void /** Register the panel's flush-then-close so the dock's own affordances (the * dock toggle and ESC) persist an in-progress draft too. Pass `null` to * unregister. */ onRegisterClose?: (requestClose: (() => void) | null) => void } const SEARCH_DEBOUNCE_MS = 300 /** Panel width bounds (px) — draggable resize, persisted per browser. */ const PANEL_WIDTH_MIN = 280 const PANEL_WIDTH_MAX = 640 const PANEL_WIDTH_DEFAULT = 400 const PANEL_WIDTH_STORAGE_KEY = 'dsh-notes.panel-width' /** Load the persisted panel width; tolerant of corrupt/missing entries. */ function loadPanelWidth(): number { try { const raw = localStorage.getItem(PANEL_WIDTH_STORAGE_KEY) if (raw === null) return PANEL_WIDTH_DEFAULT const parsed = Number(raw) if (Number.isFinite(parsed)) { return Math.min(PANEL_WIDTH_MAX, Math.max(PANEL_WIDTH_MIN, parsed)) } } catch { // localStorage unavailable; the default width applies. } return PANEL_WIDTH_DEFAULT } /** The notes panel. */ export function NotesPanel(props: NotesPanelProps): ReactElement { const { api, t, onInsert, onClose, onChanged, seed, onSeedConsumed, onRegisterClose } = props const sessionId = props.sessionId const [notes, setNotes] = useState(null) const [count, setCount] = useState(0) const [error, setError] = useState(null) const [query, setQuery] = useState('') const [scope, setScope] = useState(sessionId === null ? 'global' : 'all') const [editing, setEditing] = useState(null) const [saving, setSaving] = useState(false) const [toast, setToast] = useState(null) const [confirmId, setConfirmId] = useState(null) const [mdMode, setMdMode] = useState<'edit' | 'preview'>('edit') const searchTimer = useRef(undefined) const contentRef = useRef(null) // Latest session id + draft, read by the session-switch effect so the // auto-save binds to the session being LEFT, not the one being entered. const sessionIdRef = useRef(sessionId) const editingRef = useRef(null) // Panel width (draggable left edge), persisted across reloads. const [panelWidth, setPanelWidth] = useState(() => loadPanelWidth()) const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null) /** Begin dragging the panel's left edge to resize it. */ const onResizeStart = useCallback((event: ReactPointerEvent): void => { if (event.button !== 0) return resizeRef.current = { startX: event.clientX, startWidth: panelWidth } try { event.currentTarget.setPointerCapture(event.pointerId) } catch { // Synthetic pointers (tests) have no capture; dragging still works. } }, [panelWidth]) /** Track the pointer while dragging, clamping to the width bounds. */ const onResizeMove = useCallback((event: ReactPointerEvent): void => { const state = resizeRef.current if (state === null) return const next = Math.min(PANEL_WIDTH_MAX, Math.max(PANEL_WIDTH_MIN, state.startWidth + (state.startX - event.clientX))) setPanelWidth(next) }, []) /** End the drag and persist the final width. */ const onResizeEnd = useCallback((): void => { resizeRef.current = null setPanelWidth((current) => { try { localStorage.setItem(PANEL_WIDTH_STORAGE_KEY, String(current)) } catch { // localStorage unavailable; the width still applies for this page. } return current }) }, []) const showToast = useCallback((text: string): void => { setToast(text) window.setTimeout(() => { setToast((current) => (current === text ? null : current)) }, 2200) }, []) const load = useCallback(async (q: string, activeScope: NoteScope): Promise => { try { const result = await api.list(q, { session: props.sessionId, scope: activeScope, }) setNotes(result.notes) setCount(result.count) setError(null) onChanged?.(result.count) } catch { setError('load-failed') } }, [api, props.sessionId, onChanged]) // Initial load + refresh whenever the panel reopens or the context changes. useEffect(() => { void load(query, scope) // eslint-disable-next-line react-hooks/exhaustive-deps }, [api, sessionId]) /** Persist a draft bound to an explicit target session. The Save button * binds to the current session; a session-switch auto-save binds to the * session being left so the note does not silently jump sessions. */ const persistDraft = useCallback(async (draft: NoteDraft, targetSession: string | null): Promise => { const content = draft.content.trim() if (content === '') return false const tags = draft.tags.split(/[,,]/).map((tag) => tag.trim()).filter((tag) => tag !== '') // The "save to global" checkbox clears the session binding; otherwise the // note binds to the target session (null when none). const bindSession = draft.global === true ? null : targetSession try { if (draft.id !== undefined) { const result = await api.update(draft.id, { title: draft.title.trim(), content, tags, sessionId: bindSession, }) if (!result.ok) { showToast(result.error) return false } } else { const result = await api.create({ title: draft.title.trim() === '' ? undefined : draft.title.trim(), content, tags, source: draft.source, sessionId: bindSession, }) if (!result.ok) { showToast(result.error) return false } } return true } catch { showToast(t('notes.panel.loadFailed')) return false } }, [api, showToast, t]) // Keep the latest draft in a ref (read by the session-switch effect below). useEffect(() => { editingRef.current = editing }, [editing]) /** Close the panel, persisting an in-progress draft first — a tap outside * the panel (or the dock's own toggle) must never silently discard work. * Blank drafts are dropped by persistDraft itself. */ const closeWithDraft = useCallback((): void => { const draft = editingRef.current if (draft !== null && draft.content.trim() !== '') { void persistDraft(draft, props.sessionId ?? null) } onClose() }, [persistDraft, onClose, props.sessionId]) // The dock routes its own close affordances (the dock toggle, ESC) through // this same flush-then-close path. useEffect(() => { onRegisterClose?.(closeWithDraft) return () => { onRegisterClose?.(null) } }, [onRegisterClose, closeWithDraft]) // Dismiss on an outside pointerdown — non-modal, no scrim and no scroll // lock, so the app behind stays usable. Anything inside the notes UI is // excluded: the panel itself, the dock button (which owns its own toggle — // closing here would double-toggle and immediately reopen it) and the // selection bubble. useEffect(() => { const onPointerDown = (event: PointerEvent): void => { const target = event.target if (!(target instanceof Element)) return if (target.closest('[data-dsh-notes-root]') !== null) return closeWithDraft() } document.addEventListener('pointerdown', onPointerDown) return () => { document.removeEventListener('pointerdown', onPointerDown) } }, [closeWithDraft]) // Session switch while the panel is open: if the editor holds a draft, // auto-save it against the session being LEFT, then fall back to the list // view. The load effect above (keyed on sessionId) reloads the new session. useEffect(() => { const prevSession = sessionIdRef.current sessionIdRef.current = sessionId if (prevSession === sessionId) return const draft = editingRef.current if (draft !== null) { if (draft.content.trim() !== '') { void persistDraft(draft, prevSession) } setEditing(null) setConfirmId(null) setMdMode('edit') } // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, persistDraft]) // Seed: the dock asks the panel to open the editor with a prefilled draft. useEffect(() => { if (seed === undefined || seed === null) return setEditing({ ...seed, // A seed without an explicit global flag follows the context: without a // session everything is global anyway. global: seed.global ?? props.sessionId === null, }) setQuery('') onSeedConsumed?.() // eslint-disable-next-line react-hooks/exhaustive-deps }, [seed]) // Debounced search. const onSearch = (text: string): void => { setQuery(text) if (searchTimer.current !== undefined) window.clearTimeout(searchTimer.current) searchTimer.current = window.setTimeout(() => { void load(text.trim(), scope) }, SEARCH_DEBOUNCE_MS) } const changeScope = (next: NoteScope): void => { setScope(next) void load(query, next) } useEffect(() => () => { if (searchTimer.current !== undefined) window.clearTimeout(searchTimer.current) }, []) // Focus the content area when the editor opens. useEffect(() => { if (editing !== null) { window.setTimeout(() => { contentRef.current?.focus() }, 30) } }, [editing]) const startNew = (source: NoteDraft['source'] = 'manual'): void => { setEditing({ title: '', content: '', tags: '', source, // New notes default to the current session (or global when the "default // save global" preference is on); the editor can opt into global via // its checkbox. sessionId: props.sessionId, global: props.defaultGlobal || props.sessionId === null, }) } const startEdit = (note: NoteView): void => { setEditing({ id: note.id, title: note.title, content: note.content, tags: note.tags.join(', '), sessionId: note.sessionId ?? null, }) } const saveEditor = async (): Promise => { if (editing === null || saving) return if (editing.content.trim() === '') return setSaving(true) const ok = await persistDraft(editing, props.sessionId ?? null) if (ok) { setEditing(null) setConfirmId(null) await load(query, scope) showToast(t('notes.item.saved')) } setSaving(false) } const removeNote = async (id: string): Promise => { try { await api.remove(id) setConfirmId(null) await load(query, scope) } catch { showToast(t('notes.panel.loadFailed')) } } const copyNote = async (note: NoteView): Promise => { const text = note.title === '' ? note.content : `${note.title}\n\n${note.content}` try { await navigator.clipboard.writeText(text) showToast(t('notes.item.copied')) } catch { showToast(t('notes.item.copyFailed')) } } // Editor view. if (editing !== null) { return (