diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 43074b5ce37..6ab2abf72f8 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -174,7 +174,6 @@ export function ChatBar({ const queuedPromptsBySession = useStore($queuedPromptsBySession) const statusItemsBySession = useStore($statusItemsBySession) const scrolledUp = useStore($threadScrolledUp) - const sessionMessages = useStore($messages) const activeQueueSessionKey = queueSessionKey || sessionId || null const queuedPrompts = useMemo( @@ -866,7 +865,9 @@ export function ChatBar({ event.preventDefault() triggerKeyConsumedRef.current = true - const history = deriveUserHistory(sessionMessages, chatMessageText) + // $messages is read imperatively (not subscribed) so the composer + // doesn't re-render on every streaming delta flush. + const history = deriveUserHistory($messages.get(), chatMessageText) const entry = browseBackward(sessionId, currentDraft, history) if (entry !== null) { @@ -891,7 +892,7 @@ export function ChatBar({ event.preventDefault() triggerKeyConsumedRef.current = true - const history = deriveUserHistory(sessionMessages, chatMessageText) + const history = deriveUserHistory($messages.get(), chatMessageText) const result = browseForward(sessionId, history) if (result !== null) { diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 725039620f9..ab1213ef166 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -35,7 +35,9 @@ import { $gatewayState, $introPersonality, $introSeed, + $lastVisibleMessageIsUser, $messages, + $messagesEmpty, $selectedStoredSessionId, $sessions, sessionPinId @@ -55,7 +57,7 @@ import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-ac import { useFileDropZone } from './hooks/use-file-drop-zone' import { ScrollToBottomButton } from './scroll-to-bottom-button' import { SessionActionsMenu } from './sidebar/session-actions-menu' -import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading' +import { threadLoadingState } from './thread-loading' interface ChatViewProps extends Omit, 'onSubmit'> { gateway: HermesGateway | null @@ -156,105 +158,35 @@ function ChatHeader({ ) } -export function ChatView({ - className, - gateway, - onToggleSelectedPin, - onDeleteSelectedSession, +interface ChatRuntimeBoundaryProps { + busy: boolean + children: React.ReactNode + onCancel: () => Promise | void + onEdit: (message: AppendMessage) => Promise + onReload: (parentId: string | null) => Promise + onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void +} + +/** + * Owns the $messages subscription and the assistant-ui external-store runtime. + * + * Isolated from ChatView so the per-token delta flush (which replaces the + * $messages atom ~30×/s during streaming) only re-renders this component and + * the runtime provider. The children (Thread, ChatBar) are created by + * ChatView, whose render output is stable across flushes — so React bails out + * of re-rendering them by element identity and the stream's render cost stays + * confined to the streaming message's own subtree. + */ +function ChatRuntimeBoundary({ + busy, + children, onCancel, - onAddContextRef, - onAddUrl, - onAttachImageBlob, - onAttachDroppedItems, - onBranchInNewChat, - maxVoiceRecordingSeconds, - onPasteClipboardImage, - onPickFiles, - onPickFolders, - onPickImages, - onRemoveAttachment, - onSteer, - onSubmit, - onThreadMessagesChange, onEdit, onReload, - onRestoreToMessage, - onTranscribeAudio -}: ChatViewProps) { - const location = useLocation() - const activeSessionId = useStore($activeSessionId) - const awaitingResponse = useStore($awaitingResponse) - const busy = useStore($busy) - const contextSuggestions = useStore($contextSuggestions) - const currentCwd = useStore($currentCwd) - const currentModel = useStore($currentModel) - const currentProvider = useStore($currentProvider) - const freshDraftReady = useStore($freshDraftReady) - const gatewayState = useStore($gatewayState) - const gatewaySwapTarget = useStore($gatewaySwapTarget) - const gatewayOpen = gatewayState === 'open' - const introPersonality = useStore($introPersonality) - const introSeed = useStore($introSeed) + onThreadMessagesChange +}: ChatRuntimeBoundaryProps) { const messages = useStore($messages) - const selectedSessionId = useStore($selectedStoredSessionId) const runtimeMessageCacheRef = useRef(new WeakMap()) - const isRoutedSessionView = Boolean(routeSessionId(location.pathname)) - - const showIntro = - freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0 - - // Session is still loading if the route references a session we haven't - // resumed yet. Once `activeSessionId` is set (runtime has resumed), the - // session exists — even if it has zero messages (a brand-new routed - // session). The flicker where `busy` flips true briefly during hydrate - // is handled by `threadLoadingState`'s last-visible-user gate. - const loadingSession = isRoutedSessionView && messages.length === 0 && !activeSessionId - const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleMessageIsUser(messages)) - const showChatBar = !loadingSession - const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new') - - const modelOptionsQuery = useQuery({ - queryKey: ['model-options', activeSessionId || 'global'], - queryFn: () => { - if (!activeSessionId) { - return getGlobalModelOptions() - } - - if (!gateway) { - throw new Error('Hermes gateway unavailable') - } - - return gateway.request('model.options', { session_id: activeSessionId }) - }, - enabled: gatewayOpen - }) - - const quickModels = useMemo( - () => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel), - [currentModel, currentProvider, modelOptionsQuery.data] - ) - - const chatBarState = useMemo( - () => ({ - model: { - model: currentModel, - provider: currentProvider, - canSwitch: gatewayOpen, - loading: !gatewayOpen || (!currentModel && !currentProvider), - quickModels - }, - tools: { - enabled: true, - label: 'Add context', - suggestions: contextSuggestions - }, - voice: { - enabled: true, - active: false - } - }), - [contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels] - ) const runtimeMessageRepository = useMemo(() => { const items: { message: ThreadMessage; parentId: string | null }[] = [] @@ -304,6 +236,113 @@ export function ChatView({ onReload }) + return {children} +} + +export function ChatView({ + className, + gateway, + onToggleSelectedPin, + onDeleteSelectedSession, + onCancel, + onAddContextRef, + onAddUrl, + onAttachImageBlob, + onAttachDroppedItems, + onBranchInNewChat, + maxVoiceRecordingSeconds, + onPasteClipboardImage, + onPickFiles, + onPickFolders, + onPickImages, + onRemoveAttachment, + onSteer, + onSubmit, + onThreadMessagesChange, + onEdit, + onReload, + onRestoreToMessage, + onTranscribeAudio +}: ChatViewProps) { + const location = useLocation() + const activeSessionId = useStore($activeSessionId) + const awaitingResponse = useStore($awaitingResponse) + const busy = useStore($busy) + const contextSuggestions = useStore($contextSuggestions) + const currentCwd = useStore($currentCwd) + const currentModel = useStore($currentModel) + const currentProvider = useStore($currentProvider) + const freshDraftReady = useStore($freshDraftReady) + const gatewayState = useStore($gatewayState) + const gatewaySwapTarget = useStore($gatewaySwapTarget) + const gatewayOpen = gatewayState === 'open' + const introPersonality = useStore($introPersonality) + const introSeed = useStore($introSeed) + // PERF: ChatView must not subscribe to $messages — the atom is replaced on + // every streaming delta flush (~30×/s) and a subscription here re-renders + // the entire chat shell (header, chat bar, thread wrapper) per token. The + // runtime that DOES need the messages lives in ChatRuntimeBoundary below; + // this component only needs streaming-stable derivations. + const messagesEmpty = useStore($messagesEmpty) + const lastVisibleIsUser = useStore($lastVisibleMessageIsUser) + const selectedSessionId = useStore($selectedStoredSessionId) + const isRoutedSessionView = Boolean(routeSessionId(location.pathname)) + + const showIntro = freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty + + // Session is still loading if the route references a session we haven't + // resumed yet. Once `activeSessionId` is set (runtime has resumed), the + // session exists — even if it has zero messages (a brand-new routed + // session). The flicker where `busy` flips true briefly during hydrate + // is handled by `threadLoadingState`'s last-visible-user gate. + const loadingSession = isRoutedSessionView && messagesEmpty && !activeSessionId + const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser) + const showChatBar = !loadingSession + const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new') + + const modelOptionsQuery = useQuery({ + queryKey: ['model-options', activeSessionId || 'global'], + queryFn: () => { + if (!activeSessionId) { + return getGlobalModelOptions() + } + + if (!gateway) { + throw new Error('Hermes gateway unavailable') + } + + return gateway.request('model.options', { session_id: activeSessionId }) + }, + enabled: gatewayOpen + }) + + const quickModels = useMemo( + () => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel), + [currentModel, currentProvider, modelOptionsQuery.data] + ) + + const chatBarState = useMemo( + () => ({ + model: { + model: currentModel, + provider: currentProvider, + canSwitch: gatewayOpen, + loading: !gatewayOpen || (!currentModel && !currentProvider), + quickModels + }, + tools: { + enabled: true, + label: 'Add context', + suggestions: contextSuggestions + }, + voice: { + enabled: true, + active: false + } + }), + [contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels] + ) + // Drop files anywhere in the conversation area, not just on the composer // input. In-app drags (project tree / gutter) carry workspace-relative paths // the gateway resolves directly, so they stay inline `@file:` refs. OS/Finder @@ -356,7 +395,13 @@ export function ChatView({ className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]" {...dropHandlers} > - + )} - + {showChatBar && } diff --git a/apps/desktop/src/app/chat/thread-loading.ts b/apps/desktop/src/app/chat/thread-loading.ts index 97686c6550c..05cfb08671f 100644 --- a/apps/desktop/src/app/chat/thread-loading.ts +++ b/apps/desktop/src/app/chat/thread-loading.ts @@ -3,9 +3,14 @@ import type { ChatMessage } from '@/lib/chat-messages' export type ThreadLoadingState = 'response' | 'session' export function lastVisibleMessageIsUser(messages: ChatMessage[]): boolean { - const lastVisible = [...messages].reverse().find(message => !message.hidden) + // Allocation-free reverse scan — runs in a hot $messages computed. + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (!messages[i].hidden) { + return messages[i].role === 'user' + } + } - return lastVisible?.role === 'user' + return false } export function threadLoadingState( diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index effeb38e79a..f2a574d475b 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -7,7 +7,8 @@ import { MessagePrimitive, type ToolCallMessagePartProps, useAui, - useAuiState + useAuiState, + useMessageRuntime } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { IconPlayerStopFilled } from '@tabler/icons-react' @@ -105,7 +106,11 @@ type ThreadLoadingState = 'response' | 'session' interface MessageActionProps { messageId: string - messageText: string + /** Lazy accessor — reads the live message text at action time. Passing the + * text itself as a prop forces the whole footer to re-render on every + * streaming delta flush (the text changes ~30×/s), which profiling showed + * was a large slice of per-token script time on long transcripts. */ + getMessageText: () => string onBranchInNewChat?: (messageId: string) => void } @@ -133,6 +138,28 @@ function messageContentText(content: unknown): string { return Array.isArray(content) ? content.map(partText).join('').trim() : '' } +// Cheap streaming-stable "does this message have visible text" check: returns +// on the first non-whitespace text part without concatenating the whole +// message. Used as a useAuiState selector so its boolean output stays stable +// across token flushes (flips false→true once per turn). +function contentHasVisibleText(content: unknown): boolean { + if (typeof content === 'string') { + return content.trim().length > 0 + } + + if (!Array.isArray(content)) { + return false + } + + for (const part of content) { + if (partText(part).trim().length > 0) { + return true + } + } + + return false +} + export const Thread: FC<{ clampToComposer?: boolean cwd?: string | null @@ -221,20 +248,39 @@ const CenteredThreadSpinner: FC = () => { const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => { const messageId = useAuiState(s => s.message.id) - const content = useAuiState(s => s.message.content) - const messageText = messageContentText(content) + const messageRuntime = useMessageRuntime() + + // PERF: this component must NOT subscribe to the streaming text. Every + // selector here returns a value that stays referentially stable across + // token flushes (booleans, status strings, '' while running), so the + // 30 Hz delta stream only re-renders the markdown part and the tiny + // StreamStallIndicator leaf — not the footer/preview/root subtree. + const messageStatus = useAuiState(s => s.message.status?.type) + const isRunning = messageStatus === 'running' + const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0) + const hasVisibleText = useAuiState(s => contentHasVisibleText(s.message.content)) + + // Preview targets only materialize once the turn completes — while running + // the selector returns '' (stable), so per-token flushes skip the regex + // scan and the re-render it would cause. + const completedText = useAuiState(s => + s.message.status?.type === 'running' ? '' : messageContentText(s.message.content) + ) const previewTargets = useMemo(() => { - if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) { + if (!completedText || !/(https?:\/\/|file:\/\/)/i.test(completedText)) { return [] } - return pickPrimaryPreviewTarget(extractPreviewTargets(messageText)) - }, [messageText]) + return pickPrimaryPreviewTarget(extractPreviewTargets(completedText)) + }, [completedText]) - const messageStatus = useAuiState(s => s.message.status?.type) - const isPlaceholder = messageStatus === 'running' && content.length === 0 - const enterRef = useEnterAnimation(messageStatus === 'running', `assistant-message:${messageId}`) + const getMessageText = useCallback( + () => messageContentText(messageRuntime.getState().content), + [messageRuntime] + ) + + const enterRef = useEnterAnimation(isRunning, `assistant-message:${messageId}`) if (isPlaceholder) { return null @@ -245,7 +291,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> className="group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden" data-role="assistant" data-slot="aui_assistant-message-root" - data-streaming={messageStatus === 'running' ? 'true' : undefined} + data-streaming={isRunning ? 'true' : undefined} ref={enterRef} >
void }> > {/* Todos render in the composer status stack now, not inline. */} - {messageStatus === 'running' && } + {isRunning && } {previewTargets.length > 0 && (
{previewTargets.map(target => ( @@ -271,8 +317,8 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
- {messageText.trim().length > 0 && ( - + {hasVisibleText && ( + )} ) @@ -313,10 +359,28 @@ const STREAM_STALL_S = 2 // Tail "still thinking" indicator: the pre-first-token spinner goes away once // text flows, but if the stream then goes quiet mid-turn (tool think-time, -// provider stall) nothing signals that work continues. Watch a per-render +// provider stall) nothing signals that work continues. Watch a per-flush // activity signal; when it hasn't changed for STREAM_STALL_S, re-show the // dither + a timer counting from the last activity. -const StreamStallIndicator: FC<{ activity: string }> = ({ activity }) => { +// +// Subscribes to the activity signal ITSELF (rather than taking it as a prop) +// so that per-token updates re-render only this leaf, not the whole +// AssistantMessage subtree. +const StreamStallIndicator: FC = () => { + const activity = useAuiState(s => { + let textLength = 0 + + for (const part of s.message.content) { + const text = (part as { text?: unknown }).text + + if (typeof text === 'string') { + textLength += text.length + } + } + + return `${s.message.content.length}:${textLength}` + }) + const [stalled, setStalled] = useState(false) useEffect(() => { @@ -584,7 +648,7 @@ function formatMessageTimestamp( return SHORT_FMT.format(date) } -const AssistantActionBar: FC = ({ messageId, messageText, onBranchInNewChat }) => { +const AssistantActionBar: FC = ({ messageId, getMessageText, onBranchInNewChat }) => { const { t } = useI18n() const copy = t.assistant.thread const [menuOpen, setMenuOpen] = useState(false) @@ -605,7 +669,7 @@ const AssistantActionBar: FC = ({ messageId, messageText, on )} data-slot="aui_msg-actions" > - + triggerHaptic('submit')} tooltip={copy.refresh}> @@ -623,7 +687,7 @@ const AssistantActionBar: FC = ({ messageId, messageText, on {copy.branchNewChat} - + @@ -631,7 +695,7 @@ const AssistantActionBar: FC = ({ messageId, messageText, on ) } -const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => { +const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => { const { t } = useI18n() const copy = t.assistant.thread const voicePlayback = useStore($voicePlayback) @@ -645,6 +709,8 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon const read = useCallback(async () => { + const text = getText() + if (!text || $voicePlayback.get().status !== 'idle') { return } @@ -654,11 +720,11 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex } catch (error) { notifyError(error, copy.readAloudFailed) } - }, [copy.readAloudFailed, messageId, text]) + }, [copy.readAloudFailed, getText, messageId]) return ( { e.preventDefault() void (isSpeaking ? stopVoicePlayback() : read()) @@ -820,8 +886,10 @@ const UserMessage: FC<{ // changes, not on every frame while the outer max-height animates open. const clampInnerRef = useRef(null) const [bodyClamped, setBodyClamped] = useState(false) + const lastClampHeightRef = useRef(-1) + const lineHeightRef = useRef(0) - const measureClamp = useCallback(() => { + const measureClamp = useCallback((entries: readonly ResizeObserverEntry[]) => { const inner = clampInnerRef.current const outer = inner?.parentElement @@ -829,12 +897,28 @@ const UserMessage: FC<{ return } - const styles = getComputedStyle(inner) - const lineHeight = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20 - const fullHeight = inner.scrollHeight + // Prefer the size the ResizeObserver already computed — reading + // `scrollHeight` outside RO timing forces a synchronous layout, and with + // many user bubbles observed at once those reads interleave with the + // style write below into a read-write-read reflow cascade. + const entryHeight = entries.find(entry => entry.target === inner)?.borderBoxSize?.[0]?.blockSize + const fullHeight = Math.ceil(entryHeight ?? inner.scrollHeight) + + if (fullHeight === lastClampHeightRef.current) { + return + } + + lastClampHeightRef.current = fullHeight + + // Line-height is stable for the life of the bubble (font settings don't + // change under it) — resolve the computed style once. + if (!lineHeightRef.current) { + const styles = getComputedStyle(inner) + lineHeightRef.current = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20 + } outer.style.setProperty('--human-msg-full', `${fullHeight}px`) - setBodyClamped(fullHeight > lineHeight * 2 + 1) + setBodyClamped(fullHeight > lineHeightRef.current * 2 + 1) }, []) useResizeObserver(measureClamp, clampInnerRef) diff --git a/apps/desktop/src/components/ui/fade-text.tsx b/apps/desktop/src/components/ui/fade-text.tsx index f80c32c2132..b487d87f6fe 100644 --- a/apps/desktop/src/components/ui/fade-text.tsx +++ b/apps/desktop/src/components/ui/fade-text.tsx @@ -34,14 +34,21 @@ function FadeTextImpl({ children, className, fadeWidth = '3rem', style, ...rest const ref = useRef(null) const [overflowing, setOverflowing] = useState(false) - const measureOverflow = useCallback(() => { + const measureOverflow = useCallback((entries: readonly ResizeObserverEntry[]) => { const el = ref.current if (!el) { return } - setOverflowing(el.scrollWidth - el.clientWidth > 1) + // `clientWidth` from the RO entry when available (already computed); + // `scrollWidth` is unavoidable — content width isn't part of the entry — + // but inside RO timing layout is already clean so the read is cheap. + const clientWidth = entries.find(entry => entry.target === el)?.contentRect?.width ?? el.clientWidth + + // setState is identity-stable: React bails out when the boolean doesn't + // change, so repeated RO fires with the same answer don't re-render. + setOverflowing(el.scrollWidth - clientWidth > 1) }, []) useResizeObserver(measureOverflow, ref) diff --git a/apps/desktop/src/hooks/use-resize-observer.ts b/apps/desktop/src/hooks/use-resize-observer.ts index b350a367d72..e9a0b0b50a6 100644 --- a/apps/desktop/src/hooks/use-resize-observer.ts +++ b/apps/desktop/src/hooks/use-resize-observer.ts @@ -1,17 +1,26 @@ import { type RefObject, useLayoutEffect, useRef } from 'react' -export function useResizeObserver(onResize: () => void, ...refs: readonly RefObject[]) { +/** + * Observe element resizes. The callback receives the ResizeObserver entries + * (empty on the initial synchronous call and in non-RO environments) so + * callers can read the observed size off the entry instead of forcing a + * fresh layout read. + */ +export function useResizeObserver( + onResize: (entries: readonly ResizeObserverEntry[]) => void, + ...refs: readonly RefObject[] +) { const refsRef = useRef(refs) refsRef.current = refs useLayoutEffect(() => { if (typeof ResizeObserver === 'undefined') { - onResize() + onResize([]) return } - const observer = new ResizeObserver(() => onResize()) + const observer = new ResizeObserver(entries => onResize(entries)) let observed = false for (const ref of refsRef.current) { @@ -31,7 +40,7 @@ export function useResizeObserver(onResize: () => void, ...refs: readonly RefObj return } - onResize() + onResize([]) return () => observer.disconnect() }, [onResize]) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index dcf778c4698..f1e1e2ee617 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -1,5 +1,6 @@ -import { atom } from 'nanostores' +import { atom, computed } from 'nanostores' +import { lastVisibleMessageIsUser } from '@/app/chat/thread-loading' import type { ContextSuggestion } from '@/app/types' import type { HermesConnection } from '@/global' import type { ChatMessage } from '@/lib/chat-messages' @@ -195,6 +196,15 @@ export const $workingSessionIds = atom([]) export const $activeSessionId = atom(null) export const $selectedStoredSessionId = atom(null) export const $messages = atom([]) + +// Streaming-stable derivations of $messages. During a token stream the array +// is replaced ~30×/s; components that only care about coarse facts (is the +// thread empty? is the tail a user message?) subscribe to these instead of +// $messages so per-token flushes don't re-render them — nanostores' `computed` +// only notifies when the derived VALUE changes. +export const $messagesEmpty = computed($messages, messages => messages.length === 0) +export const $lastVisibleMessageIsUser = computed($messages, lastVisibleMessageIsUser) + export const $freshDraftReady = atom(false) export const $busy = atom(false) export const $awaitingResponse = atom(false)