Files
aiturk-hermes-ide/apps/desktop/src/app/chat/session-tile-actions.ts
T

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
]
)
}