feat(desktop): reconcile live tool events, polish thread chrome, harden boot
- chat-messages: match tool rows by overlapping query/context/preview values so preview-first `tool.progress` rows reliably adopt later stable-id `tool.start` payloads instead of spawning ghost rows or mis-merging parallel same-name calls; preserve prior args/result across phases. - tui_gateway: emit full args + parsed result on `tool.start` / `tool.complete`, drop redundant `tool.started` re-emit from `tool.progress`. - electron/main: prefer SOURCE_REPO_ROOT before PATH `hermes` in dev so local backend edits actually run; split hardening helpers into `electron/hardening.cjs` with tests. - thread/tool UI: one-shot enter animation keyed by stable ids, braille spinner for running rows, Cursor-like disclosure rows, drill-down + duration/count formatting via new tool-fallback-model. - composer: extract `text-utils`, drop liquid-glass overrides. - right-rail: split preview-pane into preview-console / preview-file. - runtime: incremental external-store runtime + runtime-readiness gate; onboarding store + tests; route-resume hook test. - regression tests for live tool reconciliation (parallel tools, id-less progress, preview-first rows, structured args/results).
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
AssistantRuntimeImpl,
|
||||
BaseAssistantRuntimeCore,
|
||||
ExternalStoreThreadListRuntimeCore,
|
||||
ExternalStoreThreadRuntimeCore,
|
||||
hasUpcomingMessage
|
||||
} from '@assistant-ui/core/internal'
|
||||
import {
|
||||
type AssistantRuntime,
|
||||
type ExternalStoreAdapter,
|
||||
type ThreadMessage,
|
||||
useRuntimeAdapters
|
||||
} from '@assistant-ui/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
const EMPTY_ARRAY = Object.freeze([])
|
||||
|
||||
const shallowEqual = (a: object, b: object): boolean => {
|
||||
const aKeys = Object.keys(a)
|
||||
|
||||
if (aKeys.length !== Object.keys(b).length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const key of aKeys) {
|
||||
if (a[key as keyof typeof a] !== b[key as keyof typeof b]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const getThreadListAdapter = (store: ExternalStoreAdapter) => store.adapters?.threadList ?? {}
|
||||
|
||||
function syncRepositoryIncrementally(
|
||||
runtime: ExternalStoreThreadRuntimeCore,
|
||||
messageRepository: NonNullable<ExternalStoreAdapter['messageRepository']>
|
||||
): readonly ThreadMessage[] {
|
||||
const repository = (runtime as unknown as { repository: ExternalStoreThreadRuntimeCore['repository'] }).repository
|
||||
const incomingIds = new Set(messageRepository.messages.map(({ message }) => message.id))
|
||||
|
||||
for (const { message, parentId } of messageRepository.messages) {
|
||||
repository.addOrUpdateMessage(parentId, message)
|
||||
}
|
||||
|
||||
for (const { message } of repository.export().messages) {
|
||||
if (!incomingIds.has(message.id)) {
|
||||
repository.deleteMessage(message.id)
|
||||
}
|
||||
}
|
||||
|
||||
const headId = messageRepository.headId ?? messageRepository.messages.at(-1)?.message.id ?? null
|
||||
|
||||
repository.resetHead(headId)
|
||||
|
||||
return repository.getMessages()
|
||||
}
|
||||
|
||||
class IncrementalExternalStoreThreadRuntimeCore extends ExternalStoreThreadRuntimeCore {
|
||||
override __internal_setAdapter(store: ExternalStoreAdapter): void {
|
||||
if (!store.messageRepository) {
|
||||
super.__internal_setAdapter(store)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const self = this as unknown as {
|
||||
_assistantOptimisticId: null | string
|
||||
_capabilities: object
|
||||
_messages: readonly ThreadMessage[]
|
||||
_notifyEventSubscribers: (event: string, payload: object) => void
|
||||
_notifySubscribers: () => void
|
||||
_store?: ExternalStoreAdapter
|
||||
}
|
||||
|
||||
if (self._store === store) {
|
||||
return
|
||||
}
|
||||
|
||||
const isRunning = store.isRunning ?? false
|
||||
this.isDisabled = store.isDisabled ?? false
|
||||
|
||||
const oldStore = self._store
|
||||
self._store = store
|
||||
|
||||
if (this.extras !== store.extras) {
|
||||
this.extras = store.extras
|
||||
}
|
||||
|
||||
const newSuggestions = store.suggestions ?? EMPTY_ARRAY
|
||||
|
||||
if (!shallowEqual(this.suggestions, newSuggestions)) {
|
||||
this.suggestions = newSuggestions
|
||||
}
|
||||
|
||||
const newCapabilities = {
|
||||
switchToBranch: store.setMessages !== undefined,
|
||||
switchBranchDuringRun: false,
|
||||
edit: store.onEdit !== undefined,
|
||||
reload: store.onReload !== undefined,
|
||||
cancel: store.onCancel !== undefined,
|
||||
speech: store.adapters?.speech !== undefined,
|
||||
dictation: store.adapters?.dictation !== undefined,
|
||||
voice: store.adapters?.voice !== undefined,
|
||||
unstable_copy: store.unstable_capabilities?.copy !== false,
|
||||
attachments: !!store.adapters?.attachments,
|
||||
feedback: !!store.adapters?.feedback,
|
||||
queue: false
|
||||
}
|
||||
|
||||
if (!shallowEqual(self._capabilities, newCapabilities)) {
|
||||
self._capabilities = newCapabilities
|
||||
}
|
||||
|
||||
if (oldStore && oldStore.isRunning === store.isRunning && oldStore.messageRepository === store.messageRepository) {
|
||||
self._notifySubscribers()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (self._assistantOptimisticId) {
|
||||
this.repository.deleteMessage(self._assistantOptimisticId)
|
||||
self._assistantOptimisticId = null
|
||||
}
|
||||
|
||||
const messages = syncRepositoryIncrementally(this, store.messageRepository)
|
||||
|
||||
if (messages.length > 0) {
|
||||
this.ensureInitialized()
|
||||
}
|
||||
|
||||
if ((oldStore?.isRunning ?? false) !== (store.isRunning ?? false)) {
|
||||
self._notifyEventSubscribers(store.isRunning ? 'runStart' : 'runEnd', {})
|
||||
}
|
||||
|
||||
if (hasUpcomingMessage(isRunning, messages)) {
|
||||
self._assistantOptimisticId = this.repository.appendOptimisticMessage(messages.at(-1)?.id ?? null, {
|
||||
role: 'assistant',
|
||||
content: []
|
||||
})
|
||||
}
|
||||
|
||||
this.repository.resetHead(self._assistantOptimisticId ?? messages.at(-1)?.id ?? null)
|
||||
self._messages = this.repository.getMessages()
|
||||
self._notifySubscribers()
|
||||
}
|
||||
}
|
||||
|
||||
class IncrementalExternalStoreRuntimeCore extends BaseAssistantRuntimeCore {
|
||||
threads: ExternalStoreThreadListRuntimeCore
|
||||
|
||||
constructor(adapter: ExternalStoreAdapter) {
|
||||
super()
|
||||
|
||||
this.threads = new ExternalStoreThreadListRuntimeCore(
|
||||
getThreadListAdapter(adapter),
|
||||
() => new IncrementalExternalStoreThreadRuntimeCore(this._contextProvider, adapter)
|
||||
)
|
||||
}
|
||||
|
||||
setAdapter(adapter: ExternalStoreAdapter): void {
|
||||
this.threads.__internal_setAdapter(getThreadListAdapter(adapter))
|
||||
this.threads.getMainThreadRuntimeCore().__internal_setAdapter(adapter)
|
||||
}
|
||||
}
|
||||
|
||||
export function useIncrementalExternalStoreRuntime<T extends ThreadMessage>(
|
||||
store: ExternalStoreAdapter<T>
|
||||
): AssistantRuntime {
|
||||
const [runtime] = useState(() => new IncrementalExternalStoreRuntimeCore(store as ExternalStoreAdapter))
|
||||
|
||||
useEffect(() => {
|
||||
runtime.setAdapter(store as ExternalStoreAdapter)
|
||||
})
|
||||
|
||||
const { modelContext } = useRuntimeAdapters() ?? {}
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelContext) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return runtime.registerModelContextProvider(modelContext)
|
||||
}, [modelContext, runtime])
|
||||
|
||||
return useMemo(() => new AssistantRuntimeImpl(runtime), [runtime])
|
||||
}
|
||||
Reference in New Issue
Block a user