mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 20:58:51 +08:00
Compare commits
3 Commits
desktop-cm
...
bb/desktop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7521de42f4 | ||
|
|
02e56da0fc | ||
|
|
5e3c5baf82 |
@@ -10,6 +10,7 @@ import type * as React from 'react'
|
||||
import { Suspense, useCallback, useMemo, useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { TerminalSlot } from '@/app/right-sidebar/terminal/persistent'
|
||||
import { Thread } from '@/components/assistant-ui/thread'
|
||||
import { Backdrop } from '@/components/Backdrop'
|
||||
import { PromptOverlays } from '@/components/prompt-overlays'
|
||||
@@ -388,6 +389,9 @@ export function ChatView({
|
||||
<ChatDropOverlay kind={dragKind} />
|
||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||
</div>
|
||||
<section className="flex h-56 min-h-[10rem] shrink-0 border-t border-(--ui-stroke-secondary) bg-(--ui-editor-surface-background)">
|
||||
<TerminalSlot />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { setRightSidebarTab } from '@/app/right-sidebar/store'
|
||||
import { setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
|
||||
import { matchesQuery } from '@/hooks/use-media-query'
|
||||
import { PROFILE_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||
@@ -84,9 +84,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
}
|
||||
}
|
||||
|
||||
const showRightSidebarTab = (tab: 'files' | 'terminal') => {
|
||||
const showFiles = () => {
|
||||
setFileBrowserOpen(true)
|
||||
setRightSidebarTab(tab)
|
||||
setTerminalTakeover(false)
|
||||
}
|
||||
|
||||
handlersRef.current = {
|
||||
@@ -128,8 +128,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
toggleFileBrowserOpen()
|
||||
}
|
||||
},
|
||||
'view.showFiles': () => showRightSidebarTab('files'),
|
||||
'view.showTerminal': () => showRightSidebarTab('terminal'),
|
||||
'view.showFiles': showFiles,
|
||||
'view.showTerminal': () => setTerminalTakeover(true),
|
||||
'view.flipPanes': togglePanesFlipped,
|
||||
|
||||
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
|
||||
|
||||
@@ -4,22 +4,20 @@ import type { ReactNode } from 'react'
|
||||
import { ErrorBoundary } from '@/components/error-boundary'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped } from '@/store/layout'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentBranch, $currentCwd } from '@/store/session'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
import { SidebarPanelLabel } from '../shell/sidebar-label'
|
||||
|
||||
import { ProjectTree } from './files/tree'
|
||||
import { useProjectTree } from './files/use-project-tree'
|
||||
import { $rightSidebarTab, $terminalTakeover, type RightSidebarTabId, setRightSidebarTab } from './store'
|
||||
import { TerminalSlot } from './terminal/persistent'
|
||||
|
||||
interface RightSidebarPaneProps {
|
||||
onActivateFile: (path: string) => void
|
||||
@@ -27,24 +25,10 @@ interface RightSidebarPaneProps {
|
||||
onChangeCwd: (path: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
interface RightSidebarTab {
|
||||
icon: string
|
||||
id: RightSidebarTabId
|
||||
labelKey: 'files' | 'terminal'
|
||||
}
|
||||
|
||||
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
|
||||
{ id: 'files', labelKey: 'files', icon: 'list-tree' },
|
||||
{ id: 'terminal', labelKey: 'terminal', icon: 'terminal' }
|
||||
]
|
||||
|
||||
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
|
||||
const { t } = useI18n()
|
||||
const r = t.rightSidebar
|
||||
const activeTab = useStore($rightSidebarTab)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const currentBranch = useStore($currentBranch).trim()
|
||||
const currentCwd = useStore($currentCwd).trim()
|
||||
const hasCwd = currentCwd.length > 0
|
||||
|
||||
@@ -68,7 +52,6 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
} = useProjectTree(currentCwd)
|
||||
|
||||
const canCollapse = Object.values(openState).some(Boolean)
|
||||
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
|
||||
|
||||
const chooseFolder = async () => {
|
||||
const selected = await window.hermesDesktop?.selectPaths({
|
||||
@@ -97,8 +80,6 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = terminalTakeover ? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal') : RIGHT_SIDEBAR_TABS
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label={r.aria}
|
||||
@@ -109,85 +90,29 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
||||
)}
|
||||
>
|
||||
<RightSidebarChrome activeTab={effectiveTab} branch={currentBranch} tabs={tabs} />
|
||||
|
||||
{effectiveTab === 'terminal' ? (
|
||||
<TerminalSlot />
|
||||
) : (
|
||||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={currentCwd}
|
||||
cwdName={cwdName}
|
||||
data={data}
|
||||
error={rootError}
|
||||
hasCwd={hasCwd}
|
||||
loading={rootLoading}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onChangeFolder={chooseFolder}
|
||||
onCollapseAll={collapseAll}
|
||||
onLoadChildren={loadChildren}
|
||||
onNodeOpenChange={setNodeOpen}
|
||||
onPreviewFile={previewFile}
|
||||
onRefresh={() => void refreshRoot()}
|
||||
openState={openState}
|
||||
/>
|
||||
)}
|
||||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={currentCwd}
|
||||
cwdName={cwdName}
|
||||
data={data}
|
||||
error={rootError}
|
||||
hasCwd={hasCwd}
|
||||
loading={rootLoading}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onChangeFolder={chooseFolder}
|
||||
onCollapseAll={collapseAll}
|
||||
onLoadChildren={loadChildren}
|
||||
onNodeOpenChange={setNodeOpen}
|
||||
onPreviewFile={previewFile}
|
||||
onRefresh={() => void refreshRoot()}
|
||||
openState={openState}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function RightSidebarChrome({
|
||||
activeTab,
|
||||
branch,
|
||||
tabs
|
||||
}: {
|
||||
activeTab: RightSidebarTabId
|
||||
branch: string
|
||||
tabs: readonly RightSidebarTab[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const r = t.rightSidebar
|
||||
|
||||
return (
|
||||
<header className="shrink-0 bg-transparent text-[0.75rem]">
|
||||
<div className="flex items-center gap-2 px-2.5 py-1">
|
||||
<nav aria-label={r.panelsAria} className="flex min-w-0 items-center gap-1">
|
||||
{tabs.map(tab => {
|
||||
const label = r[tab.labelKey]
|
||||
|
||||
return (
|
||||
<Tip key={tab.id} label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
aria-pressed={tab.id === activeTab}
|
||||
className={cn(
|
||||
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
|
||||
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
|
||||
)}
|
||||
onClick={() => setRightSidebarTab(tab.id)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={tab.icon} size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{branch && (
|
||||
<span className="ml-auto flex min-w-0 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
|
||||
<span className="truncate">{branch}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
interface FilesystemTabProps extends FileTreeBodyProps {
|
||||
canCollapse: boolean
|
||||
cwdName: string
|
||||
|
||||
@@ -2,14 +2,10 @@ import { atom } from 'nanostores'
|
||||
|
||||
import { persistBoolean, storedBoolean } from '@/lib/storage'
|
||||
|
||||
export type RightSidebarTabId = 'files' | 'git' | 'terminal' | 'web'
|
||||
|
||||
const TAKEOVER_KEY = 'hermes.desktop.terminalTakeover'
|
||||
|
||||
export const $rightSidebarTab = atom<RightSidebarTabId>('files')
|
||||
export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
|
||||
|
||||
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
|
||||
|
||||
export const setRightSidebarTab = (tab: RightSidebarTabId) => $rightSidebarTab.set(tab)
|
||||
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
|
||||
import { $terminalTakeover, setTerminalTakeover } from '../store'
|
||||
|
||||
import { addSelectionShortcutLabel } from './selection'
|
||||
import { addSelectionShortcutLabel, terminalTheme } from './selection'
|
||||
import { useTerminalSession } from './use-terminal-session'
|
||||
|
||||
interface TerminalTabProps {
|
||||
@@ -21,6 +23,9 @@ interface TerminalTabProps {
|
||||
|
||||
export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
const { t } = useI18n()
|
||||
const { resolvedMode } = useTheme()
|
||||
const theme = terminalTheme(resolvedMode)
|
||||
|
||||
const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({
|
||||
cwd,
|
||||
onAddSelectionToChat
|
||||
@@ -30,22 +35,17 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
const label = takeover ? t.rightSidebar.terminalSplit : t.rightSidebar.terminalFocus
|
||||
|
||||
const toggleTakeover = () => {
|
||||
// Pre-select the Terminal tab so the slot is ready to host us on return.
|
||||
if (takeover) {
|
||||
setRightSidebarTab('terminal')
|
||||
}
|
||||
|
||||
setTerminalTakeover(!takeover)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div className="flex h-8 shrink-0 items-center gap-2 px-2.5">
|
||||
<SidebarPanelLabel className="text-white!">{shellName}</SidebarPanelLabel>
|
||||
<SidebarPanelLabel className="text-(--ui-text-secondary)!">{shellName}</SidebarPanelLabel>
|
||||
<Tip label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="ml-auto size-6 rounded-md text-white!"
|
||||
className="ml-auto size-6 rounded-md text-(--ui-text-secondary)!"
|
||||
onClick={toggleTakeover}
|
||||
size="icon"
|
||||
type="button"
|
||||
@@ -55,7 +55,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1 bg-[#002b36] p-2">
|
||||
<div className="relative min-h-0 flex-1 p-2" style={{ backgroundColor: theme.background }}>
|
||||
{status === 'starting' && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
|
||||
<Loader
|
||||
@@ -84,13 +84,12 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Outer div paints the dark inset; inner div is the xterm host so the
|
||||
canvas sizes to the *content* area and p-2 shows as terminal padding.
|
||||
Forcing screen/viewport bg avoids xterm's default black peeking
|
||||
through the unused pixels below the last full row. */}
|
||||
{/* Outer div paints terminal inset; inner div is the xterm host so the
|
||||
canvas sizes to the content area and p-2 stays as terminal padding. */}
|
||||
<div
|
||||
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-[#002b36]! [&_.xterm-viewport]:bg-[#002b36]!"
|
||||
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-[var(--terminal-bg)]! [&_.xterm-viewport]:bg-[var(--terminal-bg)]!"
|
||||
ref={hostRef}
|
||||
style={{ '--terminal-bg': theme.background } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useStore } from '@nanostores/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { TERMINAL_BG } from './selection'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { terminalTheme } from './selection'
|
||||
|
||||
import { TerminalTab } from './index'
|
||||
|
||||
@@ -56,6 +58,8 @@ const sameRect = (a: Rect | null, b: Rect) =>
|
||||
|
||||
export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerminalProps) {
|
||||
const slot = useStore($slot)
|
||||
const { resolvedMode } = useTheme()
|
||||
const theme = terminalTheme(resolvedMode)
|
||||
const [rect, setRect] = useState<Rect | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
@@ -107,7 +111,7 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
|
||||
visibility: visible ? 'visible' : 'hidden',
|
||||
pointerEvents: visible ? 'auto' : 'none',
|
||||
zIndex: 4,
|
||||
backgroundColor: TERMINAL_BG,
|
||||
backgroundColor: theme.background,
|
||||
contain: 'layout size paint'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,55 @@
|
||||
import type { ITheme, Terminal } from '@xterm/xterm'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
// Solarized-derived palette, but with bright ANSI 8–15 promoted to real
|
||||
// accent variants instead of Schoonover's UI grays. Hermes' TUI skins (gold,
|
||||
// crimson, ...) emit bright SGR codes that would otherwise wash out to gray.
|
||||
// We always render the dark canvas — the app's light surfaces can't host the
|
||||
// default skin without dropping below readable contrast.
|
||||
export const TERMINAL_BG = '#002b36'
|
||||
|
||||
const THEME: ITheme = {
|
||||
background: TERMINAL_BG,
|
||||
foreground: '#839496',
|
||||
cursor: '#93a1a1',
|
||||
cursorAccent: TERMINAL_BG,
|
||||
selectionBackground: '#586e7555',
|
||||
black: '#073642',
|
||||
red: '#dc322f',
|
||||
green: '#859900',
|
||||
yellow: '#b58900',
|
||||
blue: '#268bd2',
|
||||
magenta: '#d33682',
|
||||
cyan: '#2aa198',
|
||||
white: '#eee8d5',
|
||||
brightBlack: '#586e75',
|
||||
brightRed: '#f25c54',
|
||||
brightGreen: '#b3d437',
|
||||
brightYellow: '#f7c948',
|
||||
brightBlue: '#5fb3ff',
|
||||
brightMagenta: '#ff6ab4',
|
||||
brightCyan: '#5cd9c8',
|
||||
brightWhite: '#fdf6e3'
|
||||
const DARK_THEME: ITheme = {
|
||||
background: '#0f172a',
|
||||
foreground: '#dbe4ff',
|
||||
cursor: '#f8fafc',
|
||||
cursorAccent: '#0f172a',
|
||||
selectionBackground: '#93c5fd55',
|
||||
black: '#1e293b',
|
||||
red: '#f87171',
|
||||
green: '#4ade80',
|
||||
yellow: '#facc15',
|
||||
blue: '#60a5fa',
|
||||
magenta: '#c084fc',
|
||||
cyan: '#22d3ee',
|
||||
white: '#e2e8f0',
|
||||
brightBlack: '#64748b',
|
||||
brightRed: '#fca5a5',
|
||||
brightGreen: '#86efac',
|
||||
brightYellow: '#fde047',
|
||||
brightBlue: '#93c5fd',
|
||||
brightMagenta: '#d8b4fe',
|
||||
brightCyan: '#67e8f9',
|
||||
brightWhite: '#f8fafc'
|
||||
}
|
||||
|
||||
export const terminalTheme = (): ITheme => THEME
|
||||
const LIGHT_THEME: ITheme = {
|
||||
background: '#f8fafc',
|
||||
foreground: '#1f2937',
|
||||
cursor: '#111827',
|
||||
cursorAccent: '#f8fafc',
|
||||
selectionBackground: '#60a5fa44',
|
||||
black: '#1f2937',
|
||||
red: '#dc2626',
|
||||
green: '#15803d',
|
||||
yellow: '#a16207',
|
||||
blue: '#1d4ed8',
|
||||
magenta: '#9333ea',
|
||||
cyan: '#0e7490',
|
||||
white: '#d1d5db',
|
||||
brightBlack: '#4b5563',
|
||||
brightRed: '#ef4444',
|
||||
brightGreen: '#22c55e',
|
||||
brightYellow: '#eab308',
|
||||
brightBlue: '#3b82f6',
|
||||
brightMagenta: '#a855f7',
|
||||
brightCyan: '#06b6d4',
|
||||
brightWhite: '#111827'
|
||||
}
|
||||
|
||||
export const terminalTheme = (mode: 'light' | 'dark'): ITheme => (mode === 'dark' ? DARK_THEME : LIGHT_THEME)
|
||||
|
||||
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { WebglAddon } from '@xterm/addon-webgl'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { isAddSelectionShortcut, terminalSelectionAnchor, terminalSelectionLabel, terminalTheme } from './selection'
|
||||
|
||||
@@ -184,6 +185,9 @@ function quotePathForShell(path: string, shellName: string): string {
|
||||
}
|
||||
|
||||
export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSessionOptions) {
|
||||
const { resolvedMode } = useTheme()
|
||||
const activeTheme = useMemo(() => terminalTheme(resolvedMode), [resolvedMode])
|
||||
const initialThemeRef = useRef(activeTheme)
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const termRef = useRef<Terminal | null>(null)
|
||||
const sessionIdRef = useRef<string | null>(null)
|
||||
@@ -266,7 +270,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
lineHeight: 1.12,
|
||||
macOptionIsMeta: true,
|
||||
scrollback: 1000,
|
||||
theme: terminalTheme()
|
||||
theme: initialThemeRef.current
|
||||
})
|
||||
|
||||
const fit = new FitAddon()
|
||||
@@ -493,6 +497,14 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
}
|
||||
}, [addSelectionToChat, cwd])
|
||||
|
||||
useEffect(() => {
|
||||
const term = termRef.current
|
||||
|
||||
if (term) {
|
||||
term.options.theme = activeTheme
|
||||
}
|
||||
}, [activeTheme])
|
||||
|
||||
return {
|
||||
addSelectionToChat,
|
||||
hostRef,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@/lib/chat-messages'
|
||||
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
|
||||
import { playCompletionSound } from '@/lib/completion-sound'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { setClarifyRequest } from '@/store/clarify'
|
||||
@@ -781,9 +782,7 @@ export function useMessageStream({
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
if (isActiveEvent) {
|
||||
triggerHaptic('streamDone')
|
||||
}
|
||||
playCompletionSound()
|
||||
|
||||
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
|
||||
completeAssistantMessage(sessionId, finalText)
|
||||
|
||||
257
apps/desktop/src/lib/completion-sound.ts
Normal file
257
apps/desktop/src/lib/completion-sound.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
// Completion sound bank for agent turn-end cues.
|
||||
// Runtime playback is pinned to a curated default (currently variant 8).
|
||||
|
||||
import { $hapticsMuted } from '@/store/haptics'
|
||||
|
||||
type OscType = OscillatorType
|
||||
|
||||
interface ToneSpec {
|
||||
attack?: number
|
||||
dur: number
|
||||
freq: number
|
||||
gain?: number
|
||||
start?: number
|
||||
type?: OscType
|
||||
}
|
||||
|
||||
let ctx: AudioContext | null = null
|
||||
|
||||
function getCtx(): AudioContext | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
if (!ctx) {
|
||||
const Ctor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||
|
||||
if (!Ctor) {
|
||||
return null
|
||||
}
|
||||
|
||||
ctx = new Ctor()
|
||||
}
|
||||
|
||||
// Autoplay policies can leave the context suspended until a gesture; a
|
||||
// resume() here recovers it once the user has interacted with the window.
|
||||
if (ctx.state === 'suspended') {
|
||||
void ctx.resume().catch(() => undefined)
|
||||
}
|
||||
|
||||
return ctx
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// One enveloped oscillator voice → master. Linear attack into an exponential
|
||||
// decay keeps the tail smooth and avoids the click you get ramping to zero.
|
||||
function voice(ac: AudioContext, master: GainNode, t0: number, spec: ToneSpec) {
|
||||
const osc = ac.createOscillator()
|
||||
const env = ac.createGain()
|
||||
const start = t0 + (spec.start ?? 0)
|
||||
const peak = spec.gain ?? 0.5
|
||||
const attack = spec.attack ?? 0.006
|
||||
const end = start + spec.dur
|
||||
|
||||
osc.type = spec.type ?? 'sine'
|
||||
osc.frequency.setValueAtTime(spec.freq, start)
|
||||
|
||||
env.gain.setValueAtTime(0.0001, start)
|
||||
env.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0002), start + attack)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
|
||||
osc.connect(env)
|
||||
env.connect(master)
|
||||
osc.start(start)
|
||||
osc.stop(end + 0.02)
|
||||
}
|
||||
|
||||
let reverbImpulse: AudioBuffer | null = null
|
||||
|
||||
// A short, synthetic reverb tail (decaying noise impulse). Used as a subtle wet
|
||||
// send so the chimes feel like they sit in a room rather than a tin can — the
|
||||
// detail that nudges them from "arcade beep" toward "polished app". The impulse
|
||||
// buffer is generated once and cached; each play gets a fresh, disposable
|
||||
// convolver so connections never pile up on a shared node.
|
||||
function makeReverb(ac: AudioContext): ConvolverNode {
|
||||
if (!reverbImpulse) {
|
||||
const seconds = 1.1
|
||||
const length = Math.floor(ac.sampleRate * seconds)
|
||||
reverbImpulse = ac.createBuffer(2, length, ac.sampleRate)
|
||||
|
||||
for (let channel = 0; channel < 2; channel += 1) {
|
||||
const data = reverbImpulse.getChannelData(channel)
|
||||
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
// White noise with a steep exponential decay → smooth, short tail.
|
||||
data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 3.2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const convolver = ac.createConvolver()
|
||||
convolver.buffer = reverbImpulse
|
||||
|
||||
return convolver
|
||||
}
|
||||
|
||||
export interface CompletionSoundVariant {
|
||||
id: number
|
||||
name: string
|
||||
// `master` is warm (runs through low-pass + room tail).
|
||||
play: (ac: AudioContext, master: GainNode, t0: number) => void
|
||||
}
|
||||
|
||||
// Note frequencies (equal temperament). Everything lives in a low-mid register
|
||||
// (C3–C5) so the chimes feel warm and "appy" rather than bright and arcade-y.
|
||||
const C3 = 130.81
|
||||
const C4 = 261.63
|
||||
const E4 = 329.63
|
||||
const G4 = 392
|
||||
const C5 = 523.25
|
||||
const D5 = 587.33
|
||||
const E5 = 659.25
|
||||
const G5 = 783.99
|
||||
const C6 = 1046.5
|
||||
|
||||
export const COMPLETION_SOUND_VARIANTS: readonly CompletionSoundVariant[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Tiks success (MIT)',
|
||||
play: (ac, master, t0) => {
|
||||
// Ported from rexa-developer/tiks success(): tonic then fifth.
|
||||
voice(ac, master, t0, { freq: C5, dur: 0.11, gain: 0.12, attack: 0.008, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.085, { freq: G5, dur: 0.16, gain: 0.12, attack: 0.008, type: 'sine' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Seslen message (MIT)',
|
||||
play: (ac, master, t0) => {
|
||||
// Ported from productdevbook/seslen message(): soft two-tone bell.
|
||||
voice(ac, master, t0, { freq: 880, dur: 0.28, gain: 0.1, attack: 0.01, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.08, { freq: 1320, dur: 0.34, gain: 0.085, attack: 0.01, type: 'sine' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Seslen success chirp (MIT)',
|
||||
play: (ac, master, t0) => {
|
||||
// Ported from productdevbook/seslen success(): 660→990→1320 triangle.
|
||||
const osc = ac.createOscillator()
|
||||
const env = ac.createGain()
|
||||
osc.type = 'triangle'
|
||||
osc.frequency.setValueAtTime(660, t0)
|
||||
osc.frequency.linearRampToValueAtTime(990, t0 + 0.08)
|
||||
osc.frequency.linearRampToValueAtTime(1320, t0 + 0.18)
|
||||
env.gain.setValueAtTime(0.0001, t0)
|
||||
env.gain.linearRampToValueAtTime(0.11, t0 + 0.01)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.32)
|
||||
osc.connect(env)
|
||||
env.connect(master)
|
||||
osc.start(t0)
|
||||
osc.stop(t0 + 0.34)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Tiks notify (MIT)',
|
||||
play: (ac, master, t0) => {
|
||||
// Ported from rexa-developer/tiks notify(): two-note rising figure.
|
||||
voice(ac, master, t0, { freq: 880, dur: 0.18, gain: 0.1, attack: 0.008, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.1, { freq: 1320, dur: 0.3, gain: 0.1, attack: 0.008, type: 'sine' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Seslen notify (MIT)',
|
||||
play: (ac, master, t0) => {
|
||||
// Ported from productdevbook/seslen notify(): 660-880-1320 sequence.
|
||||
const notes = [660, 880, 1320]
|
||||
notes.forEach((frequency, i) => {
|
||||
const start = t0 + i * 0.1
|
||||
voice(ac, master, start, { freq: frequency, dur: 0.16, gain: 0.095, attack: 0.01, type: 'sine' })
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Seslen victory arpeggio (MIT)',
|
||||
play: (ac, master, t0) => {
|
||||
const notes = [C5, E5, G5, C6]
|
||||
notes.forEach((frequency, i) => {
|
||||
voice(ac, master, t0 + i * 0.09, { freq: frequency, dur: 0.24, gain: 0.12, attack: 0.008, type: 'triangle' })
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Seslen level-up arpeggio (MIT)',
|
||||
play: (ac, master, t0) => {
|
||||
const notes = [C5, D5, E5, G5, C6]
|
||||
notes.forEach((frequency, i) => {
|
||||
voice(ac, master, t0 + i * 0.09, { freq: frequency, dur: 0.22, gain: 0.11, attack: 0.008, type: 'triangle' })
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Two-note comfort (minimal)',
|
||||
play: (ac, master, t0) => {
|
||||
voice(ac, master, t0, { freq: E4, dur: 0.22, gain: 0.05, attack: 0.03, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.08, { freq: C4, dur: 0.52, gain: 0.07, attack: 0.08, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.08, { freq: C3, dur: 0.46, gain: 0.02, attack: 0.1, type: 'sine' })
|
||||
}
|
||||
}
|
||||
] as const
|
||||
|
||||
const DEFAULT_COMPLETION_VARIANT_ID = 8
|
||||
|
||||
function playVariant(variantId: number) {
|
||||
const variant = COMPLETION_SOUND_VARIANTS.find(v => v.id === variantId)
|
||||
|
||||
if (!variant) {
|
||||
return
|
||||
}
|
||||
|
||||
const ac = getCtx()
|
||||
|
||||
if (!ac) {
|
||||
return
|
||||
}
|
||||
|
||||
// Signal path: voices → master → low-pass → (dry + reverb send) → out.
|
||||
// The low-pass sits low to keep things warm, and a small wet send adds the
|
||||
// sense of space that makes the chime feel like part of a polished app.
|
||||
const master = ac.createGain()
|
||||
const tone = ac.createBiquadFilter()
|
||||
tone.type = 'lowpass'
|
||||
tone.frequency.setValueAtTime(3400, ac.currentTime)
|
||||
tone.Q.setValueAtTime(0.4, ac.currentTime)
|
||||
master.gain.setValueAtTime(0.7, ac.currentTime)
|
||||
master.connect(tone)
|
||||
|
||||
const dry = ac.createGain()
|
||||
dry.gain.setValueAtTime(0.92, ac.currentTime)
|
||||
tone.connect(dry)
|
||||
dry.connect(ac.destination)
|
||||
|
||||
const reverb = makeReverb(ac)
|
||||
const wet = ac.createGain()
|
||||
wet.gain.setValueAtTime(0.22, ac.currentTime)
|
||||
tone.connect(reverb)
|
||||
reverb.connect(wet)
|
||||
wet.connect(ac.destination)
|
||||
|
||||
variant.play(ac, master, ac.currentTime + 0.01)
|
||||
}
|
||||
|
||||
// Plays the fixed completion cue on any `message.complete`.
|
||||
export function playCompletionSound() {
|
||||
if ($hapticsMuted.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
playVariant(DEFAULT_COMPLETION_VARIANT_ID)
|
||||
}
|
||||
Reference in New Issue
Block a user