feat: file tabs

This commit is contained in:
Brooklyn Nicholson
2026-05-05 13:17:40 -05:00
parent 5ec0667fb3
commit 5269012c51
27 changed files with 763 additions and 133 deletions
@@ -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,