feat: file tabs
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { type MutableRefObject, useCallback } from 'react'
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import {
|
||||
appendAssistantTextPart,
|
||||
@@ -51,6 +51,13 @@ interface MessageStreamOptions {
|
||||
) => ClientSessionState
|
||||
}
|
||||
|
||||
interface QueuedStreamDeltas {
|
||||
assistant: string
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
const STREAM_DELTA_FLUSH_MS = 16
|
||||
|
||||
export function useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession,
|
||||
@@ -123,19 +130,108 @@ export function useMessageStream({
|
||||
[updateSessionState]
|
||||
)
|
||||
|
||||
const queuedDeltasRef = useRef<Map<string, QueuedStreamDeltas>>(new Map())
|
||||
const flushHandleRef = useRef<number | null>(null)
|
||||
|
||||
const flushQueuedDeltas = useCallback(
|
||||
(sessionId?: string) => {
|
||||
const queue = queuedDeltasRef.current
|
||||
const ids = sessionId ? [sessionId] : [...queue.keys()]
|
||||
|
||||
for (const id of ids) {
|
||||
const queued = queue.get(id)
|
||||
|
||||
if (!queued) {
|
||||
continue
|
||||
}
|
||||
|
||||
queue.delete(id)
|
||||
|
||||
if (queued.assistant) {
|
||||
mutateStream(
|
||||
id,
|
||||
parts => appendAssistantTextPart(parts, queued.assistant),
|
||||
() => [assistantTextPart(queued.assistant)]
|
||||
)
|
||||
}
|
||||
|
||||
if (queued.reasoning) {
|
||||
mutateStream(
|
||||
id,
|
||||
parts => appendReasoningPart(parts, queued.reasoning),
|
||||
() => [reasoningPart(queued.reasoning)]
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[mutateStream]
|
||||
)
|
||||
|
||||
const scheduleDeltaFlush = useCallback(() => {
|
||||
if (flushHandleRef.current !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
flushQueuedDeltas()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof window.requestAnimationFrame === 'function') {
|
||||
flushHandleRef.current = window.requestAnimationFrame(() => {
|
||||
flushHandleRef.current = null
|
||||
flushQueuedDeltas()
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
flushHandleRef.current = window.setTimeout(() => {
|
||||
flushHandleRef.current = null
|
||||
flushQueuedDeltas()
|
||||
}, STREAM_DELTA_FLUSH_MS)
|
||||
}, [flushQueuedDeltas])
|
||||
|
||||
const queueDelta = useCallback(
|
||||
(sessionId: string, key: keyof QueuedStreamDeltas, delta: string) => {
|
||||
if (!delta) {
|
||||
return
|
||||
}
|
||||
|
||||
const queued = queuedDeltasRef.current.get(sessionId) ?? { assistant: '', reasoning: '' }
|
||||
queued[key] += delta
|
||||
queuedDeltasRef.current.set(sessionId, queued)
|
||||
scheduleDeltaFlush()
|
||||
},
|
||||
[scheduleDeltaFlush]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (flushHandleRef.current !== null && typeof window !== 'undefined') {
|
||||
if (typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(flushHandleRef.current)
|
||||
} else {
|
||||
window.clearTimeout(flushHandleRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
flushHandleRef.current = null
|
||||
flushQueuedDeltas()
|
||||
},
|
||||
[flushQueuedDeltas]
|
||||
)
|
||||
|
||||
const appendAssistantDelta = useCallback(
|
||||
(sessionId: string, delta: string) => {
|
||||
if (!delta) {
|
||||
return
|
||||
}
|
||||
|
||||
mutateStream(
|
||||
sessionId,
|
||||
parts => appendAssistantTextPart(parts, delta),
|
||||
() => [assistantTextPart(delta)]
|
||||
)
|
||||
queueDelta(sessionId, 'assistant', delta)
|
||||
},
|
||||
[mutateStream]
|
||||
[queueDelta]
|
||||
)
|
||||
|
||||
const appendReasoningDelta = useCallback(
|
||||
@@ -144,6 +240,14 @@ export function useMessageStream({
|
||||
return
|
||||
}
|
||||
|
||||
if (!replace) {
|
||||
queueDelta(sessionId, 'reasoning', delta)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
mutateStream(
|
||||
sessionId,
|
||||
(parts, message) => {
|
||||
@@ -160,7 +264,7 @@ export function useMessageStream({
|
||||
() => [reasoningPart(delta)]
|
||||
)
|
||||
},
|
||||
[mutateStream]
|
||||
[flushQueuedDeltas, mutateStream, queueDelta]
|
||||
)
|
||||
|
||||
const upsertToolCall = useCallback(
|
||||
@@ -384,6 +488,8 @@ export function useMessageStream({
|
||||
return
|
||||
}
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
if (isActiveEvent) {
|
||||
triggerHaptic('streamStart')
|
||||
}
|
||||
@@ -421,6 +527,8 @@ export function useMessageStream({
|
||||
return
|
||||
}
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
if (isActiveEvent) {
|
||||
triggerHaptic('streamDone')
|
||||
}
|
||||
@@ -440,9 +548,12 @@ export function useMessageStream({
|
||||
return
|
||||
}
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
upsertToolCall(sessionId, payload, 'running')
|
||||
} else if (event.type === 'tool.complete') {
|
||||
if (sessionId) {
|
||||
flushQueuedDeltas(sessionId)
|
||||
upsertToolCall(sessionId, payload, 'complete')
|
||||
}
|
||||
|
||||
@@ -478,6 +589,7 @@ export function useMessageStream({
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
flushQueuedDeltas(sessionId)
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
awaitingResponse: false,
|
||||
@@ -495,6 +607,7 @@ export function useMessageStream({
|
||||
appendReasoningDelta,
|
||||
activeSessionIdRef,
|
||||
completeAssistantMessage,
|
||||
flushQueuedDeltas,
|
||||
queryClient,
|
||||
refreshHermesConfig,
|
||||
updateSessionState,
|
||||
|
||||
@@ -65,6 +65,7 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
||||
if (selection.persistGlobal) {
|
||||
void refreshCurrentModel()
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
|
||||
})
|
||||
|
||||
@@ -134,11 +134,13 @@ export function usePreviewRouting({
|
||||
if (!candidate) {
|
||||
return
|
||||
}
|
||||
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.normalizePreviewTarget) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = previewSessionId
|
||||
const cwd = currentCwd || ''
|
||||
const target = await desktop.normalizePreviewTarget(candidate, cwd || undefined).catch(() => null)
|
||||
|
||||
@@ -25,6 +25,7 @@ function rawHashLooksLikeSession(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
const hash = window.location.hash.replace(/^#/, '')
|
||||
|
||||
if (!hash || hash === '/') {
|
||||
|
||||
@@ -29,6 +29,8 @@ export function useSessionStateCache({
|
||||
const selectedStoredSessionIdRef = useRef<string | null>(null)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>())
|
||||
const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null)
|
||||
const viewSyncRafRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
activeSessionIdRef.current = activeSessionId
|
||||
@@ -78,18 +80,60 @@ export function useSessionStateCache({
|
||||
|
||||
const syncSessionStateToView = useCallback(
|
||||
(sessionId: string, state: ClientSessionState) => {
|
||||
if (sessionId !== activeSessionIdRef.current) {
|
||||
pendingViewStateRef.current = { sessionId, state }
|
||||
|
||||
if (viewSyncRafRef.current !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
setMessages(state.messages)
|
||||
setBusy(state.busy)
|
||||
busyRef.current = state.busy
|
||||
setAwaitingResponse(state.awaitingResponse)
|
||||
if (typeof window === 'undefined') {
|
||||
const pending = pendingViewStateRef.current
|
||||
|
||||
if (!pending || pending.sessionId !== activeSessionIdRef.current) {
|
||||
pendingViewStateRef.current = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pendingViewStateRef.current = null
|
||||
setMessages(pending.state.messages)
|
||||
setBusy(pending.state.busy)
|
||||
busyRef.current = pending.state.busy
|
||||
setAwaitingResponse(pending.state.awaitingResponse)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
viewSyncRafRef.current = window.requestAnimationFrame(() => {
|
||||
viewSyncRafRef.current = null
|
||||
const pending = pendingViewStateRef.current
|
||||
|
||||
if (!pending || pending.sessionId !== activeSessionIdRef.current) {
|
||||
pendingViewStateRef.current = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pendingViewStateRef.current = null
|
||||
setMessages(pending.state.messages)
|
||||
setBusy(pending.state.busy)
|
||||
busyRef.current = pending.state.busy
|
||||
setAwaitingResponse(pending.state.awaitingResponse)
|
||||
})
|
||||
},
|
||||
[busyRef, setAwaitingResponse, setBusy, setMessages]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (viewSyncRafRef.current !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(viewSyncRafRef.current)
|
||||
viewSyncRafRef.current = null
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const updateSessionState = useCallback(
|
||||
(
|
||||
sessionId: string,
|
||||
|
||||
Reference in New Issue
Block a user