feat: file tabs
This commit is contained in:
@@ -38,7 +38,13 @@ import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks'
|
||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||
import { composerPlainText, placeCaretEnd, refChipElement, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
|
||||
import {
|
||||
composerPlainText,
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
import { SkinSlashPopover } from './skin-slash-popover'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
|
||||
@@ -36,6 +36,7 @@ type PreviewWebview = HTMLElement & {
|
||||
}
|
||||
|
||||
interface PreviewPaneProps {
|
||||
embedded?: boolean
|
||||
onClose: () => void
|
||||
onRestartServer?: (url: string, context?: string) => Promise<string>
|
||||
reloadRequest?: number
|
||||
@@ -359,15 +360,30 @@ function PreviewConsolePanel({
|
||||
const selectedLogIds = useStore(consoleState.$selectedLogIds)
|
||||
const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds])
|
||||
const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
|
||||
const stickScrollRafRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!consoleShouldStickRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const consoleBody = consoleBodyRef.current
|
||||
if (stickScrollRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(stickScrollRafRef.current)
|
||||
stickScrollRafRef.current = null
|
||||
}
|
||||
|
||||
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
|
||||
stickScrollRafRef.current = window.requestAnimationFrame(() => {
|
||||
stickScrollRafRef.current = null
|
||||
const consoleBody = consoleBodyRef.current
|
||||
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (stickScrollRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(stickScrollRafRef.current)
|
||||
stickScrollRafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [consoleBodyRef, consoleHeight, consoleShouldStickRef, logs])
|
||||
|
||||
function sendLogsToComposer(entries: ConsoleEntry[]) {
|
||||
@@ -917,6 +933,7 @@ function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: Pr
|
||||
const TITLEBAR_GROUP_ID = 'preview'
|
||||
|
||||
export function PreviewPane({
|
||||
embedded = false,
|
||||
onClose,
|
||||
onRestartServer,
|
||||
reloadRequest = 0,
|
||||
@@ -1136,9 +1153,12 @@ export function PreviewPane({
|
||||
|
||||
consoleShouldStickRef.current = true
|
||||
|
||||
const consoleBody = consoleBodyRef.current
|
||||
const handle = window.requestAnimationFrame(() => {
|
||||
const consoleBody = consoleBodyRef.current
|
||||
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
|
||||
})
|
||||
|
||||
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
|
||||
return () => window.cancelAnimationFrame(handle)
|
||||
}, [consoleOpen])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1423,21 +1443,23 @@ export function PreviewPane({
|
||||
}, [appendConsoleEntry, consoleState, isWebPreview, target.url])
|
||||
|
||||
return (
|
||||
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
|
||||
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-background text-muted-foreground">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
className="pointer-events-auto inline max-w-full cursor-pointer truncate text-left text-xs font-medium text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline"
|
||||
href={currentUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={`Open ${currentUrl}`}
|
||||
>
|
||||
{previewLabel || 'Preview'}
|
||||
</a>
|
||||
{!embedded && (
|
||||
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
className="pointer-events-auto inline max-w-full cursor-pointer truncate text-left text-xs font-medium text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline"
|
||||
href={currentUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={`Open ${currentUrl}`}
|
||||
>
|
||||
{previewLabel || 'Preview'}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="pointer-events-auto relative min-h-0 flex-1 overflow-hidden bg-background"
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type MouseEvent, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
|
||||
import { X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$filePreviewTarget,
|
||||
$rightRailActiveTabId,
|
||||
RIGHT_RAIL_PREVIEW_TAB_ID,
|
||||
type RightRailTabId,
|
||||
selectRightRailTab
|
||||
} from '@/store/layout'
|
||||
import {
|
||||
$filePreviewTabs,
|
||||
$previewReloadRequest,
|
||||
$previewTarget,
|
||||
dismissFilePreviewTarget,
|
||||
dismissPreviewTarget
|
||||
closeFilePreviewTab,
|
||||
dismissPreviewTarget,
|
||||
type FilePreviewTab,
|
||||
type PreviewTarget
|
||||
} from '@/store/preview'
|
||||
|
||||
import { PreviewPane } from './preview-pane'
|
||||
@@ -27,23 +38,152 @@ interface ChatPreviewRailProps {
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
interface RailTab {
|
||||
closeLabel: string
|
||||
id: RightRailTabId
|
||||
label: string
|
||||
target: PreviewTarget
|
||||
}
|
||||
|
||||
function previewTabLabel(target: PreviewTarget): string {
|
||||
const value = target.label || target.path || target.source || target.url
|
||||
const parts = value.split(/[\\/]/).filter(Boolean)
|
||||
|
||||
return parts.at(-1) || value || 'Preview'
|
||||
}
|
||||
|
||||
function tabLabel(tab: FilePreviewTab): string {
|
||||
return previewTabLabel(tab.target)
|
||||
}
|
||||
|
||||
export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatPreviewRailProps) {
|
||||
const previewReloadRequest = useStore($previewReloadRequest)
|
||||
const filePreviewTarget = useStore($filePreviewTarget)
|
||||
const activeTabId = useStore($rightRailActiveTabId)
|
||||
const filePreviewTabs = useStore($filePreviewTabs)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const target = filePreviewTarget ?? previewTarget
|
||||
|
||||
if (!target) {
|
||||
const tabs = useMemo<readonly RailTab[]>(
|
||||
() => [
|
||||
...(previewTarget
|
||||
? [
|
||||
{
|
||||
closeLabel: 'Close preview',
|
||||
id: RIGHT_RAIL_PREVIEW_TAB_ID,
|
||||
label: 'Preview',
|
||||
target: previewTarget
|
||||
} satisfies RailTab
|
||||
]
|
||||
: []),
|
||||
...filePreviewTabs.map(tab => ({
|
||||
closeLabel: `Close ${tabLabel(tab)}`,
|
||||
id: tab.id,
|
||||
label: tabLabel(tab),
|
||||
target: tab.target
|
||||
}))
|
||||
],
|
||||
[filePreviewTabs, previewTarget]
|
||||
)
|
||||
|
||||
const activeTab = tabs.find(tab => tab.id === activeTabId) ?? tabs[0]
|
||||
// Read-by-ref so close handlers stay reference-stable across renders.
|
||||
const activeTabRef = useRef<RailTab | undefined>(activeTab)
|
||||
activeTabRef.current = activeTab
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab && activeTab.id !== activeTabId) {
|
||||
selectRightRailTab(activeTab.id)
|
||||
}
|
||||
}, [activeTab, activeTabId])
|
||||
|
||||
const closeRailTab = useCallback((tab: RailTab) => {
|
||||
if (tab.id === RIGHT_RAIL_PREVIEW_TAB_ID) {
|
||||
dismissPreviewTarget()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
closeFilePreviewTab(tab.id)
|
||||
}, [])
|
||||
|
||||
// Stable: PreviewPane lists onClose in a useEffect dep array that pushes
|
||||
// titlebar tools. A fresh closure every render → setTitlebarToolGroup every
|
||||
// render → DesktopController setState → re-render → ∞.
|
||||
const handleCloseDocument = useCallback(() => {
|
||||
const tab = activeTabRef.current
|
||||
|
||||
if (tab) {
|
||||
closeRailTab(tab)
|
||||
}
|
||||
}, [closeRailTab])
|
||||
|
||||
const closeTab = (event: MouseEvent, tab: RailTab) => {
|
||||
event.stopPropagation()
|
||||
closeRailTab(tab)
|
||||
}
|
||||
|
||||
if (!activeTab) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID
|
||||
|
||||
return (
|
||||
<PreviewPane
|
||||
onClose={filePreviewTarget ? dismissFilePreviewTarget : dismissPreviewTarget}
|
||||
onRestartServer={filePreviewTarget ? undefined : onRestartServer}
|
||||
reloadRequest={previewReloadRequest}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={target}
|
||||
/>
|
||||
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
|
||||
<div
|
||||
className="flex h-(--titlebar-height) shrink-0 overflow-x-auto overflow-y-hidden overscroll-x-contain border-b border-border/60 bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_94%,transparent)] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map(tab => {
|
||||
const active = tab.id === activeTab.id
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/tab relative flex h-full max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag]',
|
||||
active
|
||||
? 'bg-background text-foreground'
|
||||
: 'border-r border-border/40 text-muted-foreground hover:bg-accent/30 hover:text-foreground'
|
||||
)}
|
||||
key={tab.id}
|
||||
>
|
||||
{active && <span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-primary/70" />}
|
||||
<button
|
||||
aria-selected={active}
|
||||
className="flex h-full min-w-0 flex-1 items-center truncate pl-3 pr-1.5 text-left outline-none"
|
||||
onClick={() => selectRightRailTab(tab.id)}
|
||||
role="tab"
|
||||
title={tab.label}
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
<button
|
||||
aria-label={tab.closeLabel}
|
||||
className={cn(
|
||||
'mr-1.5 hidden size-4 shrink-0 place-items-center rounded-sm text-muted-foreground/55 transition-colors hover:bg-accent hover:text-foreground focus-visible:grid group-hover/tab:grid',
|
||||
active && 'grid'
|
||||
)}
|
||||
onClick={event => closeTab(event, tab)}
|
||||
title={tab.closeLabel}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<PreviewPane
|
||||
embedded
|
||||
onClose={handleCloseDocument}
|
||||
onRestartServer={isPreview ? onRestartServer : undefined}
|
||||
reloadRequest={previewReloadRequest}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={activeTab.target}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef } from 'react'
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
@@ -35,13 +35,15 @@ import {
|
||||
setSessionsLoading
|
||||
} from '../store/session'
|
||||
|
||||
import { AgentsView } from './agents'
|
||||
import { ArtifactsView } from './artifacts'
|
||||
import { ChatView } from './chat'
|
||||
import { useComposerActions } from './chat/hooks/use-composer-actions'
|
||||
import { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH, PREVIEW_RAIL_PANE_WIDTH } from './chat/right-rail'
|
||||
import {
|
||||
ChatPreviewRail,
|
||||
PREVIEW_RAIL_MAX_WIDTH,
|
||||
PREVIEW_RAIL_MIN_WIDTH,
|
||||
PREVIEW_RAIL_PANE_WIDTH
|
||||
} from './chat/right-rail'
|
||||
import { ChatSidebar } from './chat/sidebar'
|
||||
import { CommandCenterView } from './command-center'
|
||||
import { FileBrowserPane } from './file-browser'
|
||||
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
||||
@@ -57,7 +59,6 @@ import { usePromptActions } from './session/hooks/use-prompt-actions'
|
||||
import { useRouteResume } from './session/hooks/use-route-resume'
|
||||
import { useSessionActions } from './session/hooks/use-session-actions'
|
||||
import { useSessionStateCache } from './session/hooks/use-session-state-cache'
|
||||
import { SettingsView } from './settings'
|
||||
import { AppShell } from './shell/app-shell'
|
||||
import { useOverlayRouting } from './shell/hooks/use-overlay-routing'
|
||||
import { useStatusSnapshot } from './shell/hooks/use-status-snapshot'
|
||||
@@ -65,7 +66,12 @@ import { useStatusbarItems } from './shell/hooks/use-statusbar-items'
|
||||
import type { StatusbarItem } from './shell/statusbar-controls'
|
||||
import type { TitlebarTool } from './shell/titlebar-controls'
|
||||
import { useGroupRegistry } from './shell/use-group-registry'
|
||||
import { SkillsView } from './skills'
|
||||
|
||||
const AgentsView = lazy(async () => ({ default: (await import('./agents')).AgentsView }))
|
||||
const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView }))
|
||||
const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView }))
|
||||
const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView }))
|
||||
const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView }))
|
||||
|
||||
export function DesktopController() {
|
||||
const queryClient = useQueryClient()
|
||||
@@ -404,34 +410,42 @@ export function DesktopController() {
|
||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||
|
||||
{settingsOpen && (
|
||||
<SettingsView
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onConfigSaved={() => {
|
||||
void refreshHermesConfig()
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<SettingsView
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onConfigSaved={() => {
|
||||
void refreshHermesConfig()
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{commandCenterOpen && (
|
||||
<CommandCenterView
|
||||
initialSection={commandCenterInitialSection}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onDeleteSession={removeSession}
|
||||
onMainModelChanged={(provider, model) => {
|
||||
setCurrentProvider(provider)
|
||||
setCurrentModel(model)
|
||||
updateModelOptionsCache(provider, model, true)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
onNavigateRoute={path => navigate(path)}
|
||||
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<CommandCenterView
|
||||
initialSection={commandCenterInitialSection}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onDeleteSession={removeSession}
|
||||
onMainModelChanged={(provider, model) => {
|
||||
setCurrentProvider(provider)
|
||||
setCurrentModel(model)
|
||||
updateModelOptionsCache(provider, model, true)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
onNavigateRoute={path => navigate(path)}
|
||||
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{agentsOpen && <AgentsView onClose={closeOverlayToPreviousRoute} />}
|
||||
{agentsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<AgentsView onClose={closeOverlayToPreviousRoute} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -489,16 +503,20 @@ export function DesktopController() {
|
||||
<Route element={chatView} path=":sessionId" />
|
||||
<Route
|
||||
element={
|
||||
<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
<Suspense fallback={null}>
|
||||
<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
</Suspense>
|
||||
}
|
||||
path="skills"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ArtifactsView
|
||||
setStatusbarItemGroup={setStatusbarItemGroup}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<ArtifactsView
|
||||
setStatusbarItemGroup={setStatusbarItemGroup}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
path="artifacts"
|
||||
/>
|
||||
|
||||
@@ -24,12 +24,14 @@ interface FileBrowserPaneProps {
|
||||
export function FileBrowserPane({ onActivateFile, onChangeCwd }: FileBrowserPaneProps) {
|
||||
const currentCwd = useStore($currentCwd).trim()
|
||||
const hasCwd = currentCwd.length > 0
|
||||
|
||||
const cwdName = hasCwd
|
||||
? (currentCwd
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? currentCwd)
|
||||
: 'No folder selected'
|
||||
|
||||
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
|
||||
|
||||
const chooseFolder = async () => {
|
||||
|
||||
@@ -179,6 +179,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
if (!cwd || inflight.has(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
inflight.add(id)
|
||||
|
||||
setProjectTree(current => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -121,7 +121,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
{visiblePaneTools.length > 0 && (
|
||||
<div
|
||||
aria-label="Pane controls"
|
||||
className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0px))] z-70 flex flex-row items-center gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))] z-70 flex flex-row items-center gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{visiblePaneTools.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
|
||||
Reference in New Issue
Block a user