refactor(desktop): extract composer placeholder logic into useComposerPlaceholder

Moves the resting-placeholder state + the conversation-change re-roll effect +
the disabled/reconnecting/starting derivation out of ChatBar into
hooks/use-composer-placeholder.ts, verbatim. The hook owns its own i18n + browse
reset; ChatBar just reads the derived string.
This commit is contained in:
Brooklyn Nicholson 2026-06-30 14:25:21 -05:00
parent 2a84bcb149
commit a8f9d089f1
2 changed files with 65 additions and 44 deletions

View file

@ -0,0 +1,60 @@
import { useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { resetBrowseState } from '@/store/composer-input-history'
import { pickPlaceholder } from '../composer-utils'
interface UseComposerPlaceholderOptions {
disabled: boolean
reconnecting: boolean
sessionId: null | string | undefined
}
/**
* The composer's placeholder text. A resting starter (new session) / continuation
* (existing session) is picked once and only re-rolled when we genuinely move to
* a *different* conversation the nullid persist of a freshly-started session
* keeps its starter so the text doesn't flip mid-stream. While the transport is
* down, it swaps to a reconnecting / starting message instead.
*/
export function useComposerPlaceholder({ disabled, reconnecting, sessionId }: UseComposerPlaceholderOptions): string {
const { t } = useI18n()
const newSessionPlaceholders = t.composer.newSessionPlaceholders
const followUpPlaceholders = t.composer.followUpPlaceholders
const [restingPlaceholder, setRestingPlaceholder] = useState(() =>
pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)
)
const prevSessionIdRef = useRef(sessionId)
useEffect(() => {
const prev = prevSessionIdRef.current
prevSessionIdRef.current = sessionId
if (prev === sessionId) {
return
}
// null → id: the new session we're already in just got persisted. Keep the
// starter we showed instead of swapping to a follow-up under the user.
if (prev == null && sessionId) {
return
}
resetBrowseState(prev)
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
// When the transport is disabled it's because the gateway isn't open.
// Distinguish a cold start ("Starting Hermes...") from a dropped connection
// we're trying to restore. During reconnect, keep the textbox editable so a
// flaky network doesn't block drafting; only submit/backend actions stay
// disabled until the gateway is open again.
return disabled
? reconnecting
? t.composer.placeholderReconnecting
: t.composer.placeholderStarting
: restingPlaceholder
}

View file

@ -25,8 +25,7 @@ import {
browseBackward,
browseForward,
deriveUserHistory,
isBrowsingHistory,
resetBrowseState
isBrowsingHistory
} from '@/store/composer-input-history'
import {
$composerPopoutPosition,
@ -49,7 +48,6 @@ import { AttachmentList } from './attachments'
import {
COMPLETION_ACTIONS,
COMPOSER_FADE_BACKGROUND,
pickPlaceholder,
type QueueEditState,
slashArgStage,
slashChipKindForItem,
@ -66,6 +64,7 @@ import { useComposerDraft } from './hooks/use-composer-draft'
import { useComposerDrop } from './hooks/use-composer-drop'
import { useComposerEscCancel } from './hooks/use-composer-esc-cancel'
import { useComposerMetrics } from './hooks/use-composer-metrics'
import { useComposerPlaceholder } from './hooks/use-composer-placeholder'
import { useComposerQueue } from './hooks/use-composer-queue'
import { useComposerSubmit } from './hooks/use-composer-submit'
import { useComposerUrlDialog } from './hooks/use-composer-url-dialog'
@ -186,8 +185,6 @@ export function ChatBar({
const { t } = useI18n()
const gatewayState = useStore($gatewayState)
const newSessionPlaceholders = t.composer.newSessionPlaceholders
const followUpPlaceholders = t.composer.followUpPlaceholders
const reconnecting = gatewayState === 'closed' || gatewayState === 'error'
const inputDisabled = disabled && !reconnecting
@ -287,45 +284,9 @@ export function ChatBar({
stashAt
})
// Resting placeholder: a starter for brand-new sessions, a continuation for
// existing ones. Picked once and only re-rolled when we genuinely move to a
// *different* conversation. Critically, the first id assignment of a freshly
// started session (null → id, on the first send) is treated as the same
// conversation so the placeholder doesn't visibly flip mid-stream.
const [restingPlaceholder, setRestingPlaceholder] = useState(() =>
pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)
)
const prevSessionIdRef = useRef(sessionId)
useEffect(() => {
const prev = prevSessionIdRef.current
prevSessionIdRef.current = sessionId
if (prev === sessionId) {
return
}
// null → id: the new session we're already in just got persisted. Keep the
// starter we showed instead of swapping to a follow-up under the user.
if (prev == null && sessionId) {
return
}
resetBrowseState(prev)
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
// When the transport is disabled it's because the gateway isn't open.
// Distinguish a cold start ("Starting Hermes...") from a dropped connection
// we're trying to restore. During reconnect, keep the textbox editable so a
// flaky network doesn't block drafting; only submit/backend actions stay
// disabled until the gateway is open again.
const placeholder = disabled
? reconnecting
? t.composer.placeholderReconnecting
: t.composer.placeholderStarting
: restingPlaceholder
// Resting / reconnecting / starting placeholder text, re-rolled only on a real
// conversation change.
const placeholder = useComposerPlaceholder({ disabled, reconnecting, sessionId })
// Keep the floating box on-screen: re-clamp (with the real measured size +
// thread bounds) when it pops out and on every window resize — so a position