672 lines
22 KiB
TypeScript
672 lines
22 KiB
TypeScript
/**
|
|
* Prompt actions for a SESSION TILE — the same verbs the primary chat wires
|
|
* (submit incl. slash, cancel, steer, edit, reload, restore, branch-hide
|
|
* sync), targeted at the tile's session instead of the active one. State
|
|
* writes go through the delegate's `updateSession` (the wiring cache), so
|
|
* the cache, the primary view, and every tile mirror stay one truth; view
|
|
* concerns (busy pill, transcript) reach the tile via its `$sessionStates`
|
|
* slice — never the global `$busy`/`$messages`.
|
|
*/
|
|
|
|
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
|
|
import { useCallback, useMemo, useRef } from 'react'
|
|
|
|
import type { ClientSessionState } from '@/app/types'
|
|
import { useI18n } from '@/i18n'
|
|
import { textPart } from '@/lib/chat-messages'
|
|
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
|
import { triggerHaptic } from '@/lib/haptics'
|
|
import { clearClarifyRequest } from '@/store/clarify'
|
|
import type { ComposerAttachment } from '@/store/composer'
|
|
import { resetSessionBackground } from '@/store/composer-status'
|
|
import { notifyError } from '@/store/notifications'
|
|
import { clearPreviewArtifacts } from '@/store/preview-status'
|
|
import { clearAllPrompts } from '@/store/prompts'
|
|
import { $sessions, knownSessionOwner, ownerLookupSessionRows, sessionMatchesStoredId } from '@/store/session'
|
|
import {
|
|
requestForSessionProfile,
|
|
type SessionOwnerScope,
|
|
type SessionProfileRoute
|
|
} from '@/store/session-request-router'
|
|
import {
|
|
$sessionStates,
|
|
isSessionRemote,
|
|
patchSessionTile,
|
|
sessionTileDelegate,
|
|
sessionTileOwnerRoute
|
|
} from '@/store/session-states'
|
|
import { broadcastSessionsChanged } from '@/store/session-sync'
|
|
import { clearSessionSubagents } from '@/store/subagents'
|
|
import { clearSessionTodos } from '@/store/todos'
|
|
import { setSessionDraftingTool } from '@/store/tool-drafting'
|
|
import type { SessionInfo } from '@/types/hermes'
|
|
|
|
import type { GatewayRequester } from '../contrib/types'
|
|
import { uploadComposerAttachment } from '../session/hooks/use-prompt-actions'
|
|
import {
|
|
appendMidTurnUserMessage,
|
|
applyBranchVisibility,
|
|
applyReloadOptimistic,
|
|
applyRewindOptimistic,
|
|
durableRowIdsForRebind,
|
|
finalizeInterruptedMessages,
|
|
planEdit,
|
|
planReload,
|
|
planRestore,
|
|
rebindSurvivorRowIds,
|
|
runRewindSubmit,
|
|
type SurvivorUserRowIds
|
|
} from '../session/hooks/use-prompt-actions/rewind'
|
|
import { useSubmitPrompt } from '../session/hooks/use-prompt-actions/submit'
|
|
import {
|
|
markSessionRecentlyInterrupted,
|
|
shouldInterruptBeforeRewind,
|
|
type SubmitTextOptions,
|
|
withSessionNotFoundResume
|
|
} from '../session/hooks/use-prompt-actions/utils'
|
|
import { upsertOptimisticSession } from '../session/hooks/use-session-actions/utils'
|
|
|
|
import type { ComposerScope } from './composer/scope'
|
|
|
|
/**
|
|
* List a tile's session in the sidebar/tab strip on its first send.
|
|
*
|
|
* A ⌘T tab's session is created UNLISTED (see `openNewSessionTile`), so it has
|
|
* no `$sessions` row until its first turn persists and a refresh surfaces it —
|
|
* for that whole first exchange the tab and the sidebar read "New session".
|
|
* ⌘N has no such gap: its session is created per-send and seeded with the
|
|
* user's text as the row preview. Seeding the same way here names the session
|
|
* within the first message; the server's auto-title supersedes it once the turn
|
|
* completes.
|
|
*
|
|
* No-ops on empty text and on a session that is already listed, so re-sends
|
|
* never clobber a real title with a raw message preview.
|
|
*/
|
|
export function listTileSessionRow(deps: {
|
|
cwd?: string
|
|
model?: string
|
|
preview: string
|
|
runtimeId: string
|
|
sessions: readonly SessionInfo[]
|
|
storedSessionId: string
|
|
}): boolean {
|
|
const preview = deps.preview.trim()
|
|
|
|
if (!preview || deps.sessions.some(session => sessionMatchesStoredId(session, deps.storedSessionId))) {
|
|
return false
|
|
}
|
|
|
|
const knownOwner =
|
|
sessionTileOwnerRoute(deps.storedSessionId) ?? knownSessionOwner(deps.sessions, deps.storedSessionId)
|
|
|
|
const ownerRoute: SessionProfileRoute | undefined =
|
|
knownOwner && typeof knownOwner === 'object' ? knownOwner : undefined
|
|
|
|
upsertOptimisticSession(
|
|
{ info: { cwd: deps.cwd, model: deps.model }, session_id: deps.runtimeId, stored_session_id: deps.storedSessionId },
|
|
deps.storedSessionId,
|
|
null,
|
|
preview,
|
|
null,
|
|
undefined,
|
|
ownerRoute
|
|
)
|
|
broadcastSessionsChanged()
|
|
|
|
return true
|
|
}
|
|
|
|
interface SessionTileActionsArgs {
|
|
requestGateway: GatewayRequester
|
|
runtimeId: string
|
|
scope: ComposerScope
|
|
storedSessionId: string
|
|
}
|
|
|
|
export function useSessionTileActions({ requestGateway, runtimeId, scope, storedSessionId }: SessionTileActionsArgs) {
|
|
const { t } = useI18n()
|
|
const copy = t.desktop
|
|
|
|
const runtimeIdRef = useRef(runtimeId)
|
|
runtimeIdRef.current = runtimeId
|
|
const storedIdRef = useRef(storedSessionId)
|
|
storedIdRef.current = storedSessionId
|
|
// A tile IS its session (see the comment on the useSubmitPrompt call below)
|
|
// A tile owns one stable stored/runtime pair, so seed the shared ownership
|
|
// cache explicitly rather than relying on the primary route cache.
|
|
const runtimeIdByStoredSessionIdRef = useRef(new Map([[storedSessionId, runtimeId]]))
|
|
runtimeIdByStoredSessionIdRef.current.set(storedSessionId, runtimeId)
|
|
|
|
const bindRecoveredRuntime = useCallback((recoveredId: string) => {
|
|
const storedId = storedIdRef.current
|
|
|
|
runtimeIdRef.current = recoveredId
|
|
runtimeIdByStoredSessionIdRef.current.set(storedId, recoveredId)
|
|
patchSessionTile(storedId, { error: undefined, runtimeId: recoveredId })
|
|
}, [])
|
|
|
|
// Tile busy tracks the SESSION state, never the global $busy — and it must
|
|
// read LIVE. A render-time snapshot goes stale (this hook's host doesn't
|
|
// re-render on busy edges), and a stale `true` silently blocks every
|
|
// subsequent submit ("tile only sends one message"). The setter is a no-op:
|
|
// session state owns busy; submit's optimistic writes flow through
|
|
// updateSession.
|
|
const busyRef = useMemo(
|
|
() =>
|
|
({
|
|
get current() {
|
|
return $sessionStates.get()[runtimeIdRef.current]?.busy ?? false
|
|
},
|
|
set current(_value: boolean) {
|
|
// Owned by session state.
|
|
}
|
|
}) as { current: boolean },
|
|
[]
|
|
)
|
|
|
|
const update = useCallback(
|
|
(updater: (state: ClientSessionState) => ClientSessionState) =>
|
|
sessionTileDelegate()?.updateSession(runtimeIdRef.current, updater),
|
|
[]
|
|
)
|
|
|
|
const readState = useCallback(() => $sessionStates.get()[runtimeIdRef.current], [])
|
|
const readMessages = useCallback(() => readState()?.messages ?? [], [readState])
|
|
|
|
// Tile session RPCs must follow the tile's composite owner even when the
|
|
// active gateway has moved to a same-named profile on another source.
|
|
const requestSessionGateway = useCallback(
|
|
<T>(method: string, params?: Record<string, unknown>, timeoutMs?: number, signal?: AbortSignal) => {
|
|
const knownOwner: SessionOwnerScope =
|
|
sessionTileOwnerRoute(storedIdRef.current) ?? knownSessionOwner(ownerLookupSessionRows(), storedIdRef.current)
|
|
|
|
// A bare profile is the legacy/unknown tile shape. Preserve its ambient
|
|
// behavior; only a composite route is strong enough to retarget a tile
|
|
// across same-named sources.
|
|
const owner: SessionOwnerScope = knownOwner && typeof knownOwner === 'object' ? knownOwner : undefined
|
|
|
|
return requestForSessionProfile<T>(owner, requestGateway, method, params ?? {}, timeoutMs, signal)
|
|
},
|
|
[requestGateway]
|
|
)
|
|
|
|
// A ⌘T tab's session is unlisted until its first turn persists — seed the
|
|
// row from the user's first message so the tab and sidebar name it right
|
|
// away (see listTileSessionRow).
|
|
const listTileSession = useCallback((preview: string) => {
|
|
const runtimeId = runtimeIdRef.current
|
|
const state = $sessionStates.get()[runtimeId]
|
|
|
|
listTileSessionRow({
|
|
cwd: state?.cwd,
|
|
model: state?.model,
|
|
preview,
|
|
runtimeId,
|
|
sessions: $sessions.get(),
|
|
storedSessionId: storedIdRef.current
|
|
})
|
|
}, [])
|
|
|
|
// Tile-side attachment staging: same upload rules as the primary submit
|
|
// (skip synced/pathless, byte-upload files+images), against the tile scope.
|
|
const syncAttachmentsForSubmit = useCallback(
|
|
async (
|
|
sessionId: string,
|
|
attachments: ComposerAttachment[],
|
|
options: { updateComposerAttachments?: boolean } = {}
|
|
): Promise<{ attachments: ComposerAttachment[]; sessionId: string }> => {
|
|
const remote = isSessionRemote(storedIdRef.current ?? sessionId)
|
|
let liveSessionId = sessionId
|
|
const synced: ComposerAttachment[] = []
|
|
|
|
// A tile owns its own runtime binding, so a recovery here rebinds the
|
|
// tile's ref rather than the foreground session's.
|
|
const onSessionRecovered = (recoveredId: string) => {
|
|
liveSessionId = recoveredId
|
|
bindRecoveredRuntime(recoveredId)
|
|
}
|
|
|
|
for (const attachment of attachments) {
|
|
if (!attachment.path || attachment.attachedSessionId === liveSessionId) {
|
|
synced.push(attachment)
|
|
|
|
continue
|
|
}
|
|
|
|
if (attachment.kind === 'image' || attachment.kind === 'file') {
|
|
const next = await uploadComposerAttachment(attachment, {
|
|
backendCwd: readState()?.cwd,
|
|
remote,
|
|
requestGateway: requestSessionGateway,
|
|
sessionId: liveSessionId,
|
|
storedSessionId: storedIdRef.current,
|
|
onSessionRecovered
|
|
})
|
|
|
|
if (options.updateComposerAttachments ?? true) {
|
|
if (attachment.occurrenceId) {
|
|
// Merge staging into the latest state for this exact tile-chip
|
|
// occurrence. A preview may complete while upload is pending,
|
|
// and remove + same-path reattach must not receive stale staging.
|
|
scope.attachments.updateIfCurrent(attachment, {
|
|
attachedSessionId: next.attachedSessionId,
|
|
label: next.label,
|
|
path: next.path,
|
|
refText: next.refText,
|
|
uploadState: next.uploadState
|
|
})
|
|
} else {
|
|
scope.attachments.update(next)
|
|
}
|
|
}
|
|
|
|
synced.push(next)
|
|
|
|
continue
|
|
}
|
|
|
|
synced.push(attachment)
|
|
}
|
|
|
|
return { attachments: synced, sessionId: liveSessionId }
|
|
},
|
|
[bindRecoveredRuntime, readState, requestSessionGateway, scope.attachments]
|
|
)
|
|
|
|
// The REAL submit pipeline with tile seams: session always exists, and the
|
|
// scope's writers replace the global view/attachment writes.
|
|
const submitPromptText = useSubmitPrompt({
|
|
activeSessionIdRef: runtimeIdRef,
|
|
busyRef,
|
|
copy,
|
|
createBackendSessionForSend: async () => runtimeIdRef.current,
|
|
getRoutedStoredSessionId: () => storedIdRef.current,
|
|
getRuntimeIdForStoredSession: storedId => (storedId === storedIdRef.current ? runtimeIdRef.current : null),
|
|
// A tile IS its session — no route to abandon, so the create-abort guard's
|
|
// token is a stable constant (the guard never trips for a tile).
|
|
getRouteToken: () => runtimeId,
|
|
onRuntimeRecovered: bindRecoveredRuntime,
|
|
requestGateway: requestSessionGateway,
|
|
runtimeIdByStoredSessionIdRef,
|
|
// Tile ids are always bound before this hook mounts, so routed recovery is
|
|
// unreachable here; keep the shared submit contract explicit.
|
|
resumeStoredSession: () => undefined,
|
|
selectedStoredSessionIdRef: storedIdRef,
|
|
syncAttachmentsForSubmit,
|
|
updateSessionState: (sessionId, updater) => sessionTileDelegate()!.updateSession(sessionId, updater),
|
|
scope: {
|
|
removeAttachments: attachments => scope.attachments.removeOccurrences(attachments),
|
|
readAttachments: () => scope.attachments.$attachments.get(),
|
|
// Busy/messages flow through updateSession -> the tile's state slice;
|
|
// the primary view atoms must never see a tile turn.
|
|
setAwaitingResponse: () => undefined,
|
|
setBusy: () => undefined,
|
|
setMessages: () => undefined
|
|
}
|
|
})
|
|
|
|
const submitText = useCallback(
|
|
async (rawText: string, options?: SubmitTextOptions) => {
|
|
const visibleText = rawText.trim()
|
|
const attachments = options?.attachments ?? scope.attachments.$attachments.get()
|
|
|
|
listTileSession(visibleText)
|
|
|
|
if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) {
|
|
triggerHaptic('selection')
|
|
await sessionTileDelegate()?.executeSlash(visibleText, runtimeIdRef.current)
|
|
|
|
return true
|
|
}
|
|
|
|
return await submitPromptText(rawText, options)
|
|
},
|
|
[listTileSession, scope.attachments.$attachments, submitPromptText]
|
|
)
|
|
|
|
const cancelRun = useCallback(async () => {
|
|
const sessionId = runtimeIdRef.current
|
|
|
|
// Frontend busy clears immediately; gateway wind-down can lag (#83855).
|
|
markSessionRecentlyInterrupted(sessionId)
|
|
|
|
update(state => ({
|
|
...state,
|
|
messages: finalizeInterruptedMessages(state.messages, state.streamId),
|
|
busy: false,
|
|
awaitingResponse: false,
|
|
streamId: null,
|
|
pendingBranchGroup: null,
|
|
needsInput: false,
|
|
interrupted: true
|
|
}))
|
|
|
|
clearSessionTodos(sessionId)
|
|
clearSessionSubagents(sessionId)
|
|
resetSessionBackground(sessionId)
|
|
setSessionDraftingTool(sessionId, '')
|
|
clearAllPrompts(sessionId)
|
|
clearClarifyRequest(undefined, sessionId)
|
|
|
|
try {
|
|
await withSessionNotFoundResume(
|
|
sessionId,
|
|
storedIdRef.current,
|
|
liveId => requestSessionGateway('session.interrupt', { session_id: liveId }),
|
|
{
|
|
requestGateway: requestSessionGateway,
|
|
onRecovered: bindRecoveredRuntime
|
|
}
|
|
)
|
|
} catch (err) {
|
|
notifyError(err, copy.stopFailed)
|
|
}
|
|
}, [bindRecoveredRuntime, copy.stopFailed, requestSessionGateway, update])
|
|
|
|
const steerPrompt = useCallback(
|
|
async (rawText: string): Promise<boolean> => {
|
|
const text = rawText.trim()
|
|
const sessionId = runtimeIdRef.current
|
|
|
|
if (!text || !sessionId) {
|
|
return false
|
|
}
|
|
|
|
const messageId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
|
|
const mutate = (updater: (state: ClientSessionState) => ClientSessionState) =>
|
|
sessionTileDelegate()?.updateSession(sessionId, updater)
|
|
|
|
// Match the primary composer: record the correction in arrival order —
|
|
// sealed already-streamed output above, correction below, post-redirect
|
|
// deltas below that — before awaiting the redirect RPC, whose completion
|
|
// can race us. The old insert-before-the-active-reply splice put the
|
|
// bubble above output the user had already read (#73793), and its
|
|
// last-assistant fallback could land it mid-thread when the stream id
|
|
// was missing or stale (#83151).
|
|
mutate(state =>
|
|
appendMidTurnUserMessage(state, {
|
|
id: messageId,
|
|
role: 'user' as const,
|
|
parts: [textPart(text)]
|
|
})
|
|
)
|
|
|
|
const discardOptimisticMessage = () =>
|
|
mutate(state => ({
|
|
...state,
|
|
messages: state.messages.filter(message => message.id !== messageId)
|
|
}))
|
|
|
|
const moveOptimisticMessageToEnd = () =>
|
|
mutate(state => {
|
|
const message = state.messages.find(candidate => candidate.id === messageId)
|
|
|
|
return message
|
|
? { ...state, messages: [...state.messages.filter(candidate => candidate.id !== messageId), message] }
|
|
: state
|
|
})
|
|
|
|
try {
|
|
const { result } = await withSessionNotFoundResume(
|
|
sessionId,
|
|
storedIdRef.current,
|
|
liveId => requestSessionGateway<{ status?: string }>('session.redirect', { session_id: liveId, text }),
|
|
{
|
|
requestGateway: requestSessionGateway,
|
|
onRecovered: bindRecoveredRuntime
|
|
}
|
|
)
|
|
|
|
if (result?.status === 'redirected') {
|
|
triggerHaptic('submit')
|
|
|
|
return true
|
|
}
|
|
|
|
if (result?.status === 'queued') {
|
|
moveOptimisticMessageToEnd()
|
|
triggerHaptic('submit')
|
|
|
|
return true
|
|
}
|
|
} catch {
|
|
discardOptimisticMessage()
|
|
// Swallow — the caller queues the text so nothing is lost.
|
|
|
|
return false
|
|
}
|
|
|
|
discardOptimisticMessage()
|
|
|
|
return false
|
|
},
|
|
[bindRecoveredRuntime, requestSessionGateway]
|
|
)
|
|
|
|
// Rewind primitive (interrupt-first for live turns, busy-retry) — shared with
|
|
// the primary chat so the two can't diverge.
|
|
const submitRewind = useCallback(
|
|
(
|
|
text: string,
|
|
truncateOrdinal: number | undefined,
|
|
interruptFirst: boolean,
|
|
truncateMessageId?: string,
|
|
truncateRowId?: number,
|
|
sourceText?: string,
|
|
rebindRowIds?: readonly number[]
|
|
) =>
|
|
runRewindSubmit(
|
|
requestSessionGateway,
|
|
runtimeIdRef.current,
|
|
text,
|
|
truncateOrdinal,
|
|
truncateMessageId,
|
|
interruptFirst,
|
|
{
|
|
storedSessionId: storedIdRef.current,
|
|
onSessionRecovered: bindRecoveredRuntime
|
|
},
|
|
truncateRowId,
|
|
sourceText,
|
|
rebindRowIds
|
|
),
|
|
[bindRecoveredRuntime, requestSessionGateway]
|
|
)
|
|
|
|
// After a durable rewind the surviving bubbles' cached rowIds are stale (the
|
|
// gateway re-inserted the kept prefix as new SQLite rows). Rebind them to the
|
|
// authoritative post-rewrite ids so the NEXT rewind/edit/regenerate doesn't
|
|
// send a dead id and get refused with 4018 (consecutive-rewind staleness,
|
|
// #83202 review).
|
|
const applySurvivorRowIds = useCallback(
|
|
(survivorRowIds: SurvivorUserRowIds | undefined) => {
|
|
if (!survivorRowIds) {
|
|
return
|
|
}
|
|
|
|
update(state => ({ ...state, messages: rebindSurvivorRowIds(state.messages, survivorRowIds) }))
|
|
},
|
|
[update]
|
|
)
|
|
|
|
const reloadFromMessage = useCallback(
|
|
async (parentId: string | null) => {
|
|
const state = readState()
|
|
|
|
if (!state || state.busy) {
|
|
return
|
|
}
|
|
|
|
const plan = planReload(state.messages, parentId)
|
|
|
|
if (!plan) {
|
|
return
|
|
}
|
|
|
|
const messages = state.messages
|
|
|
|
update(current => applyReloadOptimistic(current, plan))
|
|
|
|
try {
|
|
// Recovery for a dead runtime id rides inside submitRewind →
|
|
// runRewindSubmit (withSessionNotFoundResume + runtime rebind), so the
|
|
// PR-era inline prompt.submit wrapper is superseded on current main.
|
|
applySurvivorRowIds(
|
|
await submitRewind(
|
|
plan.text,
|
|
plan.truncateOrdinal,
|
|
false,
|
|
plan.truncateMessageId,
|
|
plan.truncateRowId,
|
|
plan.sourceText,
|
|
durableRowIdsForRebind(messages)
|
|
)
|
|
)
|
|
} catch (err) {
|
|
// Mirror the primary-chat reload catch: optimistic hide/truncate
|
|
// must roll back when the submit is rejected (#95745).
|
|
update(current => ({
|
|
...current,
|
|
busy: false,
|
|
awaitingResponse: false,
|
|
turnLive: false,
|
|
turnStartedAt: null,
|
|
messages
|
|
}))
|
|
notifyError(err, copy.regenerateFailed)
|
|
}
|
|
},
|
|
[applySurvivorRowIds, copy.regenerateFailed, readState, submitRewind, update]
|
|
)
|
|
|
|
const restoreToMessage = useCallback(
|
|
async (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => {
|
|
const sessionId = runtimeIdRef.current
|
|
const messages = readMessages()
|
|
const plan = planRestore(messages, messageId, target)
|
|
|
|
clearSessionTodos(sessionId)
|
|
resetSessionBackground(sessionId)
|
|
clearPreviewArtifacts(sessionId)
|
|
|
|
const interruptFirst = shouldInterruptBeforeRewind({
|
|
busy: readState()?.busy ?? false,
|
|
sessionId
|
|
})
|
|
|
|
update(state => applyRewindOptimistic(state, plan.sourceIndex))
|
|
|
|
try {
|
|
applySurvivorRowIds(
|
|
await submitRewind(
|
|
plan.text,
|
|
plan.truncateOrdinal,
|
|
interruptFirst,
|
|
plan.truncateMessageId,
|
|
plan.truncateRowId,
|
|
plan.sourceText,
|
|
durableRowIdsForRebind(messages)
|
|
)
|
|
)
|
|
} catch (err) {
|
|
update(state => ({
|
|
...state,
|
|
busy: false,
|
|
awaitingResponse: false,
|
|
turnLive: false,
|
|
turnStartedAt: null,
|
|
messages
|
|
}))
|
|
throw err
|
|
}
|
|
},
|
|
[applySurvivorRowIds, readMessages, readState, submitRewind, update]
|
|
)
|
|
|
|
const editMessage = useCallback(
|
|
async (edited: AppendMessage) => {
|
|
const messages = readMessages()
|
|
const plan = planEdit(messages, edited)
|
|
|
|
if (!plan) {
|
|
return
|
|
}
|
|
|
|
const sessionId = runtimeIdRef.current
|
|
|
|
clearSessionTodos(sessionId)
|
|
resetSessionBackground(sessionId)
|
|
clearPreviewArtifacts(sessionId)
|
|
|
|
const interruptFirst = shouldInterruptBeforeRewind({
|
|
busy: readState()?.busy ?? false,
|
|
sessionId
|
|
})
|
|
|
|
update(state => applyRewindOptimistic(state, plan.sourceIndex, plan.editedMessage))
|
|
|
|
try {
|
|
applySurvivorRowIds(
|
|
await submitRewind(
|
|
plan.text,
|
|
plan.truncateOrdinal,
|
|
interruptFirst,
|
|
plan.truncateMessageId,
|
|
plan.truncateRowId,
|
|
plan.sourceText,
|
|
durableRowIdsForRebind(messages)
|
|
)
|
|
)
|
|
} catch (err) {
|
|
update(state => ({
|
|
...state,
|
|
busy: false,
|
|
awaitingResponse: false,
|
|
turnLive: false,
|
|
turnStartedAt: null,
|
|
messages
|
|
}))
|
|
notifyError(err, copy.editFailed)
|
|
}
|
|
},
|
|
[applySurvivorRowIds, copy.editFailed, readMessages, readState, submitRewind, update]
|
|
)
|
|
|
|
// Branch-visibility sync (assistant-ui hides non-active branches).
|
|
const handleThreadMessagesChange = useCallback(
|
|
(nextMessages: readonly ThreadMessage[]) => update(state => applyBranchVisibility(state, nextMessages)),
|
|
[update]
|
|
)
|
|
|
|
const dismissError = useCallback(
|
|
(messageId: string) => {
|
|
update(state => ({ ...state, messages: state.messages.filter(m => m.id !== messageId) }))
|
|
},
|
|
[update]
|
|
)
|
|
|
|
return useMemo(
|
|
() => ({
|
|
cancelRun,
|
|
dismissError,
|
|
editMessage,
|
|
handleThreadMessagesChange,
|
|
reloadFromMessage,
|
|
restoreToMessage,
|
|
steerPrompt,
|
|
submitText
|
|
}),
|
|
[
|
|
cancelRun,
|
|
dismissError,
|
|
editMessage,
|
|
handleThreadMessagesChange,
|
|
reloadFromMessage,
|
|
restoreToMessage,
|
|
steerPrompt,
|
|
submitText
|
|
]
|
|
)
|
|
}
|