mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 09:01:54 +00:00
feat: file tabs
This commit is contained in:
parent
5ec0667fb3
commit
5269012c51
27 changed files with 763 additions and 133 deletions
|
|
@ -32,6 +32,8 @@ const BUNDLED_HERMES_ROOT = path.join(process.resourcesPath, 'hermes-agent')
|
|||
const BUNDLED_VENV_ROOT = path.join(app.getPath('userData'), 'hermes-runtime')
|
||||
const BUNDLED_VENV_MARKER = path.join(BUNDLED_VENV_ROOT, '.hermes-desktop-runtime.json')
|
||||
const DESKTOP_LOG_PATH = path.join(app.getPath('userData'), 'desktop.log')
|
||||
const DESKTOP_LOG_FLUSH_MS = 120
|
||||
const DESKTOP_LOG_BUFFER_MAX_CHARS = 64 * 1024
|
||||
const RUNTIME_SCHEMA_VERSION = 3
|
||||
const BUNDLED_RUNTIME_REQUIREMENTS = [
|
||||
'openai>=2.21.0,<3',
|
||||
|
|
@ -186,6 +188,47 @@ let connectionPromise = null
|
|||
const hermesLog = []
|
||||
const previewWatchers = new Map()
|
||||
let previewShortcutActive = false
|
||||
let desktopLogBuffer = ''
|
||||
let desktopLogFlushTimer = null
|
||||
let desktopLogFlushPromise = Promise.resolve()
|
||||
|
||||
function flushDesktopLogBufferSync() {
|
||||
if (!desktopLogBuffer) return
|
||||
const chunk = desktopLogBuffer
|
||||
desktopLogBuffer = ''
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
|
||||
fs.appendFileSync(DESKTOP_LOG_PATH, chunk)
|
||||
} catch {
|
||||
// Logging must never block app startup/shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
function flushDesktopLogBufferAsync() {
|
||||
if (!desktopLogBuffer) return desktopLogFlushPromise
|
||||
const chunk = desktopLogBuffer
|
||||
desktopLogBuffer = ''
|
||||
|
||||
desktopLogFlushPromise = desktopLogFlushPromise
|
||||
.then(async () => {
|
||||
await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
|
||||
await fs.promises.appendFile(DESKTOP_LOG_PATH, chunk)
|
||||
})
|
||||
.catch(() => {
|
||||
// Logging must never crash the desktop shell.
|
||||
})
|
||||
|
||||
return desktopLogFlushPromise
|
||||
}
|
||||
|
||||
function scheduleDesktopLogFlush() {
|
||||
if (desktopLogFlushTimer) return
|
||||
desktopLogFlushTimer = setTimeout(() => {
|
||||
desktopLogFlushTimer = null
|
||||
void flushDesktopLogBufferAsync()
|
||||
}, DESKTOP_LOG_FLUSH_MS)
|
||||
}
|
||||
|
||||
function rememberLog(chunk) {
|
||||
const text = String(chunk || '').trim()
|
||||
|
|
@ -196,12 +239,19 @@ function rememberLog(chunk) {
|
|||
hermesLog.splice(0, hermesLog.length - 300)
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
|
||||
fs.appendFileSync(DESKTOP_LOG_PATH, `${lines.join('\n')}\n`)
|
||||
} catch {
|
||||
// Logging must never block app startup.
|
||||
desktopLogBuffer += `${lines.join('\n')}\n`
|
||||
|
||||
if (desktopLogBuffer.length >= DESKTOP_LOG_BUFFER_MAX_CHARS) {
|
||||
if (desktopLogFlushTimer) {
|
||||
clearTimeout(desktopLogFlushTimer)
|
||||
desktopLogFlushTimer = null
|
||||
}
|
||||
void flushDesktopLogBufferAsync()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
scheduleDesktopLogFlush()
|
||||
}
|
||||
|
||||
function fileExists(filePath) {
|
||||
|
|
@ -1156,6 +1206,7 @@ function createWindow() {
|
|||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
webviewTag: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
devTools: Boolean(DEV_SERVER)
|
||||
}
|
||||
|
|
@ -1177,6 +1228,10 @@ function createWindow() {
|
|||
} else {
|
||||
mainWindow.loadURL(pathToFileURL(resolveRendererIndex()).toString())
|
||||
}
|
||||
|
||||
mainWindow.webContents.once('did-finish-load', () => {
|
||||
startHermes().catch(error => rememberLog(error.stack || error.message))
|
||||
})
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:connection', async () => startHermes())
|
||||
|
|
@ -1461,7 +1516,6 @@ app.whenReady().then(() => {
|
|||
Menu.setApplicationMenu(buildApplicationMenu())
|
||||
installMediaPermissions()
|
||||
createWindow()
|
||||
startHermes().catch(error => rememberLog(error.stack || error.message))
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
|
|
@ -1469,6 +1523,11 @@ app.whenReady().then(() => {
|
|||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
if (desktopLogFlushTimer) {
|
||||
clearTimeout(desktopLogFlushTimer)
|
||||
desktopLogFlushTimer = null
|
||||
}
|
||||
flushDesktopLogBufferSync()
|
||||
closePreviewWatchers()
|
||||
|
||||
if (hermesProcess && !hermesProcess.killed) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
|
||||
"dev:renderer": "vite --host 127.0.0.1 --port 5174",
|
||||
"dev:electron": "wait-on http://127.0.0.1:5174 && cross-env HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
|
||||
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"start": "npm run build && electron .",
|
||||
"build": "tsc -b && vite build",
|
||||
"stage:hermes": "node scripts/stage-hermes-payload.mjs",
|
||||
|
|
@ -121,7 +123,7 @@
|
|||
"asar": true,
|
||||
"afterSign": "scripts/notarize.cjs",
|
||||
"asarUnpack": [
|
||||
"dist/**"
|
||||
"**/*.node"
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
'use client'
|
||||
|
||||
import { type StreamdownTextComponents, StreamdownTextPrimitive } from '@assistant-ui/react-streamdown'
|
||||
import { useAuiState } from '@assistant-ui/react'
|
||||
import {
|
||||
type StreamdownTextComponents,
|
||||
StreamdownTextPrimitive,
|
||||
type SyntaxHighlighterProps
|
||||
} from '@assistant-ui/react-streamdown'
|
||||
import { code } from '@streamdown/code'
|
||||
import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
|
|
@ -203,6 +208,8 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
|
|||
}
|
||||
|
||||
const MarkdownTextImpl = () => {
|
||||
const isStreaming = useAuiState(s => s.message.status?.type === 'running')
|
||||
|
||||
const components = useMemo(
|
||||
() =>
|
||||
({
|
||||
|
|
@ -267,10 +274,10 @@ const MarkdownTextImpl = () => {
|
|||
<td className={cn('px-3 py-2 align-top text-sm leading-snug', className)} {...props} />
|
||||
),
|
||||
img: MarkdownImage,
|
||||
SyntaxHighlighter,
|
||||
SyntaxHighlighter: (props: SyntaxHighlighterProps) => <SyntaxHighlighter {...props} defer={isStreaming} />,
|
||||
CodeHeader
|
||||
}) as StreamdownTextComponents,
|
||||
[]
|
||||
[isStreaming]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -280,8 +287,8 @@ const MarkdownTextImpl = () => {
|
|||
containerClassName="aui-md max-w-full overflow-hidden text-foreground"
|
||||
lineNumbers={false}
|
||||
mode="streaming"
|
||||
parseIncompleteMarkdown
|
||||
plugins={{ code }}
|
||||
parseIncompleteMarkdown={!isStreaming}
|
||||
plugins={isStreaming ? undefined : { code }}
|
||||
preprocess={preprocessMarkdown}
|
||||
shikiTheme={['github-light-default', 'github-dark-default']}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -19,10 +19,15 @@ import { isLikelyProseCodeBlock } from '@/lib/markdown-code'
|
|||
* `showLanguage` is disabled because we render our own `CodeHeader`;
|
||||
* leaving it on causes the language to appear twice.
|
||||
*/
|
||||
export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
|
||||
interface HermesSyntaxHighlighterProps extends SyntaxHighlighterProps {
|
||||
defer?: boolean
|
||||
}
|
||||
|
||||
export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
||||
components: { Pre, Code: _UnusedCode },
|
||||
language,
|
||||
code
|
||||
code,
|
||||
defer = false
|
||||
}) => {
|
||||
// Streamdown may hand us fence contents with edge newlines. Strip blank
|
||||
// fence padding without touching indentation on the first real line.
|
||||
|
|
@ -38,6 +43,14 @@ export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
|
|||
return <div className="whitespace-pre-wrap wrap-anywhere text-foreground">{trimmed}</div>
|
||||
}
|
||||
|
||||
if (defer) {
|
||||
return (
|
||||
<Pre className="aui-shiki m-0 overflow-hidden rounded-b-md border border-t-0 border-border bg-card font-mono text-sm leading-relaxed [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-4 [&_pre]:py-3 [&_pre]:font-mono [&_pre]:leading-relaxed">
|
||||
<code className="block whitespace-pre">{trimmed}</code>
|
||||
</Pre>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Pre className="aui-shiki m-0 overflow-hidden rounded-b-md border border-t-0 border-border bg-card font-mono text-sm leading-relaxed [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-4 [&_pre]:py-3 [&_pre]:font-mono [&_pre]:leading-relaxed">
|
||||
<ShikiHighlighter
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
useAuiState
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type FC, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { type FC, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import spinners from 'unicode-animations'
|
||||
// Scroll behavior: delegated to `use-stick-to-bottom` (StackBlitz), the
|
||||
// reference implementation that powers bolt.new and several other streaming
|
||||
|
|
@ -204,6 +204,9 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
|
|||
// "Armed" behavior ref. Non-null = "keep chasing bottom across resize
|
||||
// ticks until we get there." Null = "user owns the viewport."
|
||||
const armedRef = useRef<ScrollBehavior | null>(null)
|
||||
const pinRafRef = useRef<number | null>(null)
|
||||
const previousScrollTopRef = useRef(0)
|
||||
const suppressNextScrollEventRef = useRef(false)
|
||||
|
||||
const messageCount = useAuiState(s => s.thread.messages.length)
|
||||
const prevMessageCountRef = useRef(messageCount)
|
||||
|
|
@ -233,7 +236,9 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
|
|||
// state object so its resize handler sees a clean follow state.
|
||||
state.escapedFromLock = false
|
||||
state.isAtBottom = true
|
||||
suppressNextScrollEventRef.current = true
|
||||
el.scrollTop = el.scrollHeight
|
||||
previousScrollTopRef.current = el.scrollTop
|
||||
},
|
||||
[scrollRef, state]
|
||||
)
|
||||
|
|
@ -248,21 +253,29 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
|
|||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
const behavior = armedRef.current
|
||||
|
||||
if (!behavior) {
|
||||
if (pinRafRef.current !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
const distance = el.scrollHeight - (el.scrollTop + el.clientHeight)
|
||||
pinRafRef.current = window.requestAnimationFrame(() => {
|
||||
pinRafRef.current = null
|
||||
|
||||
if (distance < 2) {
|
||||
armedRef.current = null
|
||||
if (!armedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
const distance = el.scrollHeight - (el.scrollTop + el.clientHeight)
|
||||
|
||||
el.scrollTop = el.scrollHeight
|
||||
if (distance < 2) {
|
||||
armedRef.current = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
suppressNextScrollEventRef.current = true
|
||||
el.scrollTop = el.scrollHeight
|
||||
previousScrollTopRef.current = el.scrollTop
|
||||
})
|
||||
})
|
||||
|
||||
observer.observe(el)
|
||||
|
|
@ -273,7 +286,14 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
|
|||
observer.observe(content)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
|
||||
if (pinRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(pinRafRef.current)
|
||||
pinRafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [scrollRef])
|
||||
|
||||
// User-intent detection — any upward gesture disarms the chase.
|
||||
|
|
@ -294,12 +314,31 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
|
|||
armedRef.current = null
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
const currentTop = el.scrollTop
|
||||
|
||||
if (suppressNextScrollEventRef.current) {
|
||||
suppressNextScrollEventRef.current = false
|
||||
previousScrollTopRef.current = currentTop
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (currentTop + 1 < previousScrollTopRef.current) {
|
||||
armedRef.current = null
|
||||
}
|
||||
|
||||
previousScrollTopRef.current = currentTop
|
||||
}
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: true })
|
||||
el.addEventListener('touchmove', onTouch, { passive: true })
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('touchmove', onTouch)
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [scrollRef])
|
||||
|
||||
|
|
@ -409,12 +448,30 @@ const ComposerClearance: FC = () => {
|
|||
}
|
||||
|
||||
bindComposer()
|
||||
const mutationObserver = new MutationObserver(() => void bindComposer())
|
||||
mutationObserver.observe(document.body, { childList: true, subtree: true })
|
||||
let bindRaf: number | null = null
|
||||
let bindAttempts = 0
|
||||
|
||||
const tryBindComposer = () => {
|
||||
if (bindComposer()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (bindAttempts >= 120) {
|
||||
return
|
||||
}
|
||||
|
||||
bindAttempts += 1
|
||||
bindRaf = window.requestAnimationFrame(tryBindComposer)
|
||||
}
|
||||
|
||||
tryBindComposer()
|
||||
|
||||
return () => {
|
||||
composerObserver?.disconnect()
|
||||
mutationObserver.disconnect()
|
||||
|
||||
if (bindRaf !== null) {
|
||||
window.cancelAnimationFrame(bindRaf)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
@ -452,7 +509,15 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
|||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
const previewTargets = pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
|
||||
|
||||
const previewTargets = useMemo(() => {
|
||||
if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
|
||||
}, [messageText])
|
||||
|
||||
const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0)
|
||||
|
||||
if (isPlaceholder) {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import {
|
|||
Children,
|
||||
type CSSProperties,
|
||||
isValidElement,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
|
|
@ -71,10 +71,14 @@ const remPx = () =>
|
|||
|
||||
// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem") to pixels for drag clamping.
|
||||
function widthToPx(value: WidthValue | undefined) {
|
||||
if (typeof value === 'number') {return Number.isFinite(value) ? value : undefined}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/)
|
||||
|
||||
if (!match) {return undefined}
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Number.parseFloat(match[1]) * (match[2] === 'rem' ? remPx() : 1)
|
||||
}
|
||||
|
|
@ -95,7 +99,9 @@ function collectPanes(children: ReactNode) {
|
|||
return
|
||||
}
|
||||
|
||||
if (!isRole(child, 'pane')) {return}
|
||||
if (!isRole(child, 'pane')) {
|
||||
return
|
||||
}
|
||||
|
||||
const props = child.props as PaneProps
|
||||
|
||||
|
|
@ -117,7 +123,9 @@ function trackForPane(pane: CollectedPane, states: Record<string, { open: boolea
|
|||
const stateOpen = states[pane.id]?.open ?? pane.defaultOpen
|
||||
const open = !pane.disabled && stateOpen
|
||||
|
||||
if (!open) {return { open: false, track: '0px' }}
|
||||
if (!open) {
|
||||
return { open: false, track: '0px' }
|
||||
}
|
||||
|
||||
const override = states[pane.id]?.widthOverride
|
||||
|
||||
|
|
@ -192,7 +200,9 @@ export function Pane({
|
|||
const paneRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (registered.current) {return}
|
||||
if (registered.current) {
|
||||
return
|
||||
}
|
||||
registered.current = true
|
||||
ensurePaneRegistered(id, { open: defaultOpen })
|
||||
}, [defaultOpen, id])
|
||||
|
|
@ -208,7 +218,9 @@ export function Pane({
|
|||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const paneWidth = paneRef.current?.getBoundingClientRect().width ?? 0
|
||||
|
||||
if (!canResize || paneWidth <= 0) {return}
|
||||
if (!canResize || paneWidth <= 0) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
|
||||
const handle = event.currentTarget
|
||||
|
|
@ -245,12 +257,16 @@ export function Pane({
|
|||
)
|
||||
|
||||
if (!ctx) {
|
||||
if (import.meta.env.DEV) {console.warn(`[Pane:${id}] must be rendered inside <PaneShell>`)}
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`[Pane:${id}] must be rendered inside <PaneShell>`)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (!slot) {return null}
|
||||
if (!slot) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -288,7 +304,9 @@ export function PaneMain({ children, className }: PaneMainProps) {
|
|||
const ctx = useContext(PaneShellContext)
|
||||
|
||||
if (!ctx) {
|
||||
if (import.meta.env.DEV) {console.warn('[PaneMain] must be rendered inside <PaneShell>')}
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[PaneMain] must be rendered inside <PaneShell>')
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,7 +174,14 @@ export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string)
|
|||
const last = next.at(-1)
|
||||
|
||||
if (last?.type === 'text') {
|
||||
next[next.length - 1] = { ...last, text: renderMediaTags(last.text) }
|
||||
const current = last.text
|
||||
|
||||
const deltaMayContainMedia =
|
||||
delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:')
|
||||
|
||||
const needsMediaPass = deltaMayContainMedia || current.includes('MEDIA:')
|
||||
const nextText = needsMediaPass ? renderMediaTags(current) : current
|
||||
next[next.length - 1] = nextText === current ? last : { ...last, text: nextText }
|
||||
}
|
||||
|
||||
return next
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean {
|
|||
if (event.type !== 'tool.complete') {
|
||||
return false
|
||||
}
|
||||
|
||||
const diff = asRecord(event.payload).inline_diff
|
||||
|
||||
return typeof diff === 'string' && diff.trim().length > 0
|
||||
|
|
|
|||
|
|
@ -14,9 +14,10 @@ const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
|
|||
|
||||
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
|
||||
export const FILE_BROWSER_PANE_ID = 'file-browser'
|
||||
export const RIGHT_RAIL_PREVIEW_TAB_ID = 'preview'
|
||||
|
||||
export type RightRailTabId = typeof RIGHT_RAIL_PREVIEW_TAB_ID | `file:${string}`
|
||||
|
||||
// Pre-register app chrome panes so legacy callers see the correct initial
|
||||
// values whether or not the user has any persisted state.
|
||||
ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true })
|
||||
ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false })
|
||||
|
||||
|
|
@ -30,6 +31,8 @@ export const $fileBrowserOpen: ReadableAtom<boolean> = computed(
|
|||
states => states[FILE_BROWSER_PANE_ID]?.open ?? false
|
||||
)
|
||||
|
||||
export const $rightRailActiveTabId = atom<RightRailTabId>(RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
|
||||
export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states => {
|
||||
const override = states[CHAT_SIDEBAR_PANE_ID]?.widthOverride
|
||||
|
||||
|
|
@ -60,6 +63,10 @@ export function toggleFileBrowserOpen() {
|
|||
togglePane(FILE_BROWSER_PANE_ID)
|
||||
}
|
||||
|
||||
export function selectRightRailTab(id: RightRailTabId) {
|
||||
$rightRailActiveTabId.set(id)
|
||||
}
|
||||
|
||||
export function setSidebarPinsOpen(open: boolean) {
|
||||
$sidebarPinsOpen.set(open)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ function isSnapshot(value: unknown): value is PaneStateSnapshot {
|
|||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const r = value as Record<string, unknown>
|
||||
|
||||
if (typeof r.open !== 'boolean') {
|
||||
|
|
@ -60,6 +61,7 @@ function persist(states: Record<string, PaneStateSnapshot>) {
|
|||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const minimal: Record<string, { open: boolean }> = {}
|
||||
|
||||
for (const [id, s] of Object.entries(states)) {
|
||||
|
|
@ -107,6 +109,7 @@ export function ensurePaneRegistered(id: string, defaults: PaneRegisterDefaults)
|
|||
if (current[id] !== undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
$paneStates.set({ ...current, [id]: { open: defaults.open, widthOverride: defaults.widthOverride } })
|
||||
}
|
||||
|
||||
|
|
@ -117,6 +120,7 @@ export function setPaneOpen(id: string, open: boolean) {
|
|||
if (existing?.open === open) {
|
||||
return
|
||||
}
|
||||
|
||||
$paneStates.set({ ...current, [id]: { open, widthOverride: existing?.widthOverride } })
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +137,7 @@ export function setPaneWidthOverride(id: string, width: number | undefined) {
|
|||
if (existing.widthOverride === width) {
|
||||
return
|
||||
}
|
||||
|
||||
$paneStates.set({ ...current, [id]: { open: existing.open, widthOverride: width } })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout'
|
||||
import {
|
||||
$filePreviewTabs,
|
||||
$filePreviewTarget,
|
||||
$previewServerRestart,
|
||||
$previewServerRestartStatus,
|
||||
|
|
@ -118,14 +120,16 @@ describe('preview store', () => {
|
|||
expect($previewTarget.get()).toEqual(withRenderMode(preview, 'preview'))
|
||||
})
|
||||
|
||||
it('clears file inspection when a live preview opens', () => {
|
||||
it('keeps file tabs when a live preview opens', () => {
|
||||
const file = previewTarget('/work/file.html')
|
||||
const live = previewTarget('/work/live.html')
|
||||
|
||||
setCurrentSessionPreviewTarget(file, 'manual')
|
||||
setCurrentSessionPreviewTarget(live, 'tool-result')
|
||||
|
||||
expect($filePreviewTabs.get().map(tab => tab.target)).toEqual([withRenderMode(file, 'source')])
|
||||
expect($filePreviewTarget.get()).toBeNull()
|
||||
expect($rightRailActiveTabId.get()).toBe(RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
expect($previewTarget.get()).toEqual(withRenderMode(live, 'preview'))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID, type RightRailTabId, selectRightRailTab } from './layout'
|
||||
import { $activeSessionId, $selectedStoredSessionId } from './session'
|
||||
|
||||
export interface PreviewTarget {
|
||||
|
|
@ -39,12 +40,27 @@ export interface SessionPreviewRecord {
|
|||
|
||||
type SessionPreviewRegistry = Record<string, SessionPreviewRecord[]>
|
||||
|
||||
export interface FilePreviewTab {
|
||||
id: `file:${string}`
|
||||
target: PreviewTarget
|
||||
}
|
||||
|
||||
const REGISTRY_STORAGE_KEY = 'hermes.desktop.sessionPreviews.v1'
|
||||
const MAX_RECORDS_PER_SESSION = 1
|
||||
const MAX_SESSIONS = 120
|
||||
|
||||
export const $previewTarget = atom<PreviewTarget | null>(null)
|
||||
export const $filePreviewTarget = atom<PreviewTarget | null>(null)
|
||||
export const $filePreviewTabs = atom<FilePreviewTab[]>([])
|
||||
export const $filePreviewTarget = computed([$filePreviewTabs, $rightRailActiveTabId], (tabs, activeTabId) => {
|
||||
if (!activeTabId.startsWith('file:')) {
|
||||
return null
|
||||
}
|
||||
|
||||
return tabs.find(tab => tab.id === activeTabId)?.target ?? null
|
||||
})
|
||||
export const $rightRailHasContent = computed([$previewTarget, $filePreviewTabs], (target, tabs) =>
|
||||
Boolean(target || tabs.length)
|
||||
)
|
||||
export const $previewReloadRequest = atom(0)
|
||||
export const $previewServerRestart = atom<PreviewServerRestart | null>(null)
|
||||
export const $previewServerRestartStatus = computed($previewServerRestart, restart => restart?.status ?? 'idle')
|
||||
|
|
@ -72,18 +88,32 @@ function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null):
|
|||
|
||||
export function setPreviewTarget(target: PreviewTarget | null) {
|
||||
if (isSamePreviewTarget($previewTarget.get(), target)) {
|
||||
if (target) {
|
||||
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
$previewTarget.set(target)
|
||||
|
||||
if (target) {
|
||||
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
}
|
||||
}
|
||||
|
||||
function setFilePreviewTarget(target: PreviewTarget | null) {
|
||||
if (isSamePreviewTarget($filePreviewTarget.get(), target)) {
|
||||
return
|
||||
}
|
||||
export function filePreviewTabId(target: PreviewTarget): `file:${string}` {
|
||||
return `file:${target.url}`
|
||||
}
|
||||
|
||||
$filePreviewTarget.set(target)
|
||||
function openFilePreviewTarget(target: PreviewTarget) {
|
||||
const id = filePreviewTabId(target)
|
||||
const current = $filePreviewTabs.get()
|
||||
const index = current.findIndex(tab => tab.id === id)
|
||||
const tab: FilePreviewTab = { id, target }
|
||||
|
||||
$filePreviewTabs.set(index === -1 ? [...current, tab] : current.map((item, i) => (i === index ? tab : item)))
|
||||
selectRightRailTab(id)
|
||||
}
|
||||
|
||||
// Manual/file-browser opens are "peeking at a file" → source view in the file
|
||||
|
|
@ -104,7 +134,8 @@ function tryOpenFilePreview(target: PreviewTarget, source: PreviewRecordSource):
|
|||
if (target.kind !== 'file' || !isFilePreviewSource(source)) {
|
||||
return false
|
||||
}
|
||||
setFilePreviewTarget(previewTargetForSource(target, source))
|
||||
|
||||
openFilePreviewTarget(previewTargetForSource(target, source))
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
@ -113,6 +144,7 @@ function isPreviewTarget(value: unknown): value is PreviewTarget {
|
|||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const r = value as Record<string, unknown>
|
||||
|
||||
return (
|
||||
|
|
@ -127,6 +159,7 @@ function isPreviewRecord(value: unknown): value is SessionPreviewRecord {
|
|||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const r = value as Record<string, unknown>
|
||||
|
||||
return (
|
||||
|
|
@ -151,17 +184,20 @@ function loadSessionPreviewRegistry(): SessionPreviewRegistry {
|
|||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const out: SessionPreviewRegistry = {}
|
||||
|
||||
for (const [sessionId, records] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (!Array.isArray(records)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const valid = records.filter(isPreviewRecord).slice(0, MAX_RECORDS_PER_SESSION)
|
||||
|
||||
if (valid.length > 0) {
|
||||
|
|
@ -258,7 +294,6 @@ export function setSessionPreviewTarget(
|
|||
|
||||
const record = registerSessionPreview(sessionId, target, source, rawTarget)
|
||||
|
||||
setFilePreviewTarget(null)
|
||||
setPreviewTarget(record?.normalized ?? previewTargetForSource(target, source))
|
||||
|
||||
return record
|
||||
|
|
@ -288,12 +323,14 @@ export function dismissSessionPreview(sessionId: string | null | undefined, url?
|
|||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = $sessionPreviewRegistry.get()
|
||||
const records = current[id]
|
||||
|
||||
if (!records?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const targetUrl = url || records.find(record => !record.dismissedAt)?.normalized.url
|
||||
|
||||
|
|
@ -325,16 +362,58 @@ export function dismissPreviewTarget() {
|
|||
}
|
||||
|
||||
$previewTarget.set(null)
|
||||
|
||||
if ($rightRailActiveTabId.get() === RIGHT_RAIL_PREVIEW_TAB_ID) {
|
||||
selectRightRailTab($filePreviewTabs.get()[0]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
}
|
||||
}
|
||||
|
||||
export function closeFilePreviewTab(tabId: RightRailTabId) {
|
||||
if (!tabId.startsWith('file:')) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = $filePreviewTabs.get()
|
||||
const index = current.findIndex(tab => tab.id === tabId)
|
||||
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = current.filter(tab => tab.id !== tabId)
|
||||
|
||||
$filePreviewTabs.set(next)
|
||||
|
||||
if ($rightRailActiveTabId.get() === tabId) {
|
||||
selectRightRailTab(next[Math.min(index, next.length - 1)]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
}
|
||||
}
|
||||
|
||||
export function dismissFilePreviewTarget() {
|
||||
setFilePreviewTarget(null)
|
||||
closeFilePreviewTab($rightRailActiveTabId.get())
|
||||
}
|
||||
|
||||
export function closeActiveRightRailTab() {
|
||||
const activeTabId = $rightRailActiveTabId.get()
|
||||
|
||||
if (activeTabId === RIGHT_RAIL_PREVIEW_TAB_ID) {
|
||||
if ($previewTarget.get()) {
|
||||
dismissPreviewTarget()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (activeTabId.startsWith('file:')) {
|
||||
closeFilePreviewTab(activeTabId)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSessionPreviewRegistry() {
|
||||
$sessionPreviewRegistry.set({})
|
||||
setPreviewTarget(null)
|
||||
setFilePreviewTarget(null)
|
||||
$filePreviewTabs.set([])
|
||||
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
}
|
||||
|
||||
export function requestPreviewReload() {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ function loadToolDisclosureStates(): ToolDisclosureStates {
|
|||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
|
|
@ -67,6 +68,7 @@ export function setToolDisclosureOpen(id: string, open: boolean) {
|
|||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = $toolDisclosureStates.get()
|
||||
|
||||
if (current[id] === open) {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export function useSkinCommand() {
|
|||
|
||||
const normalized = arg.toLowerCase()
|
||||
const targetName = ALIASES[normalized] || normalized
|
||||
|
||||
const target = availableThemes.find(
|
||||
t => t.name.toLowerCase() === targetName || t.label.toLowerCase() === normalized
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,16 @@ import path from 'path'
|
|||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
shiki: ['react-shiki', 'shiki'],
|
||||
streamdown: ['@assistant-ui/react-streamdown', '@streamdown/code', 'streamdown']
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue