mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-29 11:42:04 +00:00
Merge remote-tracking branch 'origin/main' into bb/pets-merge
# Conflicts: # hermes_cli/commands.py # tui_gateway/server.py
This commit is contained in:
commit
e495b33bf1
251 changed files with 23395 additions and 2720 deletions
|
|
@ -13,6 +13,7 @@ import {
|
|||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -42,22 +43,23 @@ export function ContextMenu({
|
|||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
|
||||
)}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
title={state.tools.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="0.875rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<Tip label={state.tools.label} side="top">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
|
||||
)}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="0.875rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="start" className={cn('w-60', composerPanelCard)} side="top" sideOffset={6}>
|
||||
<DropdownMenuLabel className="px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)">
|
||||
{c.attachLabel}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import {
|
||||
POPOUT_ESTIMATED_HEIGHT,
|
||||
POPOUT_WIDTH_REM,
|
||||
readPopoutBounds,
|
||||
setComposerPopoutPosition,
|
||||
type PopoutPosition,
|
||||
type PopoutSize
|
||||
|
|
@ -147,7 +148,7 @@ export function useComposerPopoutGestures({
|
|||
const beginFloatDrag = useCallback(
|
||||
(state: PressState, clientX: number, clientY: number, next: PopoutPosition, size?: PopoutSize) => {
|
||||
clearTimer()
|
||||
const clamped = setComposerPopoutPosition(next, { size })
|
||||
const clamped = setComposerPopoutPosition(next, { area: readPopoutBounds(composerRef.current), size })
|
||||
liveRef.current = clamped
|
||||
|
||||
state.mode = 'float'
|
||||
|
|
@ -159,7 +160,7 @@ export function useComposerPopoutGestures({
|
|||
|
||||
setDragging(true)
|
||||
},
|
||||
[clearTimer]
|
||||
[clearTimer, composerRef]
|
||||
)
|
||||
|
||||
const peelOffFromDock = useCallback(
|
||||
|
|
@ -265,7 +266,7 @@ export function useComposerPopoutGestures({
|
|||
bottom: state.startBottom - (pending.y - state.startY),
|
||||
right: state.startRight - (pending.x - state.startX)
|
||||
},
|
||||
{ size }
|
||||
{ area: readPopoutBounds(composer), size }
|
||||
)
|
||||
|
||||
if (composer) {
|
||||
|
|
@ -327,7 +328,7 @@ export function useComposerPopoutGestures({
|
|||
} else {
|
||||
// Persist the resting position once, on release — never per move.
|
||||
const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined
|
||||
setComposerPopoutPosition(liveRef.current, { persist: true, size })
|
||||
setComposerPopoutPosition(liveRef.current, { area: readPopoutBounds(composer), persist: true, size })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import {
|
|||
$composerPopoutPosition,
|
||||
$composerPoppedOut,
|
||||
POPOUT_WIDTH_REM,
|
||||
readPopoutBounds,
|
||||
setComposerPoppedOut,
|
||||
setComposerPopoutPosition
|
||||
} from '@/store/composer-popout'
|
||||
|
|
@ -59,6 +60,7 @@ import {
|
|||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { $statusItemsBySession } from '@/store/composer-status'
|
||||
import { $previewStatusBySession } from '@/store/preview-status'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
|
|
@ -194,6 +196,7 @@ export function ChatBar({
|
|||
const attachments = useStore($composerAttachments)
|
||||
const queuedPromptsBySession = useStore($queuedPromptsBySession)
|
||||
const statusItemsBySession = useStore($statusItemsBySession)
|
||||
const previewStatusBySession = useStore($previewStatusBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
// Pop-out is a shared, persisted state — but secondary windows (the Ctrl+Shift+N
|
||||
// tiny window, subagent watch windows) always start docked and can't pop out:
|
||||
|
|
@ -216,8 +219,12 @@ export function ChatBar({
|
|||
|
||||
const statusStackVisible = useMemo(
|
||||
() =>
|
||||
queuedPrompts.length > 0 || (statusSessionId ? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 : false),
|
||||
[queuedPrompts.length, statusItemsBySession, statusSessionId]
|
||||
queuedPrompts.length > 0 ||
|
||||
(statusSessionId
|
||||
? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 ||
|
||||
(previewStatusBySession[statusSessionId]?.length ?? 0) > 0
|
||||
: false),
|
||||
[previewStatusBySession, queuedPrompts.length, statusItemsBySession, statusSessionId]
|
||||
)
|
||||
|
||||
const composerRef = useRef<HTMLFormElement | null>(null)
|
||||
|
|
@ -542,9 +549,12 @@ export function ChatBar({
|
|||
syncComposerMetrics()
|
||||
}, [poppedOut, syncComposerMetrics])
|
||||
|
||||
// Keep the floating box on-screen: re-clamp (with the real measured size) when
|
||||
// it pops out and whenever the window resizes — so a position persisted on a
|
||||
// bigger/other monitor, or a shrunk window, can never strand it out of reach.
|
||||
// Keep the floating box on-screen: re-clamp (with the real measured size +
|
||||
// thread bounds) when it pops out and on every window resize — so a position
|
||||
// persisted on a bigger/other monitor, a shrunk window, or now-wider sidebar
|
||||
// can never strand it. The rAF pass re-clamps after layout settles (sidebar
|
||||
// widths, fonts), so anyone loading in out of bounds is pulled back + saved
|
||||
// even if the first measure was premature.
|
||||
useEffect(() => {
|
||||
if (!poppedOut) {
|
||||
return undefined
|
||||
|
|
@ -553,14 +563,18 @@ export function ChatBar({
|
|||
const reclamp = (persist: boolean) => {
|
||||
const el = composerRef.current
|
||||
const size = el ? { height: el.offsetHeight, width: el.offsetWidth } : undefined
|
||||
setComposerPopoutPosition($composerPopoutPosition.get(), { persist, size })
|
||||
setComposerPopoutPosition($composerPopoutPosition.get(), { area: readPopoutBounds(el), persist, size })
|
||||
}
|
||||
|
||||
reclamp(true)
|
||||
const raf = requestAnimationFrame(() => reclamp(true))
|
||||
const onResize = () => reclamp(false)
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
window.removeEventListener('resize', onResize)
|
||||
}
|
||||
}, [poppedOut])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ChevronDown } from '@/lib/icons'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
|
|
@ -74,34 +75,36 @@ export function ModelPill({
|
|||
|
||||
if (!model.modelMenuContent) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={copy.openModelPicker}
|
||||
className={pillClass}
|
||||
disabled={disabled}
|
||||
onClick={() => setModelPickerOpen(true)}
|
||||
title={copy.openModelPicker}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Tip label={copy.openModelPicker} side="top">
|
||||
<Button
|
||||
aria-label={title}
|
||||
aria-label={copy.openModelPicker}
|
||||
className={pillClass}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
onClick={() => setModelPickerOpen(true)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<Tip label={title} side="top">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={title}
|
||||
className={pillClass}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="end" className="w-64 p-0" side="top" sideOffset={8}>
|
||||
<ModelMenuCloseContext.Provider value={() => setOpen(false)}>
|
||||
{model.modelMenuContent}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ import {
|
|||
type StatusGroup,
|
||||
stopBackgroundProcess
|
||||
} from '@/store/composer-status'
|
||||
import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { PreviewStatusRow } from './preview-row'
|
||||
import { StatusItemRow } from './status-row'
|
||||
|
||||
// Slow safety-net poll for silent exits (processes without notify_on_complete
|
||||
|
|
@ -52,6 +54,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
|||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const itemsBySession = useStore($statusItemsBySession)
|
||||
const previewsBySession = useStore($previewStatusBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
|
||||
const groups = useMemo(
|
||||
|
|
@ -59,6 +62,8 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
|||
[itemsBySession, sessionId]
|
||||
)
|
||||
|
||||
const previews = sessionId ? (previewsBySession[sessionId] ?? []) : []
|
||||
|
||||
// Seed from the registry on session open; event-driven refreshes (terminal /
|
||||
// process tool completions) live in use-message-stream.
|
||||
useEffect(() => {
|
||||
|
|
@ -122,6 +127,21 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
|||
)
|
||||
}))
|
||||
|
||||
if (previews.length > 0 && sessionId) {
|
||||
sections.push({
|
||||
key: 'preview',
|
||||
// Not a collapsible group — preview links just sit there, one line each,
|
||||
// each individually closeable.
|
||||
node: (
|
||||
<div className="px-1 py-0.5">
|
||||
{previews.map(item => (
|
||||
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (queue) {
|
||||
sections.push({ key: 'queue', node: queue })
|
||||
}
|
||||
|
|
|
|||
125
apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx
Normal file
125
apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { memo, useState } from 'react'
|
||||
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ChevronRight, X } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { PREVIEW_PANE_ID } from '@/store/layout'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $paneOpen } from '@/store/panes'
|
||||
import { $previewTarget, dismissPreviewTarget, setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { type PreviewArtifact } from '@/store/preview-status'
|
||||
|
||||
interface PreviewStatusRowProps {
|
||||
item: PreviewArtifact
|
||||
onDismiss: (id: string) => void
|
||||
}
|
||||
|
||||
/** One detected artifact, single line, always visible: filename + open + close. */
|
||||
export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss }: PreviewStatusRowProps) {
|
||||
const { t } = useI18n()
|
||||
const activePreview = useStore($previewTarget)
|
||||
const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID))
|
||||
const [opening, setOpening] = useState(false)
|
||||
const isOpen = activePreview?.source === item.target && previewPaneOpen
|
||||
|
||||
const resolveTarget = async () => {
|
||||
const target = await normalizeOrLocalPreviewTarget(item.target, item.cwd || undefined)
|
||||
|
||||
if (!target) {
|
||||
throw new Error(`Could not open preview target: ${item.target}`)
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
const togglePreview = async () => {
|
||||
if (opening) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
dismissPreviewTarget()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setOpening(true)
|
||||
|
||||
try {
|
||||
setCurrentSessionPreviewTarget(await resolveTarget(), 'tool-result', item.target)
|
||||
} catch (error) {
|
||||
notifyError(error, t.preview.unavailable)
|
||||
} finally {
|
||||
setOpening(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openInBrowser = async () => {
|
||||
try {
|
||||
const bridge = window.hermesDesktop?.openPreviewInBrowser
|
||||
|
||||
if (!bridge) {
|
||||
throw new Error('Desktop preview browser bridge is unavailable')
|
||||
}
|
||||
|
||||
await bridge((await resolveTarget()).url)
|
||||
} catch (error) {
|
||||
notifyError(error, t.preview.unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
leading={<ChevronRight aria-hidden className="size-3 text-muted-foreground/80" />}
|
||||
onActivate={() => void togglePreview()}
|
||||
trailing={
|
||||
<span className="-my-1 flex items-center gap-0.5">
|
||||
<Tip label={t.preview.openInBrowser}>
|
||||
<Button
|
||||
aria-label={t.preview.openInBrowser}
|
||||
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void openInBrowser()
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="link-external" size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={t.statusStack.dismiss}>
|
||||
<Button
|
||||
aria-label={t.statusStack.dismiss}
|
||||
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onDismiss(item.id)
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Tip>
|
||||
</span>
|
||||
}
|
||||
trailingVisible
|
||||
>
|
||||
<span className="min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4 text-foreground/92" title={item.target}>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className={cn('shrink-0 text-[0.62rem] leading-4 text-muted-foreground/70', opening && 'animate-pulse')}>
|
||||
{opening ? t.preview.opening : isOpen ? t.preview.hide : t.preview.openPreview}
|
||||
</span>
|
||||
</StatusRow>
|
||||
)
|
||||
})
|
||||
|
|
@ -433,17 +433,18 @@ export function ChatView({
|
|||
|
||||
<PromptOverlays />
|
||||
|
||||
<div
|
||||
className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"
|
||||
{...dropHandlers}
|
||||
<ChatRuntimeBoundary
|
||||
busy={busy}
|
||||
onCancel={onCancel}
|
||||
onEdit={onEdit}
|
||||
onReload={onReload}
|
||||
onThreadMessagesChange={onThreadMessagesChange}
|
||||
suppressMessages={routeSessionMismatch}
|
||||
>
|
||||
<ChatRuntimeBoundary
|
||||
busy={busy}
|
||||
onCancel={onCancel}
|
||||
onEdit={onEdit}
|
||||
onReload={onReload}
|
||||
onThreadMessagesChange={onThreadMessagesChange}
|
||||
suppressMessages={routeSessionMismatch}
|
||||
<div
|
||||
className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"
|
||||
data-slot="composer-bounds"
|
||||
{...dropHandlers}
|
||||
>
|
||||
<Thread
|
||||
clampToComposer={showChatBar}
|
||||
|
|
@ -458,54 +459,62 @@ export function ChatView({
|
|||
sessionId={activeSessionId}
|
||||
sessionKey={threadKey}
|
||||
/>
|
||||
{showChatBar && (
|
||||
<Suspense fallback={<ChatBarFallback />}>
|
||||
<ChatBar
|
||||
busy={busy}
|
||||
cwd={currentCwd}
|
||||
disabled={!gatewayOpen}
|
||||
focusKey={activeSessionId}
|
||||
gateway={gateway}
|
||||
maxRecordingSeconds={maxVoiceRecordingSeconds}
|
||||
onAddContextRef={onAddContextRef}
|
||||
onAddUrl={onAddUrl}
|
||||
onAttachDroppedItems={onAttachDroppedItems}
|
||||
onAttachImageBlob={onAttachImageBlob}
|
||||
onCancel={onCancel}
|
||||
onPasteClipboardImage={onPasteClipboardImage}
|
||||
onPickFiles={onPickFiles}
|
||||
onPickFolders={onPickFolders}
|
||||
onPickImages={onPickImages}
|
||||
onRemoveAttachment={onRemoveAttachment}
|
||||
onSteer={onSteer}
|
||||
onSubmit={onSubmit}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
queueSessionKey={selectedSessionId}
|
||||
sessionId={activeSessionId}
|
||||
state={chatBarState}
|
||||
/>
|
||||
</Suspense>
|
||||
{resumeExhausted && routedSessionId && (
|
||||
<div className="absolute inset-0 z-10 grid place-items-center bg-(--ui-chat-surface-background) px-8 py-10">
|
||||
<ErrorState
|
||||
className="max-w-sm"
|
||||
description={t.desktop.resumeStrandedBody}
|
||||
title={t.desktop.resumeStrandedTitle}
|
||||
>
|
||||
<div className="grid justify-items-center">
|
||||
<Button onClick={() => onRetryResume(routedSessionId)} size="sm" variant="outline">
|
||||
{t.desktop.resumeRetry}
|
||||
</Button>
|
||||
</div>
|
||||
</ErrorState>
|
||||
</div>
|
||||
)}
|
||||
</ChatRuntimeBoundary>
|
||||
{resumeExhausted && routedSessionId && (
|
||||
<div className="absolute inset-0 z-10 grid place-items-center bg-(--ui-chat-surface-background) px-8 py-10">
|
||||
<ErrorState
|
||||
className="max-w-sm"
|
||||
description={t.desktop.resumeStrandedBody}
|
||||
title={t.desktop.resumeStrandedTitle}
|
||||
>
|
||||
<div className="grid justify-items-center">
|
||||
<Button onClick={() => onRetryResume(routedSessionId)} size="sm" variant="outline">
|
||||
{t.desktop.resumeRetry}
|
||||
</Button>
|
||||
</div>
|
||||
</ErrorState>
|
||||
</div>
|
||||
{showChatBar && <ScrollToBottomButton />}
|
||||
<ChatDropOverlay kind={dragKind} />
|
||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||
</div>
|
||||
{/* Composer renders OUTSIDE the contain:[layout paint] wrapper above:
|
||||
that wrapper is a containing block for — and clips — position:fixed
|
||||
descendants, so the popped-out (fixed) composer would anchor to the
|
||||
chat column (which shifts/resizes with the sidebars) and get clipped
|
||||
off-screen instead of floating against the viewport. As a sibling it
|
||||
anchors to the outer relative container instead: docked is absolute
|
||||
(identical placement), floating resolves against the viewport. Both
|
||||
states stay mounted here, so dock⇄float never remounts the editor. */}
|
||||
{showChatBar && (
|
||||
<Suspense fallback={<ChatBarFallback />}>
|
||||
<ChatBar
|
||||
busy={busy}
|
||||
cwd={currentCwd}
|
||||
disabled={!gatewayOpen}
|
||||
focusKey={activeSessionId}
|
||||
gateway={gateway}
|
||||
maxRecordingSeconds={maxVoiceRecordingSeconds}
|
||||
onAddContextRef={onAddContextRef}
|
||||
onAddUrl={onAddUrl}
|
||||
onAttachDroppedItems={onAttachDroppedItems}
|
||||
onAttachImageBlob={onAttachImageBlob}
|
||||
onCancel={onCancel}
|
||||
onPasteClipboardImage={onPasteClipboardImage}
|
||||
onPickFiles={onPickFiles}
|
||||
onPickFolders={onPickFolders}
|
||||
onPickImages={onPickImages}
|
||||
onRemoveAttachment={onRemoveAttachment}
|
||||
onSteer={onSteer}
|
||||
onSubmit={onSubmit}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
queueSessionKey={selectedSessionId}
|
||||
sessionId={activeSessionId}
|
||||
state={chatBarState}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{showChatBar && <ScrollToBottomButton />}
|
||||
<ChatDropOverlay kind={dragKind} />
|
||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||
</div>
|
||||
</ChatRuntimeBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
FILE_BROWSER_MAX_WIDTH,
|
||||
FILE_BROWSER_MIN_WIDTH,
|
||||
pinSession,
|
||||
PREVIEW_PANE_ID,
|
||||
setSidebarOverlayMounted,
|
||||
SIDEBAR_DEFAULT_WIDTH,
|
||||
SIDEBAR_MAX_WIDTH,
|
||||
|
|
@ -1127,7 +1128,7 @@ export function DesktopController() {
|
|||
const previewPane = (
|
||||
<Pane
|
||||
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
|
||||
id="preview"
|
||||
id={PREVIEW_PANE_ID}
|
||||
key="preview"
|
||||
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
|
||||
minWidth={PREVIEW_RAIL_MIN_WIDTH}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ErrorBoundary } from '@/components/error-boundary'
|
|||
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 { selectDesktopPaths } from '@/lib/desktop-fs'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
|
|
@ -167,38 +168,41 @@ function FilesystemTab({
|
|||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={r.refreshTree}
|
||||
className={HEADER_ACTION_LABEL_REVEAL}
|
||||
disabled={!hasCwd || loading}
|
||||
onClick={onRefresh}
|
||||
size="icon-xs"
|
||||
title={r.refreshTree}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={r.openFolder}
|
||||
className={HEADER_ACTION_CLASS}
|
||||
onClick={() => void onChangeFolder()}
|
||||
size="icon-xs"
|
||||
title={r.openFolder}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="folder-opened" size="0.8125rem" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={r.collapseAll}
|
||||
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
|
||||
disabled={!hasCwd || !canCollapse}
|
||||
onClick={onCollapseAll}
|
||||
size="icon-xs"
|
||||
title={r.collapseAll}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="collapse-all" size="0.8125rem" />
|
||||
</Button>
|
||||
<Tip label={r.refreshTree} side="left">
|
||||
<Button
|
||||
aria-label={r.refreshTree}
|
||||
className={HEADER_ACTION_LABEL_REVEAL}
|
||||
disabled={!hasCwd || loading}
|
||||
onClick={onRefresh}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={r.openFolder} side="left">
|
||||
<Button
|
||||
aria-label={r.openFolder}
|
||||
className={HEADER_ACTION_CLASS}
|
||||
onClick={() => void onChangeFolder()}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="folder-opened" size="0.8125rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={r.collapseAll} side="left">
|
||||
<Button
|
||||
aria-label={r.collapseAll}
|
||||
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
|
||||
disabled={!hasCwd || !canCollapse}
|
||||
onClick={onCollapseAll}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="collapse-all" size="0.8125rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</RightSidebarSectionHeader>
|
||||
<FileTreeBody
|
||||
collapseNonce={collapseNonce}
|
||||
|
|
|
|||
|
|
@ -120,31 +120,7 @@ describe('usePreviewRouting', () => {
|
|||
expect(window.hermesDesktop.normalizePreviewTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers structured tool-result preview targets', async () => {
|
||||
render(
|
||||
<PreviewRoutingHarness
|
||||
onEvent={handler => {
|
||||
handleEvent = handler
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
act(() =>
|
||||
handleEvent({
|
||||
payload: { path: './dist/index.html' },
|
||||
session_id: 'session-1',
|
||||
type: 'tool.complete'
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect($previewTarget.get()?.source).toBe('./dist/index.html')
|
||||
})
|
||||
|
||||
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('./dist/index.html')
|
||||
})
|
||||
|
||||
it('registers html previews from edit inline diffs', async () => {
|
||||
it('does not auto-open a preview from tool results', async () => {
|
||||
render(
|
||||
<PreviewRoutingHarness
|
||||
onEvent={handler => {
|
||||
|
|
@ -160,9 +136,9 @@ describe('usePreviewRouting', () => {
|
|||
type: 'tool.complete'
|
||||
})
|
||||
)
|
||||
act(() => handleEvent({ payload: { path: './dist/index.html' }, session_id: 'session-1', type: 'tool.complete' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect($previewTarget.get()?.source).toBe('preview-demo.html')
|
||||
})
|
||||
expect($previewTarget.get()).toBeNull()
|
||||
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ import {
|
|||
getSessionPreviewRecord,
|
||||
progressPreviewServerRestart,
|
||||
requestPreviewReload,
|
||||
setPreviewTarget,
|
||||
setSessionPreviewTarget
|
||||
setPreviewTarget
|
||||
} from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
|
@ -40,53 +39,6 @@ function activePreviewSessionId(
|
|||
return selectedStoredSessionId || routedSessionId || activeSessionIdRef.current || ''
|
||||
}
|
||||
|
||||
function looksLikePreviewTarget(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value) || /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value)
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
|
||||
}
|
||||
|
||||
function htmlPathFromInlineDiff(value: string): string {
|
||||
const cleaned = stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '')
|
||||
|
||||
for (const match of cleaned.matchAll(/(?:^|\s)(?:[ab]\/)?([^\s]+\.html?)(?=\s|$)/gi)) {
|
||||
const candidate = match[1]?.trim()
|
||||
|
||||
if (candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function structuredPreviewCandidate(payload: unknown): string {
|
||||
const record = asRecord(payload)
|
||||
const fields = ['url', 'target', 'path', 'file', 'filepath', 'preview']
|
||||
|
||||
for (const field of fields) {
|
||||
const value = record[field]
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const target = value.trim()
|
||||
|
||||
if (target && looksLikePreviewTarget(target)) {
|
||||
return target
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inlineDiff = record.inline_diff
|
||||
|
||||
if (typeof inlineDiff === 'string') {
|
||||
return htmlPathFromInlineDiff(inlineDiff)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function usePreviewRouting({
|
||||
activeSessionIdRef,
|
||||
baseHandleGatewayEvent,
|
||||
|
|
@ -99,6 +51,10 @@ export function usePreviewRouting({
|
|||
const previewRegistry = useStore($sessionPreviewRegistry)
|
||||
const previewSessionId = activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId)
|
||||
|
||||
// Restore a *user-opened* preview when its session becomes active. Tool
|
||||
// results no longer auto-register/open a preview — the inline preview card in
|
||||
// the tool row is the only entry point, so HTML artifacts never pop the rail
|
||||
// open on their own.
|
||||
useEffect(() => {
|
||||
if (currentView !== 'chat' || !previewSessionId) {
|
||||
setPreviewTarget(null)
|
||||
|
|
@ -111,53 +67,6 @@ export function usePreviewRouting({
|
|||
setPreviewTarget(record?.normalized ?? null)
|
||||
}, [currentView, previewRegistry, previewSessionId])
|
||||
|
||||
const registerStructuredPreview = useCallback(
|
||||
async (event: RpcEvent) => {
|
||||
if (
|
||||
event.session_id &&
|
||||
event.session_id !== activeSessionIdRef.current &&
|
||||
event.session_id !== previewSessionId
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.type.startsWith('tool.')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!previewSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidate = structuredPreviewCandidate(event.payload)
|
||||
|
||||
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)
|
||||
|
||||
if (
|
||||
!target ||
|
||||
sessionId !== activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId) ||
|
||||
$currentCwd.get() !== cwd
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setSessionPreviewTarget(sessionId, target, 'tool-result', candidate)
|
||||
},
|
||||
[activeSessionIdRef, currentCwd, previewSessionId, routedSessionId, selectedStoredSessionId]
|
||||
)
|
||||
|
||||
const restartPreviewServer = useCallback(
|
||||
async (url: string, context?: string) => {
|
||||
const sessionId = activeSessionIdRef.current
|
||||
|
|
@ -210,13 +119,14 @@ export function usePreviewRouting({
|
|||
return
|
||||
}
|
||||
|
||||
void registerStructuredPreview(event)
|
||||
|
||||
// Only refresh an already-open live preview when a file changes; never
|
||||
// open one unprompted. (Preview links are surfaced from the tool row into
|
||||
// the status stack — see tool-fallback.tsx.)
|
||||
if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) {
|
||||
requestPreviewReload()
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, baseHandleGatewayEvent, registerStructuredPreview]
|
||||
[activeSessionIdRef, baseHandleGatewayEvent]
|
||||
)
|
||||
|
||||
return { handleDesktopGatewayEvent, restartPreviewServer }
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
updateComposerAttachment
|
||||
} from '@/store/composer'
|
||||
import { resetSessionBackground } from '@/store/composer-status'
|
||||
import { clearPreviewArtifacts } from '@/store/preview-status'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { setPetScale } from '@/store/pet-gallery'
|
||||
|
|
@ -1675,6 +1676,7 @@ export function usePromptActions({
|
|||
// rows (and kill the live processes) before the fresh run repopulates.
|
||||
clearSessionTodos(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
clearPreviewArtifacts(sessionId)
|
||||
|
||||
clearNotifications()
|
||||
setMutableRef(busyRef, true)
|
||||
|
|
@ -1737,6 +1739,7 @@ export function usePromptActions({
|
|||
// processes) before the re-run repopulates them.
|
||||
clearSessionTodos(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
clearPreviewArtifacts(sessionId)
|
||||
|
||||
clearNotifications()
|
||||
setMutableRef(busyRef, true)
|
||||
|
|
|
|||
239
apps/desktop/src/app/settings/computer-use-panel.tsx
Normal file
239
apps/desktop/src/app/settings/computer-use-panel.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getActionStatus, getComputerUseStatus, grantComputerUsePermissions } from '@/hermes'
|
||||
import { AlertTriangle, Check, ExternalLink, Loader2, RefreshCw, X } from '@/lib/icons'
|
||||
import { upsertDesktopActionTask } from '@/store/activity'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ComputerUseStatus } from '@/types/hermes'
|
||||
|
||||
import { Pill } from './primitives'
|
||||
|
||||
interface ComputerUsePanelProps {
|
||||
/** Re-read the parent toolset list after a permission/install change so the
|
||||
* "Configured / Needs keys" pill stays in sync. */
|
||||
onConfiguredChange?: () => void
|
||||
}
|
||||
|
||||
// Per-OS one-liner shown when there's no TCC grant flow (Windows/Linux). macOS
|
||||
// drives the permission rows instead, so it has no entry here.
|
||||
const PLATFORM_NOTE: Record<string, string> = {
|
||||
linux: 'Drives your desktop via the X11/XWayland accessibility stack — no permission prompt.',
|
||||
win32: 'First run may trigger a Windows SmartScreen prompt for the cua-driver UIAccess worker — allow it.'
|
||||
}
|
||||
|
||||
function tone(granted: boolean | null) {
|
||||
return granted === true ? 'primary' : 'muted'
|
||||
}
|
||||
|
||||
function GrantIcon({ granted }: { granted: boolean | null }) {
|
||||
const Icon = granted === true ? Check : granted === false ? X : AlertTriangle
|
||||
|
||||
return <Icon className="size-3" />
|
||||
}
|
||||
|
||||
function PermissionRow({ granted, label, hint }: { granted: boolean | null; label: string; hint: string }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-background/55 p-2.5">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<p className="mt-0.5 text-[0.7rem] text-muted-foreground">{hint}</p>
|
||||
</div>
|
||||
<Pill tone={tone(granted)}>
|
||||
<GrantIcon granted={granted} />
|
||||
{granted === true ? 'Granted' : granted === false ? 'Not granted' : 'Unknown'}
|
||||
</Pill>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-platform Computer Use preflight card.
|
||||
*
|
||||
* cua-driver runs on macOS, Windows, and Linux, but readiness differs: macOS
|
||||
* needs two TCC grants (Accessibility + Screen Recording) that attach to
|
||||
* cua-driver's own `com.trycua.driver` identity — not Hermes — and are
|
||||
* requested via `cua-driver permissions grant` (dialog attributed to
|
||||
* CuaDriver). Windows/Linux have no TCC toggles, so readiness is driver health
|
||||
* from `cua-driver doctor`. The backend folds both into one `ready` signal.
|
||||
*
|
||||
* Binary install/upgrade stays in the cua-driver provider's post-setup runner
|
||||
* below this card (the generic ToolsetConfigPanel).
|
||||
*/
|
||||
export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps) {
|
||||
const [status, setStatus] = useState<ComputerUseStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [granting, setGranting] = useState(false)
|
||||
const activeRef = useRef(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setStatus(await getComputerUseStatus())
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not read Computer Use status')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true
|
||||
void refresh()
|
||||
|
||||
return () => void (activeRef.current = false)
|
||||
}, [refresh])
|
||||
|
||||
const grant = useCallback(async () => {
|
||||
setGranting(true)
|
||||
|
||||
try {
|
||||
const started = await grantComputerUsePermissions()
|
||||
|
||||
if (!started.ok) {
|
||||
notifyError(new Error('spawn failed'), 'Could not request permissions')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: 'Approve in System Settings',
|
||||
message: 'macOS will show a permission dialog attributed to CuaDriver. Approve it, then return here.'
|
||||
})
|
||||
|
||||
// The driver waits for the user to flip the switch — poll until it exits.
|
||||
for (let attempt = 0; attempt < 150 && activeRef.current; attempt += 1) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 1500))
|
||||
|
||||
if (!activeRef.current) {
|
||||
break
|
||||
}
|
||||
|
||||
const polled = await getActionStatus(started.name, 200)
|
||||
upsertDesktopActionTask(polled)
|
||||
|
||||
if (!polled.running) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (activeRef.current) {
|
||||
await refresh()
|
||||
onConfiguredChange?.()
|
||||
}
|
||||
} catch (err) {
|
||||
if (activeRef.current) {
|
||||
notifyError(err, 'Could not request permissions')
|
||||
}
|
||||
} finally {
|
||||
if (activeRef.current) {
|
||||
setGranting(false)
|
||||
}
|
||||
}
|
||||
}, [onConfiguredChange, refresh])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mt-3 flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking Computer Use status…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!status.platform_supported) {
|
||||
return (
|
||||
<p className="mt-3 px-1 text-xs text-muted-foreground">
|
||||
Computer Use isn't supported on this platform ({status.platform}).
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (!status.installed) {
|
||||
return (
|
||||
<p className="mt-3 px-1 text-xs text-muted-foreground">
|
||||
Install the cua-driver backend below to drive this machine.
|
||||
{status.can_grant && ' Then grant Accessibility and Screen Recording here.'}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const failingChecks = status.checks.filter(c => c.status !== 'ok')
|
||||
|
||||
return (
|
||||
<div className="mt-3 grid gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-1">
|
||||
<div className="min-w-0">
|
||||
{status.can_grant ? (
|
||||
<p className="text-[0.72rem] text-muted-foreground">
|
||||
Grants attach to CuaDriver's own identity (com.trycua.driver), not Hermes — so the dialog is
|
||||
attributed to the process that drives your Mac.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[0.72rem] text-muted-foreground">{PLATFORM_NOTE[status.platform] ?? ''}</p>
|
||||
)}
|
||||
{status.version && <p className="text-[0.68rem] text-muted-foreground/80">{status.version}</p>}
|
||||
</div>
|
||||
<Button onClick={() => void refresh()} size="sm" variant="text">
|
||||
<RefreshCw className="size-3.5" />
|
||||
Recheck
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{status.can_grant ? (
|
||||
<>
|
||||
<PermissionRow
|
||||
granted={status.accessibility}
|
||||
hint="Lets cua-driver post clicks, keystrokes, and read the accessibility tree."
|
||||
label="Accessibility"
|
||||
/>
|
||||
<PermissionRow
|
||||
granted={status.screen_recording}
|
||||
hint="Lets cua-driver capture screenshots of app windows."
|
||||
label="Screen Recording"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-background/55 p-2.5">
|
||||
<span className="text-sm font-medium">Driver health</span>
|
||||
<Pill tone={tone(status.ready)}>
|
||||
<GrantIcon granted={status.ready} />
|
||||
{status.ready === true ? 'Ready' : status.ready === false ? 'Not ready' : 'Unknown'}
|
||||
</Pill>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{failingChecks.map(c => (
|
||||
<p className="px-1 text-[0.7rem] text-muted-foreground" key={c.label}>
|
||||
<AlertTriangle className="mr-1 inline size-3" />
|
||||
{c.label}: {c.message}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{status.error && (
|
||||
<p className="px-1 text-[0.7rem] text-muted-foreground">
|
||||
<AlertTriangle className="mr-1 inline size-3" />
|
||||
{status.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status.ready ? (
|
||||
<div className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground">
|
||||
<Check className="size-3.5" />
|
||||
Computer Use is ready. Ask the agent to capture an app and click around.
|
||||
</div>
|
||||
) : (
|
||||
status.can_grant && (
|
||||
<Button disabled={granting} onClick={() => void grant()} size="sm">
|
||||
{granting ? <Loader2 className="size-3.5 animate-spin" /> : <ExternalLink className="size-3.5" />}
|
||||
{granting ? 'Waiting for approval…' : 'Grant permissions'}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
|||
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
|
||||
import { fieldCopyForSchemaKey } from './field-copy'
|
||||
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
|
||||
import { MemoryConnect } from './memory/connect'
|
||||
import { ModelSettings } from './model-settings'
|
||||
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
|
||||
import { ProviderConfigPanel } from './provider-config-panel'
|
||||
|
|
@ -31,7 +32,8 @@ function ConfigField({
|
|||
value,
|
||||
enumOptions,
|
||||
optionLabels,
|
||||
onChange
|
||||
onChange,
|
||||
descriptionExtra
|
||||
}: {
|
||||
schemaKey: string
|
||||
schema: ConfigFieldSchema
|
||||
|
|
@ -39,6 +41,7 @@ function ConfigField({
|
|||
enumOptions?: string[]
|
||||
optionLabels?: Record<string, string>
|
||||
onChange: (value: unknown) => void
|
||||
descriptionExtra?: ReactNode
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.settings.config
|
||||
|
|
@ -64,8 +67,17 @@ function ConfigField({
|
|||
? rawDescription
|
||||
: undefined
|
||||
|
||||
const descriptionNode: ReactNode = descriptionExtra ? (
|
||||
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{description}
|
||||
{descriptionExtra}
|
||||
</span>
|
||||
) : (
|
||||
description
|
||||
)
|
||||
|
||||
const row = (action: ReactNode, wide = false) => (
|
||||
<ListRow action={action} description={description} title={label} wide={wide} />
|
||||
<ListRow action={action} description={descriptionNode} title={label} wide={wide} />
|
||||
)
|
||||
|
||||
if (schema.type === 'boolean') {
|
||||
|
|
@ -358,6 +370,11 @@ export function ConfigSettings({
|
|||
{fields.map(([key, field]) => (
|
||||
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
|
||||
<ConfigField
|
||||
descriptionExtra={
|
||||
key === 'memory.provider' && Boolean(getNested(config, key)) ? (
|
||||
<MemoryConnect provider={String(getNested(config, key))} />
|
||||
) : undefined
|
||||
}
|
||||
enumOptions={
|
||||
key === 'tts.elevenlabs.voice_id'
|
||||
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
|
||||
|
|
|
|||
162
apps/desktop/src/app/settings/memory/connect.tsx
Normal file
162
apps/desktop/src/app/settings/memory/connect.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getMemoryProviderOAuthStatus, startMemoryProviderOAuth } from '@/hermes'
|
||||
import { Check, ExternalLink, Loader2 } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import type { MemoryProviderOAuthStatus } from '@/types/hermes'
|
||||
|
||||
const POLL_MS = 1500
|
||||
const POLL_TIMEOUT_MS = 120_000
|
||||
|
||||
// Small connect affordance rendered under the provider dropdown. Capability is
|
||||
// backend-driven: the status route 404s for providers without an oauth_flow
|
||||
// module, so non-OAuth providers render nothing.
|
||||
export function MemoryConnect({ provider }: { provider: string }) {
|
||||
const [capable, setCapable] = useState<'no' | 'unknown' | 'yes'>('unknown')
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [auth, setAuth] = useState<MemoryProviderOAuthStatus['auth']>(null)
|
||||
const [phase, setPhase] = useState<'error' | 'idle' | 'pending'>('idle')
|
||||
const [detail, setDetail] = useState('')
|
||||
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const deadline = useRef(0)
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (timer.current !== null) {
|
||||
clearInterval(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setCapable('unknown')
|
||||
getMemoryProviderOAuthStatus(provider)
|
||||
.then(s => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
setCapable('yes')
|
||||
setConnected(s.connected)
|
||||
setAuth(s.auth)
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setCapable('no')
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
stop()
|
||||
}
|
||||
}, [provider, stop])
|
||||
|
||||
// An error message isn't sticky — it clears back to the steady state
|
||||
// (Connect link, plus the connected badge if a credential is stored).
|
||||
useEffect(() => {
|
||||
if (phase !== 'error') {
|
||||
return
|
||||
}
|
||||
|
||||
const t = setTimeout(() => {
|
||||
setPhase('idle')
|
||||
setDetail('')
|
||||
}, 6000)
|
||||
|
||||
return () => clearTimeout(t)
|
||||
}, [phase])
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
setPhase('pending')
|
||||
|
||||
try {
|
||||
await startMemoryProviderOAuth(provider)
|
||||
} catch (err) {
|
||||
setPhase('error')
|
||||
setDetail('Could not start the connection.')
|
||||
notifyError(err, 'Failed to start connection')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deadline.current = Date.now() + POLL_TIMEOUT_MS
|
||||
stop()
|
||||
timer.current = setInterval(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await getMemoryProviderOAuthStatus(provider)
|
||||
|
||||
if (next.state === 'pending') {
|
||||
if (Date.now() > deadline.current) {
|
||||
stop()
|
||||
setPhase('error')
|
||||
setDetail('Timed out — try again.')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
stop()
|
||||
setConnected(next.connected)
|
||||
setAuth(next.auth)
|
||||
|
||||
if (next.state === 'error') {
|
||||
setPhase('error')
|
||||
setDetail(next.detail || 'Connection failed.')
|
||||
} else {
|
||||
setPhase('idle')
|
||||
}
|
||||
} catch {
|
||||
// Transient poll failure — keep trying until the deadline.
|
||||
}
|
||||
})()
|
||||
}, POLL_MS)
|
||||
}, [provider, stop])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
stop()
|
||||
setPhase('idle')
|
||||
}, [stop])
|
||||
|
||||
if (capable !== 'yes') {
|
||||
return null
|
||||
}
|
||||
|
||||
const connectLabel = connected ? (auth === 'apikey' ? 'Connect via OAuth' : 'Reconnect') : 'Connect'
|
||||
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
{phase === 'idle' && connected && (
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Check className="size-3" />
|
||||
{auth === 'apikey' ? 'api key set' : 'oauth set'}
|
||||
</span>
|
||||
)}
|
||||
{phase === 'pending' ? (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Waiting for browser consent…
|
||||
</span>
|
||||
<Button className="h-auto p-0 text-xs" onClick={cancel} size="sm" type="button" variant="link">
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
className="h-auto gap-1 p-0 text-xs"
|
||||
onClick={() => void connect()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
{connectLabel}
|
||||
</Button>
|
||||
)}
|
||||
{phase === 'error' && detail && <span className="text-destructive">{detail}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -326,8 +326,10 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||
}
|
||||
|
||||
// Collapsed we show the user's chosen models (or the curated default); typing
|
||||
// spans every available model so anything is reachable past the cut.
|
||||
const PER_PROVIDER_SEARCH = 12
|
||||
// spans every available model so anything is reachable past the cut. A search
|
||||
// is itself a narrowing action, so we do NOT cap per-provider matches — a
|
||||
// provider serving 19 models (e.g. opencode-go) must show all 19 when the user
|
||||
// searches for it, not a truncated subset. (#47077 follow-up)
|
||||
|
||||
function groupModels(
|
||||
providers: ModelOptionProvider[],
|
||||
|
|
@ -374,11 +376,7 @@ function groupModels(
|
|||
? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id
|
||||
: undefined
|
||||
|
||||
let families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId)
|
||||
|
||||
if (q) {
|
||||
families = families.slice(0, PER_PROVIDER_SEARCH)
|
||||
}
|
||||
const families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId)
|
||||
|
||||
if (families.length > 0) {
|
||||
groups.push({ families, provider })
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
|||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -204,41 +205,43 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
|
|||
|
||||
if (tool.href) {
|
||||
return (
|
||||
<Button asChild className={className} size="icon-titlebar" variant="ghost">
|
||||
<a
|
||||
aria-label={tool.label}
|
||||
href={tool.href}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={tool.title ?? tool.label}
|
||||
>
|
||||
{tool.icon}
|
||||
</a>
|
||||
</Button>
|
||||
<Tip label={tool.title ?? tool.label}>
|
||||
<Button asChild className={className} size="icon-titlebar" variant="ghost">
|
||||
<a
|
||||
aria-label={tool.label}
|
||||
href={tool.href}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{tool.icon}
|
||||
</a>
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={tool.label}
|
||||
aria-pressed={tool.active ?? undefined}
|
||||
className={className}
|
||||
disabled={tool.disabled}
|
||||
onClick={() => {
|
||||
if (tool.to) {
|
||||
navigate(tool.to)
|
||||
}
|
||||
<Tip label={tool.title ?? tool.label}>
|
||||
<Button
|
||||
aria-label={tool.label}
|
||||
aria-pressed={tool.active ?? undefined}
|
||||
className={className}
|
||||
disabled={tool.disabled}
|
||||
onClick={() => {
|
||||
if (tool.to) {
|
||||
navigate(tool.to)
|
||||
}
|
||||
|
||||
tool.onSelect?.()
|
||||
}}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
size="icon-titlebar"
|
||||
title={tool.title ?? tool.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{tool.icon}
|
||||
</Button>
|
||||
tool.onSelect?.()
|
||||
}}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
size="icon-titlebar"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{tool.icon}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
|||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PAGE_INSET_X } from '../layout-constants'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { ComputerUsePanel } from '../settings/computer-use-panel'
|
||||
import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers'
|
||||
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
|
@ -334,6 +335,9 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{expanded && toolset.name === 'computer_use' && (
|
||||
<ComputerUsePanel onConfiguredChange={refreshToolsets} />
|
||||
)}
|
||||
{expanded && <ToolsetConfigPanel onConfiguredChange={refreshToolsets} toolset={toolset.name} />}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { activeTimelineIndex, deriveTimelineEntries, timelinePreview } from './thread-timeline-data'
|
||||
|
||||
describe('timelinePreview', () => {
|
||||
it('collapses whitespace to a single line', () => {
|
||||
expect(timelinePreview('hello\n\n world\tagain')).toBe('hello world again')
|
||||
})
|
||||
|
||||
it('truncates with an ellipsis past the limit', () => {
|
||||
const out = timelinePreview('abcdefghij', 5)
|
||||
expect(out).toBe('abcd…')
|
||||
expect(out.length).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveTimelineEntries', () => {
|
||||
it('keeps non-empty user prompts in order', () => {
|
||||
expect(
|
||||
deriveTimelineEntries([
|
||||
{ id: 'u1', role: 'user', text: 'first' },
|
||||
{ id: 'a1', role: 'assistant', text: 'answer' },
|
||||
{ id: 'u2', role: 'user', text: ' second ' }
|
||||
])
|
||||
).toEqual([
|
||||
{ id: 'u1', preview: 'first' },
|
||||
{ id: 'u2', preview: 'second' }
|
||||
])
|
||||
})
|
||||
|
||||
it('drops blanks and background-process notifications', () => {
|
||||
expect(
|
||||
deriveTimelineEntries([
|
||||
{ id: 'u1', role: 'user', text: ' ' },
|
||||
{ id: 'u2', role: 'user', text: '[IMPORTANT: Background process 123 finished]' },
|
||||
{ id: 'u3', role: 'user', text: 'real prompt' }
|
||||
]).map(e => e.id)
|
||||
).toEqual(['u3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('activeTimelineIndex', () => {
|
||||
it('returns the last prompt scrolled to or above the top edge', () => {
|
||||
expect(activeTimelineIndex([-400, -10, 320])).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to the first rendered entry', () => {
|
||||
expect(activeTimelineIndex([null, 120, 480])).toBe(1)
|
||||
expect(activeTimelineIndex([null, null])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
// Pure timeline helpers — no React/DOM; tested in thread-timeline-data.test.ts.
|
||||
|
||||
export interface TimelineSourceMessage {
|
||||
id: string
|
||||
role: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface TimelineEntry {
|
||||
id: string
|
||||
preview: string
|
||||
}
|
||||
|
||||
// Injected as user messages for alternation; not human prompts (thread.tsx).
|
||||
const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
|
||||
|
||||
const PREVIEW_MAX = 120
|
||||
|
||||
export function timelinePreview(text: string, max: number = PREVIEW_MAX): string {
|
||||
const collapsed = text.replace(/\s+/g, ' ').trim()
|
||||
|
||||
if (collapsed.length <= max) {
|
||||
return collapsed
|
||||
}
|
||||
|
||||
return `${collapsed.slice(0, max - 1).trimEnd()}…`
|
||||
}
|
||||
|
||||
export function deriveTimelineEntries(messages: readonly TimelineSourceMessage[]): TimelineEntry[] {
|
||||
const entries: TimelineEntry[] = []
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role !== 'user') {
|
||||
continue
|
||||
}
|
||||
|
||||
const text = message.text.trim()
|
||||
|
||||
if (!text || PROCESS_NOTIFICATION_RE.test(text)) {
|
||||
continue
|
||||
}
|
||||
|
||||
entries.push({ id: message.id, preview: timelinePreview(text) })
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Last user prompt at/above the viewport top (with slack); else first rendered. */
|
||||
export function activeTimelineIndex(offsets: readonly (number | null)[], slack: number = 8): number {
|
||||
let active = -1
|
||||
let firstRendered = -1
|
||||
|
||||
for (let i = 0; i < offsets.length; i++) {
|
||||
const offset = offsets[i]
|
||||
|
||||
if (offset == null) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (firstRendered === -1) {
|
||||
firstRendered = i
|
||||
}
|
||||
|
||||
if (offset <= slack) {
|
||||
active = i
|
||||
}
|
||||
}
|
||||
|
||||
if (active !== -1) {
|
||||
return active
|
||||
}
|
||||
|
||||
return firstRendered === -1 ? 0 : firstRendered
|
||||
}
|
||||
272
apps/desktop/src/components/assistant-ui/thread-timeline.tsx
Normal file
272
apps/desktop/src/components/assistant-ui/thread-timeline.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import { useAuiState } from '@assistant-ui/react'
|
||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { composerPanelCard } from '@/components/chat/composer-dock'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setPaneHoverRevealSuppressed } from '@/store/panes'
|
||||
|
||||
import {
|
||||
activeTimelineIndex,
|
||||
deriveTimelineEntries,
|
||||
type TimelineEntry,
|
||||
type TimelineSourceMessage
|
||||
} from './thread-timeline-data'
|
||||
|
||||
const MIN_ENTRIES = 4
|
||||
const VIEWPORT = '[data-slot="aui_thread-viewport"]'
|
||||
const HOVER_CLOSE_MS = 140
|
||||
|
||||
const ROW_CLASS =
|
||||
'relative flex w-full min-w-0 max-w-full cursor-pointer select-none overflow-hidden rounded-md px-2 py-1 text-left outline-hidden transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none'
|
||||
|
||||
const POPOVER_SHELL = cn(
|
||||
'absolute right-full top-1/2 z-50 mr-1.5 max-h-[min(22rem,calc(100vh-8rem))] w-80 max-w-[min(20rem,calc(100vw-2rem))] -translate-y-1/2 overflow-x-hidden overflow-y-auto overscroll-contain p-1 text-popover-foreground transition-[opacity,transform] duration-100 ease-out group-hover/timeline:transition-none',
|
||||
composerPanelCard,
|
||||
// Solid fill — composerPanelCard is deliberately translucent; without this,
|
||||
// directive chips in the transcript bleed through and look like popover overflow.
|
||||
'bg-(--composer-fill)'
|
||||
)
|
||||
|
||||
function userPromptText(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let out = ''
|
||||
|
||||
for (const part of content) {
|
||||
if (typeof part === 'string') {
|
||||
out += part
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (!part || typeof part !== 'object') {
|
||||
continue
|
||||
}
|
||||
|
||||
const row = part as { text?: unknown; type?: unknown }
|
||||
|
||||
if ((!row.type || row.type === 'text') && typeof row.text === 'string') {
|
||||
out += row.text
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function scrollToPrompt(id: string) {
|
||||
const viewport = document.querySelector<HTMLElement>(VIEWPORT)
|
||||
const node = viewport?.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
|
||||
|
||||
if (!viewport || !node) {
|
||||
return
|
||||
}
|
||||
|
||||
const top = viewport.scrollTop + (node.getBoundingClientRect().top - viewport.getBoundingClientRect().top) - 8
|
||||
|
||||
triggerHaptic('selection')
|
||||
viewport.scrollTo({ behavior: 'smooth', top: Math.max(0, top) })
|
||||
}
|
||||
|
||||
/** Right-edge prompt rail — hover previews, click to jump. ≥4 user turns only. */
|
||||
export const ThreadTimeline: FC = () => {
|
||||
const sourceSignature = useAuiState(s => {
|
||||
const rows: TimelineSourceMessage[] = []
|
||||
|
||||
for (const message of s.thread.messages) {
|
||||
if (message.role !== 'user') {
|
||||
continue
|
||||
}
|
||||
|
||||
rows.push({ id: message.id, role: 'user', text: userPromptText(message.content) })
|
||||
}
|
||||
|
||||
return JSON.stringify(rows)
|
||||
})
|
||||
|
||||
const entries = useMemo(
|
||||
() => deriveTimelineEntries(JSON.parse(sourceSignature) as TimelineSourceMessage[]),
|
||||
[sourceSignature]
|
||||
)
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const closeTimerRef = useRef<number | undefined>(undefined)
|
||||
|
||||
const keepOpen = useCallback(() => {
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
setPaneHoverRevealSuppressed(true)
|
||||
setOpen(true)
|
||||
}, [])
|
||||
|
||||
const closeSoon = useCallback(() => {
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
setHoverIndex(null)
|
||||
setPaneHoverRevealSuppressed(false)
|
||||
closeTimerRef.current = window.setTimeout(() => setOpen(false), HOVER_CLOSE_MS)
|
||||
}, [])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
setPaneHoverRevealSuppressed(false)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (entries.length < MIN_ENTRIES) {
|
||||
setPaneHoverRevealSuppressed(false)
|
||||
}
|
||||
}, [entries.length])
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = document.querySelector<HTMLElement>(VIEWPORT)
|
||||
|
||||
if (!viewport || entries.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let raf = 0
|
||||
|
||||
const compute = () => {
|
||||
raf = 0
|
||||
|
||||
const top = viewport.getBoundingClientRect().top
|
||||
|
||||
const offsets = entries.map(entry => {
|
||||
const node = viewport.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(entry.id)}"]`)
|
||||
|
||||
return node ? node.getBoundingClientRect().top - top : null
|
||||
})
|
||||
|
||||
const next = activeTimelineIndex(offsets)
|
||||
|
||||
setActiveIndex(prev => (prev === next ? prev : next))
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
if (!raf) {
|
||||
raf = requestAnimationFrame(compute)
|
||||
}
|
||||
}
|
||||
|
||||
compute()
|
||||
viewport.addEventListener('scroll', onScroll, { passive: true })
|
||||
|
||||
return () => {
|
||||
viewport.removeEventListener('scroll', onScroll)
|
||||
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
}
|
||||
}, [entries])
|
||||
|
||||
if (entries.length < MIN_ENTRIES) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="Conversation timeline"
|
||||
className="group/timeline pointer-events-auto absolute right-0 top-1/2 z-40 flex -translate-y-1/2 flex-col items-end"
|
||||
data-slot="thread-timeline"
|
||||
onMouseEnter={keepOpen}
|
||||
onMouseLeave={closeSoon}
|
||||
role="navigation"
|
||||
>
|
||||
<TimelineTicks
|
||||
activeIndex={activeIndex}
|
||||
entries={entries}
|
||||
onHover={setHoverIndex}
|
||||
onJump={scrollToPrompt}
|
||||
/>
|
||||
<TimelinePopover
|
||||
activeIndex={activeIndex}
|
||||
entries={entries}
|
||||
hoverIndex={hoverIndex}
|
||||
onHover={setHoverIndex}
|
||||
onJump={scrollToPrompt}
|
||||
open={open}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TimelinePopover: FC<{
|
||||
activeIndex: number
|
||||
entries: TimelineEntry[]
|
||||
hoverIndex: number | null
|
||||
onHover: (index: number) => void
|
||||
onJump: (id: string) => void
|
||||
open: boolean
|
||||
}> = ({ activeIndex, entries, hoverIndex, onHover, onJump, open }) => (
|
||||
<div
|
||||
className={cn(
|
||||
POPOVER_SHELL,
|
||||
open ? 'pointer-events-auto opacity-100 translate-x-0' : 'pointer-events-none translate-x-1 opacity-0'
|
||||
)}
|
||||
data-slot="thread-timeline-popover"
|
||||
>
|
||||
{entries.map((entry, index) => {
|
||||
const hovered = index === hoverIndex
|
||||
const active = index === activeIndex
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={entry.preview}
|
||||
className={cn(
|
||||
ROW_CLASS,
|
||||
active && 'bg-(--ui-row-active-background) text-foreground',
|
||||
hovered && 'bg-(--ui-row-hover-background) text-foreground transition-none'
|
||||
)}
|
||||
key={entry.id}
|
||||
onClick={() => onJump(entry.id)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
type="button"
|
||||
>
|
||||
<span className="block w-full min-w-0 truncate font-medium leading-snug text-foreground">
|
||||
{entry.preview}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
const TimelineTicks: FC<{
|
||||
activeIndex: number
|
||||
entries: TimelineEntry[]
|
||||
onHover: (index: number) => void
|
||||
onJump: (id: string) => void
|
||||
}> = ({ activeIndex, entries, onHover, onJump }) => (
|
||||
<div className="flex flex-col items-end py-1" data-slot="thread-timeline-ticks">
|
||||
{entries.map((entry, index) => (
|
||||
<button
|
||||
aria-label={entry.preview}
|
||||
className="group/tick flex h-2 w-7 cursor-pointer items-center justify-end pr-1"
|
||||
key={entry.id}
|
||||
onClick={() => onJump(entry.id)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'block h-px w-3 transition-opacity duration-100 ease-out',
|
||||
index === activeIndex
|
||||
? 'bg-(--theme-primary)'
|
||||
: 'dither text-(--ui-text-quaternary) opacity-70 group-hover/tick:opacity-100 group-hover/tick:transition-none'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -64,6 +64,7 @@ import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
|||
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
|
||||
import { ThreadMessageList } from '@/components/assistant-ui/thread-list'
|
||||
import { ThreadTimeline } from '@/components/assistant-ui/thread-timeline'
|
||||
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
|
||||
|
|
@ -212,6 +213,7 @@ export const Thread: FC<{
|
|||
sessionKey={sessionKey}
|
||||
/>
|
||||
{loading === 'session' && <CenteredThreadSpinner />}
|
||||
<ThreadTimeline />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -797,7 +799,15 @@ function messageAttachmentRefs(value: unknown): string[] {
|
|||
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
|
||||
}
|
||||
|
||||
function StickyHumanMessageContainer({ attachments, children }: { attachments?: ReactNode; children: ReactNode }) {
|
||||
function StickyHumanMessageContainer({
|
||||
attachments,
|
||||
children,
|
||||
messageId
|
||||
}: {
|
||||
attachments?: ReactNode
|
||||
children: ReactNode
|
||||
messageId?: string
|
||||
}) {
|
||||
return (
|
||||
// Fragment, not a wrapper: a wrapping element becomes the sticky's
|
||||
// containing block (it'd stick within its own height = never). The bubble
|
||||
|
|
@ -806,6 +816,7 @@ function StickyHumanMessageContainer({ attachments, children }: { attachments?:
|
|||
<>
|
||||
<div
|
||||
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-1"
|
||||
data-message-id={messageId}
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
|
|
@ -990,6 +1001,7 @@ const UserMessage: FC<{
|
|||
return (
|
||||
<MessagePrimitive.Root asChild>
|
||||
<StickyHumanMessageContainer
|
||||
messageId={messageId}
|
||||
attachments={
|
||||
// Attachments live BELOW the sticky bubble in normal flow, so they
|
||||
// scroll away behind the pinned bubble instead of riding along with
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react'
|
||||
import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useEffect, useMemo } from 'react'
|
||||
|
||||
import { AnsiText } from '@/components/assistant-ui/ansi-text'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
|
|
@ -10,7 +10,6 @@ import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
|||
import { CompactMarkdown } from '@/components/chat/compact-markdown'
|
||||
import { FileDiffPanel } from '@/components/chat/diff-lines'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
|
|
@ -25,6 +24,8 @@ import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } f
|
|||
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { recordPreviewArtifact } from '@/store/preview-status'
|
||||
import { $activeSessionId, $currentCwd } from '@/store/session'
|
||||
import { $toolInlineDiffs } from '@/store/tool-diffs'
|
||||
import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss'
|
||||
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
|
||||
|
|
@ -76,6 +77,8 @@ const TOOL_SECTION_LABEL_CLASS = 'mb-1 text-[0.65rem] font-medium uppercase trac
|
|||
const TOOL_SECTION_SURFACE_CLASS =
|
||||
'max-h-20 max-w-full overflow-auto bg-transparent px-2 py-1.5 text-(--ui-text-secondary)'
|
||||
|
||||
const TOOL_EXPANDED_SHELL_CLASS = 'rounded-[0.3125rem] border border-(--ui-stroke-tertiary)'
|
||||
|
||||
const TOOL_SECTION_PRE_CLASS = cn(TOOL_SECTION_SURFACE_CLASS, 'font-mono text-[0.7rem] leading-relaxed')
|
||||
|
||||
interface ToolStatusCopy {
|
||||
|
|
@ -242,6 +245,22 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
return buildToolView(p, inlineDiff)
|
||||
}, [inlineDiff, isPending, part])
|
||||
|
||||
// Surface a previewable artifact (HTML file / localhost URL) as a compact link
|
||||
// in the composer status stack rather than a bulky inline card. Uses the same
|
||||
// detected target the old inline card did, keyed to the active session the
|
||||
// stack reads from. Idempotent + dedup'd, so re-renders don't churn.
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const previewTarget = view.previewTarget
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending || !activeSessionId || !previewTarget || !isPreviewableTarget(previewTarget)) {
|
||||
return
|
||||
}
|
||||
|
||||
recordPreviewArtifact(activeSessionId, previewTarget, currentCwd || '')
|
||||
}, [activeSessionId, currentCwd, isPending, previewTarget])
|
||||
|
||||
const detailSections = useMemo(() => {
|
||||
if (!view.detail) {
|
||||
return { body: '', summary: '' }
|
||||
|
|
@ -291,12 +310,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
Boolean(view.rawResult.trim())
|
||||
|
||||
const hasExpandableContent = Boolean(
|
||||
(view.previewTarget && isPreviewableTarget(view.previewTarget)) ||
|
||||
view.imageUrl ||
|
||||
view.inlineDiff ||
|
||||
showDetail ||
|
||||
hasSearchHits ||
|
||||
toolViewMode === 'technical'
|
||||
view.imageUrl || view.inlineDiff || showDetail || hasSearchHits || toolViewMode === 'technical'
|
||||
)
|
||||
|
||||
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
|
||||
|
|
@ -360,7 +374,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
|
||||
open && 'rounded-[0.625rem] border border-(--ui-stroke-tertiary)'
|
||||
open && TOOL_EXPANDED_SHELL_CLASS
|
||||
)}
|
||||
data-file-edit={isFileEdit && open ? '' : undefined}
|
||||
data-slot="tool-block"
|
||||
|
|
@ -425,9 +439,6 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
text={copyAction.text}
|
||||
/>
|
||||
)}
|
||||
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
|
||||
<PreviewAttachment source="tool-result" target={view.previewTarget} />
|
||||
)}
|
||||
{view.imageUrl && (
|
||||
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
|
||||
<ZoomableImage alt={copy.outputAlt} className="h-auto w-full object-cover" src={view.imageUrl} />
|
||||
|
|
|
|||
|
|
@ -104,16 +104,15 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-160 flex-wrap items-center gap-2.5 rounded-lg border border-border/55 bg-card/55 px-2.5 py-1.5 text-sm">
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground/85">
|
||||
<div className="flex w-full max-w-160 items-center gap-2 rounded-lg border border-border/55 bg-card/55 px-2.5 py-1.5 text-sm">
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground/85">
|
||||
<MonitorPlay className="size-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[0.78rem] font-medium leading-[1.15rem] text-foreground/90">{name}</div>
|
||||
<div className="truncate font-mono text-[0.66rem] leading-4 text-muted-foreground/70">{target}</div>
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78rem] font-medium text-foreground/90" title={target}>
|
||||
{name}
|
||||
</span>
|
||||
<button
|
||||
className="ml-auto shrink-0 rounded-md border border-border/55 bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50 max-[28rem]:ml-9 max-[28rem]:w-[calc(100%-2.25rem)]"
|
||||
className="shrink-0 rounded-md border border-border/55 bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50"
|
||||
disabled={opening}
|
||||
onClick={() => void togglePreview()}
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
} from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes'
|
||||
import { $paneHoverRevealSuppressed, $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes'
|
||||
|
||||
import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context'
|
||||
|
||||
|
|
@ -250,6 +250,7 @@ export function Pane({
|
|||
}: PaneProps) {
|
||||
const ctx = useContext(PaneShellContext)
|
||||
const paneStates = useStore($paneStates)
|
||||
const hoverRevealSuppressed = useStore($paneHoverRevealSuppressed)
|
||||
const registered = useRef(false)
|
||||
const paneRef = useRef<HTMLDivElement | null>(null)
|
||||
// Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS.
|
||||
|
|
@ -378,7 +379,10 @@ export function Pane({
|
|||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]"
|
||||
className={cn(
|
||||
'absolute inset-y-0 z-30 [-webkit-app-region:no-drag]',
|
||||
hoverRevealSuppressed ? 'pointer-events-none' : 'pointer-events-auto'
|
||||
)}
|
||||
style={{ [edge]: HOVER_REVEAL_EDGE_GUTTER, width: HOVER_REVEAL_TRIGGER_WIDTH }}
|
||||
/>
|
||||
|
||||
|
|
@ -388,7 +392,8 @@ export function Pane({
|
|||
className={cn(
|
||||
'pointer-events-none absolute inset-y-0 z-30 overflow-hidden transition-transform delay-0',
|
||||
offscreen,
|
||||
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
|
||||
!hoverRevealSuppressed &&
|
||||
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
|
||||
'group-data-[forced]/reveal:pointer-events-auto group-data-[forced]/reveal:translate-x-0 group-data-[forced]/reveal:delay-0 group-data-[forced]/reveal:shadow-[var(--reveal-shadow)]'
|
||||
)}
|
||||
key={edge}
|
||||
|
|
|
|||
1
apps/desktop/src/global.d.ts
vendored
1
apps/desktop/src/global.d.ts
vendored
|
|
@ -81,6 +81,7 @@ declare global {
|
|||
setTranslucency?: (payload: { intensity: number }) => void
|
||||
setPreviewShortcutActive?: (active: boolean) => void
|
||||
openExternal: (url: string) => Promise<void>
|
||||
openPreviewInBrowser?: (url: string) => Promise<void>
|
||||
fetchLinkTitle: (url: string) => Promise<string>
|
||||
sanitizeWorkspaceCwd: (cwd?: null | string) => Promise<{ cwd: string; sanitized: boolean }>
|
||||
settings: {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
AudioTranscriptionResponse,
|
||||
AuxiliaryModelsResponse,
|
||||
BackendUpdateCheckResponse,
|
||||
ComputerUseStatus,
|
||||
ConfigSchemaResponse,
|
||||
CronJob,
|
||||
CronJobCreatePayload,
|
||||
|
|
@ -18,6 +19,7 @@ import type {
|
|||
HermesConfigRecord,
|
||||
LogsResponse,
|
||||
MemoryProviderConfig,
|
||||
MemoryProviderOAuthStatus,
|
||||
MessagingPlatformsResponse,
|
||||
MessagingPlatformTestResponse,
|
||||
MessagingPlatformUpdate,
|
||||
|
|
@ -59,6 +61,9 @@ export type {
|
|||
AudioTranscriptionResponse,
|
||||
AuxiliaryModelsResponse,
|
||||
BackendUpdateCheckResponse,
|
||||
ComputerUseCheck,
|
||||
ComputerUsePermissionSource,
|
||||
ComputerUseStatus,
|
||||
ConfigFieldSchema,
|
||||
ConfigSchemaResponse,
|
||||
CronJob,
|
||||
|
|
@ -73,6 +78,7 @@ export type {
|
|||
HermesConfigRecord,
|
||||
LogsResponse,
|
||||
MemoryProviderConfig,
|
||||
MemoryProviderOAuthStatus,
|
||||
MessagingEnvVarInfo,
|
||||
MessagingHomeChannel,
|
||||
MessagingPlatformInfo,
|
||||
|
|
@ -453,6 +459,23 @@ export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }>
|
|||
})
|
||||
}
|
||||
|
||||
// Memory-provider OAuth connect (provider-keyed; 404s for providers without an
|
||||
// OAuth flow). Profile-scoped: the grant lands in the active profile's config.
|
||||
export function startMemoryProviderOAuth(provider: string): Promise<MemoryProviderOAuthStatus> {
|
||||
return window.hermesDesktop.api<MemoryProviderOAuthStatus>({
|
||||
...profileScoped(),
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`,
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function getMemoryProviderOAuthStatus(provider: string): Promise<MemoryProviderOAuthStatus> {
|
||||
return window.hermesDesktop.api<MemoryProviderOAuthStatus>({
|
||||
...profileScoped(),
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status`
|
||||
})
|
||||
}
|
||||
|
||||
export function getSkills(): Promise<SkillInfo[]> {
|
||||
return window.hermesDesktop.api<SkillInfo[]>({
|
||||
...profileScoped(),
|
||||
|
|
@ -516,6 +539,21 @@ export function runToolsetPostSetup(name: string, key: string): Promise<ActionRe
|
|||
})
|
||||
}
|
||||
|
||||
export function getComputerUseStatus(): Promise<ComputerUseStatus> {
|
||||
return window.hermesDesktop.api<ComputerUseStatus>({
|
||||
...profileScoped(),
|
||||
path: '/api/tools/computer-use/status'
|
||||
})
|
||||
}
|
||||
|
||||
export function grantComputerUsePermissions(): Promise<ActionResponse> {
|
||||
return window.hermesDesktop.api<ActionResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/tools/computer-use/permissions/grant',
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function getMessagingPlatforms(): Promise<MessagingPlatformsResponse> {
|
||||
return window.hermesDesktop.api<MessagingPlatformsResponse>({
|
||||
path: '/api/messaging/platforms'
|
||||
|
|
|
|||
|
|
@ -1710,6 +1710,7 @@ export const en: Translations = {
|
|||
opening: 'Opening...',
|
||||
hide: 'Hide',
|
||||
openPreview: 'Open preview',
|
||||
openInBrowser: 'Open in browser',
|
||||
sourceLineTitle: 'Click to select · shift-click to extend · drag to composer',
|
||||
source: 'SOURCE',
|
||||
renderedPreview: 'PREVIEW',
|
||||
|
|
|
|||
|
|
@ -1839,6 +1839,7 @@ export const ja = defineLocale({
|
|||
opening: '開いています...',
|
||||
hide: '非表示',
|
||||
openPreview: 'プレビューを開く',
|
||||
openInBrowser: 'ブラウザで開く',
|
||||
sourceLineTitle: 'クリックして選択 · Shift クリックで拡張 · コンポーザーにドラッグ',
|
||||
source: 'ソース',
|
||||
renderedPreview: 'プレビュー',
|
||||
|
|
|
|||
|
|
@ -1345,6 +1345,7 @@ export interface Translations {
|
|||
opening: string
|
||||
hide: string
|
||||
openPreview: string
|
||||
openInBrowser: string
|
||||
sourceLineTitle: string
|
||||
source: string
|
||||
renderedPreview: string
|
||||
|
|
|
|||
|
|
@ -1780,6 +1780,7 @@ export const zhHant = defineLocale({
|
|||
opening: '開啟中...',
|
||||
hide: '隱藏',
|
||||
openPreview: '開啟預覽',
|
||||
openInBrowser: '在瀏覽器中開啟',
|
||||
sourceLineTitle: '點擊選取 · shift 點擊擴展 · 拖曳至輸入框',
|
||||
source: '原始碼',
|
||||
renderedPreview: '預覽',
|
||||
|
|
|
|||
|
|
@ -1885,6 +1885,7 @@ export const zh: Translations = {
|
|||
opening: '正在打开...',
|
||||
hide: '隐藏',
|
||||
openPreview: '打开预览',
|
||||
openInBrowser: '在浏览器中打开',
|
||||
sourceLineTitle: '点击选择 · shift 点击扩展 · 拖到输入框',
|
||||
source: '源码',
|
||||
renderedPreview: '预览',
|
||||
|
|
|
|||
|
|
@ -32,4 +32,13 @@ describe('extractEmbeddedImages', () => {
|
|||
expect(result.cleanedText).toBe('first mid tail')
|
||||
expect(result.images).toEqual([SAMPLE_PNG_DATA_URL, second])
|
||||
})
|
||||
|
||||
it('handles multi-megabyte data URLs without overflowing the JS stack', () => {
|
||||
const hugeDataUrl = 'data:image/png;base64,' + 'A'.repeat(8_000_000)
|
||||
const result = extractEmbeddedImages(`describe this ${hugeDataUrl} thanks`)
|
||||
|
||||
expect(result.cleanedText).toBe('describe this thanks')
|
||||
expect(result.images).toHaveLength(1)
|
||||
expect(result.images[0]).toHaveLength(hugeDataUrl.length)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
const EMBEDDED_IMAGE_RE =
|
||||
/(\{\s*"type"\s*:\s*"image_url"\s*,\s*"image_url"\s*:\s*\{\s*"url"\s*:\s*")?(data:image\/[\w.+-]+;base64,[A-Za-z0-9+/=]{64,})("\s*\}\s*\})?/g
|
||||
|
||||
const DATA_URL_RE = /^data:([\w./+-]+);base64,(.*)$/i
|
||||
const DATA_IMAGE_PREFIX = 'data:image/'
|
||||
const BASE64_MARKER = ';base64,'
|
||||
const MIN_EMBEDDED_IMAGE_BASE64_LENGTH = 64
|
||||
const JSON_IMAGE_OPEN_RE = /\{\s*"type"\s*:\s*"image_url"\s*,\s*"image_url"\s*:\s*\{\s*"url"\s*:\s*"$/
|
||||
const JSON_IMAGE_CLOSE_RE = /^"\s*\}\s*\}/
|
||||
const JSON_IMAGE_OPEN_MAX = 96
|
||||
const JSON_IMAGE_CLOSE_MAX = 16
|
||||
|
||||
export const DATA_IMAGE_URL_RE = /^data:image\/[\w.+-]+;base64,/i
|
||||
|
||||
|
|
@ -31,24 +35,119 @@ export function dataUrlToBlob(dataUrl: string): Blob | null {
|
|||
}
|
||||
}
|
||||
|
||||
function isImageMimeCode(code: number): boolean {
|
||||
return (
|
||||
(code >= 48 && code <= 57) ||
|
||||
(code >= 65 && code <= 90) ||
|
||||
(code >= 97 && code <= 122) ||
|
||||
code === 43 ||
|
||||
code === 45 ||
|
||||
code === 46 ||
|
||||
code === 95
|
||||
)
|
||||
}
|
||||
|
||||
function isBase64Code(code: number): boolean {
|
||||
return (
|
||||
(code >= 48 && code <= 57) ||
|
||||
(code >= 65 && code <= 90) ||
|
||||
(code >= 97 && code <= 122) ||
|
||||
code === 43 ||
|
||||
code === 47 ||
|
||||
code === 61
|
||||
)
|
||||
}
|
||||
|
||||
function readDataImageUrl(text: string, start: number): { end: number; url: string } | null {
|
||||
if (!text.startsWith(DATA_IMAGE_PREFIX, start)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let cursor = start + DATA_IMAGE_PREFIX.length
|
||||
|
||||
while (cursor < text.length && isImageMimeCode(text.charCodeAt(cursor))) {
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
if (cursor === start + DATA_IMAGE_PREFIX.length || !text.startsWith(BASE64_MARKER, cursor)) {
|
||||
return null
|
||||
}
|
||||
|
||||
cursor += BASE64_MARKER.length
|
||||
const base64Start = cursor
|
||||
|
||||
while (cursor < text.length && isBase64Code(text.charCodeAt(cursor))) {
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
if (cursor - base64Start < MIN_EMBEDDED_IMAGE_BASE64_LENGTH) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { end: cursor, url: text.slice(start, cursor) }
|
||||
}
|
||||
|
||||
function embeddedImageRemovalRange(text: string, dataStart: number, dataEnd: number): { end: number; start: number } {
|
||||
let start = dataStart
|
||||
let end = dataEnd
|
||||
const openSearchStart = Math.max(0, dataStart - JSON_IMAGE_OPEN_MAX)
|
||||
const openMatch = text.slice(openSearchStart, dataStart).match(JSON_IMAGE_OPEN_RE)
|
||||
|
||||
if (openMatch?.index !== undefined) {
|
||||
const close = text.slice(dataEnd, dataEnd + JSON_IMAGE_CLOSE_MAX).match(JSON_IMAGE_CLOSE_RE)
|
||||
|
||||
if (close) {
|
||||
start = openSearchStart + openMatch.index
|
||||
end = dataEnd + close[0].length
|
||||
}
|
||||
}
|
||||
|
||||
return { end, start }
|
||||
}
|
||||
|
||||
function normalizeCleanedText(text: string): string {
|
||||
return text.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
export function extractEmbeddedImages(text: string): EmbeddedImageExtraction {
|
||||
if (!text || !text.includes('data:image/')) {
|
||||
if (!text || !text.includes(DATA_IMAGE_PREFIX)) {
|
||||
return { cleanedText: text, images: [] }
|
||||
}
|
||||
|
||||
const images: string[] = []
|
||||
const pieces: string[] = []
|
||||
let appendCursor = 0
|
||||
let searchCursor = 0
|
||||
|
||||
const cleanedText = text
|
||||
.replace(EMBEDDED_IMAGE_RE, (_match, _open, dataUrl: string) => {
|
||||
images.push(dataUrl)
|
||||
while (searchCursor < text.length) {
|
||||
const dataStart = text.indexOf(DATA_IMAGE_PREFIX, searchCursor)
|
||||
|
||||
return ''
|
||||
})
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
if (dataStart === -1) {
|
||||
break
|
||||
}
|
||||
|
||||
return { cleanedText, images }
|
||||
const dataUrl = readDataImageUrl(text, dataStart)
|
||||
|
||||
if (!dataUrl) {
|
||||
searchCursor = dataStart + DATA_IMAGE_PREFIX.length
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const range = embeddedImageRemovalRange(text, dataStart, dataUrl.end)
|
||||
pieces.push(text.slice(appendCursor, range.start))
|
||||
images.push(dataUrl.url)
|
||||
appendCursor = range.end
|
||||
searchCursor = range.end
|
||||
}
|
||||
|
||||
if (!images.length) {
|
||||
return { cleanedText: text, images: [] }
|
||||
}
|
||||
|
||||
pieces.push(text.slice(appendCursor))
|
||||
|
||||
return { cleanedText: normalizeCleanedText(pieces.join('')), images }
|
||||
}
|
||||
|
||||
export function embeddedImageUrls(text: string): string[] {
|
||||
|
|
|
|||
|
|
@ -49,18 +49,28 @@ export interface PopoutSize {
|
|||
width: number
|
||||
}
|
||||
|
||||
/** Viewport-space rect the floating composer is confined to. Defaults to the
|
||||
* whole window; pass the thread area so the box can't slide under a pinned
|
||||
* sidebar or behind the header. */
|
||||
export interface PopoutBounds {
|
||||
bottom: number
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
}
|
||||
|
||||
interface SetPositionOptions {
|
||||
/** Thread-area rect to confine the box to; falls back to the full window. */
|
||||
area?: PopoutBounds
|
||||
persist?: boolean
|
||||
/** Measured box size; falls back to the compact width + a min height so the
|
||||
* box stays grabbable even when the caller can't measure it. */
|
||||
size?: PopoutSize
|
||||
}
|
||||
|
||||
// Keep at least this much of every edge between the box and the viewport, so the
|
||||
// Keep at least this much between the box and every edge of its bounds, so the
|
||||
// floating composer can never be dragged (or restored) out of reach.
|
||||
const EDGE_MARGIN = 8
|
||||
const TITLEBAR_HEIGHT_FALLBACK = 34
|
||||
const TITLEBAR_CLEARANCE_REM = 0.75
|
||||
// Height floor used when the real box height is unknown (init / load / peel-off).
|
||||
export const POPOUT_ESTIMATED_HEIGHT = 56
|
||||
const MIN_VISIBLE_HEIGHT = POPOUT_ESTIMATED_HEIGHT
|
||||
|
|
@ -69,24 +79,34 @@ const clampRange = (value: number, lo: number, hi: number) => Math.min(Math.max(
|
|||
|
||||
const rootFontSize = () => parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
|
||||
|
||||
function titlebarTopMargin() {
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue('--titlebar-height').trim()
|
||||
const titlebarHeight = Number.parseFloat(raw)
|
||||
const breathingRoom = TITLEBAR_CLEARANCE_REM * rootFontSize()
|
||||
/** The thread area's viewport rect (excludes a pinned sidebar + the header), or
|
||||
* undefined before it mounts — callers then fall back to the full window. */
|
||||
export function readPopoutBounds(composer: Element | null): PopoutBounds | undefined {
|
||||
const el = (composer?.parentElement ?? document).querySelector('[data-slot="composer-bounds"]')
|
||||
|
||||
return Math.max(EDGE_MARGIN, (Number.isFinite(titlebarHeight) ? titlebarHeight : TITLEBAR_HEIGHT_FALLBACK) + breathingRoom)
|
||||
if (!el) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { bottom, height, left, right, top, width } = el.getBoundingClientRect()
|
||||
|
||||
// Pre-layout (mount before first layout) the rect is empty — fall back to the
|
||||
// window rather than clamping the box into a collapsed area.
|
||||
return width > 0 && height > 0 ? { bottom, left, right, top } : undefined
|
||||
}
|
||||
|
||||
// Bound the bottom-right inset so the WHOLE box stays on-screen — the corner
|
||||
// anchor alone would let the box's width/height push it past the left/top edges.
|
||||
function clampPosition({ bottom, right }: PopoutPosition, size?: PopoutSize): PopoutPosition {
|
||||
// Bound the bottom/right inset so the WHOLE box stays inside `area` (the thread
|
||||
// region, or the window by default) — the corner anchor alone would let the
|
||||
// box's width/height push it past the opposite edges.
|
||||
function clampPosition({ bottom, right }: PopoutPosition, size?: PopoutSize, area?: PopoutBounds): PopoutPosition {
|
||||
const width = size?.width || POPOUT_WIDTH_REM * rootFontSize()
|
||||
const height = size?.height || MIN_VISIBLE_HEIGHT
|
||||
const topMargin = titlebarTopMargin()
|
||||
const { innerHeight: vh, innerWidth: vw } = window
|
||||
const a = area ?? { bottom: vh, left: 0, right: vw, top: 0 }
|
||||
|
||||
return {
|
||||
bottom: clampRange(bottom, EDGE_MARGIN, window.innerHeight - height - topMargin),
|
||||
right: clampRange(right, EDGE_MARGIN, window.innerWidth - width - EDGE_MARGIN)
|
||||
bottom: clampRange(bottom, vh - a.bottom + EDGE_MARGIN, vh - a.top - height - EDGE_MARGIN),
|
||||
right: clampRange(right, vw - a.right + EDGE_MARGIN, vw - a.left - width - EDGE_MARGIN)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -102,8 +122,8 @@ export function setComposerPoppedOut(value: boolean) {
|
|||
* unless `persist`. Returns the clamped position so callers can sync their live
|
||||
* ref. Pass the measured `size` for exact bounds; otherwise a fallback keeps it
|
||||
* on-screen. */
|
||||
export function setComposerPopoutPosition(position: PopoutPosition, { persist, size }: SetPositionOptions = {}): PopoutPosition {
|
||||
const next = clampPosition(position, size)
|
||||
export function setComposerPopoutPosition(position: PopoutPosition, { area, persist, size }: SetPositionOptions = {}): PopoutPosition {
|
||||
const next = clampPosition(position, size, area)
|
||||
$composerPopoutPosition.set(next)
|
||||
|
||||
if (persist) {
|
||||
|
|
|
|||
|
|
@ -32,12 +32,14 @@ const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped'
|
|||
|
||||
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
|
||||
export const FILE_BROWSER_PANE_ID = 'file-browser'
|
||||
export const PREVIEW_PANE_ID = 'preview'
|
||||
export const RIGHT_RAIL_PREVIEW_TAB_ID = 'preview'
|
||||
|
||||
export type RightRailTabId = typeof RIGHT_RAIL_PREVIEW_TAB_ID | `file:${string}`
|
||||
|
||||
ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true })
|
||||
ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false })
|
||||
ensurePaneRegistered(PREVIEW_PANE_ID, { open: true })
|
||||
|
||||
export const $sidebarOpen: ReadableAtom<boolean> = computed(
|
||||
$paneStates,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ function persist(states: Record<string, PaneStateSnapshot>) {
|
|||
}
|
||||
|
||||
export const $paneStates = atom<Record<string, PaneStateSnapshot>>(load())
|
||||
export const $paneHoverRevealSuppressed = atom(false)
|
||||
|
||||
$paneStates.subscribe(persist)
|
||||
|
||||
|
|
@ -143,3 +144,4 @@ export function setPaneWidthOverride(id: string, width: number | undefined) {
|
|||
|
||||
export const clearPaneWidthOverride = (id: string) => setPaneWidthOverride(id, undefined)
|
||||
export const getPaneStateSnapshot = (id: string) => $paneStates.get()[id]
|
||||
export const setPaneHoverRevealSuppressed = (suppressed: boolean) => $paneHoverRevealSuppressed.set(suppressed)
|
||||
|
|
|
|||
41
apps/desktop/src/store/preview-status.test.ts
Normal file
41
apps/desktop/src/store/preview-status.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
$previewStatusBySession,
|
||||
clearPreviewArtifacts,
|
||||
dismissPreviewArtifact,
|
||||
recordPreviewArtifact
|
||||
} from './preview-status'
|
||||
|
||||
beforeEach(() => $previewStatusBySession.set({}))
|
||||
|
||||
describe('recordPreviewArtifact', () => {
|
||||
it('appends new targets newest-last and is idempotent', () => {
|
||||
recordPreviewArtifact('s1', '/a/index.html', '/work')
|
||||
recordPreviewArtifact('s1', '/a/about.html', '/work')
|
||||
recordPreviewArtifact('s1', '/a/index.html', '/work')
|
||||
|
||||
expect($previewStatusBySession.get().s1.map(i => i.id)).toEqual(['/a/index.html', '/a/about.html'])
|
||||
})
|
||||
|
||||
it('caps the list and derives a label', () => {
|
||||
for (const n of [1, 2, 3, 4, 5]) {
|
||||
recordPreviewArtifact('s1', `/a/p${n}.html`, '/work')
|
||||
}
|
||||
|
||||
const list = $previewStatusBySession.get().s1
|
||||
expect(list).toHaveLength(4)
|
||||
expect(list[0].id).toBe('/a/p2.html')
|
||||
expect(list[3].label).toBe('p5.html')
|
||||
})
|
||||
|
||||
it('dismiss and clear remove rows', () => {
|
||||
recordPreviewArtifact('s1', '/a/index.html', '/work')
|
||||
recordPreviewArtifact('s1', '/a/about.html', '/work')
|
||||
dismissPreviewArtifact('s1', '/a/index.html')
|
||||
expect($previewStatusBySession.get().s1.map(i => i.id)).toEqual(['/a/about.html'])
|
||||
|
||||
clearPreviewArtifacts('s1')
|
||||
expect($previewStatusBySession.get().s1).toBeUndefined()
|
||||
})
|
||||
})
|
||||
79
apps/desktop/src/store/preview-status.ts
Normal file
79
apps/desktop/src/store/preview-status.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import { previewName } from '@/lib/preview-targets'
|
||||
|
||||
/**
|
||||
* Session-scoped feed of previewable artifacts (HTML files, localhost dev URLs)
|
||||
* a tool produced. Surfaced as compact links in the composer status stack —
|
||||
* NOT auto-opened and NOT a bulky inline card. Click opens the rail preview or
|
||||
* the browser; both are manual.
|
||||
*
|
||||
* Fed from the tool row itself (see tool-fallback.tsx) using the same detected
|
||||
* target the inline card used, so detection parity is exact.
|
||||
*/
|
||||
export interface PreviewArtifact {
|
||||
/** cwd captured at detection so a relative path still resolves on click. */
|
||||
cwd: string
|
||||
/** Dedupe key + display id (the raw target). */
|
||||
id: string
|
||||
label: string
|
||||
target: string
|
||||
}
|
||||
|
||||
const MAX_PER_SESSION = 4
|
||||
|
||||
export const $previewStatusBySession = atom<Record<string, PreviewArtifact[]>>({})
|
||||
|
||||
const writePreviews = (sid: string, items: PreviewArtifact[]) => {
|
||||
const current = $previewStatusBySession.get()
|
||||
|
||||
if (items.length === 0) {
|
||||
if (!current[sid]) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = { ...current }
|
||||
delete next[sid]
|
||||
$previewStatusBySession.set(next)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
$previewStatusBySession.set({ ...current, [sid]: items })
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a detected artifact, newest last, capped. Idempotent: a target already
|
||||
* in the list keeps its slot (the tool row re-registers on every render, so this
|
||||
* must not churn the atom or reorder rows).
|
||||
*/
|
||||
export function recordPreviewArtifact(sid: string, target: string, cwd: string) {
|
||||
const raw = target.trim()
|
||||
|
||||
if (!sid || !raw) {
|
||||
return
|
||||
}
|
||||
|
||||
const list = $previewStatusBySession.get()[sid] ?? []
|
||||
|
||||
if (list.some(item => item.id === raw)) {
|
||||
return
|
||||
}
|
||||
|
||||
writePreviews(sid, [...list, { cwd, id: raw, label: previewName(raw), target: raw }].slice(-MAX_PER_SESSION))
|
||||
}
|
||||
|
||||
export function dismissPreviewArtifact(sid: string, id: string) {
|
||||
const list = $previewStatusBySession.get()[sid]
|
||||
|
||||
if (list) {
|
||||
writePreviews(
|
||||
sid,
|
||||
list.filter(item => item.id !== id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPreviewArtifacts(sid: string) {
|
||||
writePreviews(sid, [])
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout'
|
||||
import { $rightRailActiveTabId, PREVIEW_PANE_ID, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout'
|
||||
import { $paneOpen } from './panes'
|
||||
import {
|
||||
$filePreviewTabs,
|
||||
$filePreviewTarget,
|
||||
|
|
@ -69,12 +70,14 @@ describe('preview store', () => {
|
|||
setCurrentSessionPreviewTarget(target, 'tool-result')
|
||||
|
||||
expect($previewTarget.get()).toEqual(withRenderMode(target, 'preview'))
|
||||
expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(true)
|
||||
expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(target, 'preview'))
|
||||
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('/work/demo.html')
|
||||
|
||||
dismissPreviewTarget()
|
||||
|
||||
expect($previewTarget.get()).toBeNull()
|
||||
expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(false)
|
||||
expect(getSessionPreviewRecord('session-1')).toBeNull()
|
||||
expect($sessionPreviewRegistry.get()['session-1']?.[0]?.dismissedAt).toEqual(expect.any(Number))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID, type RightRailTabId, selectRightRailTab } from './layout'
|
||||
import {
|
||||
$rightRailActiveTabId,
|
||||
PREVIEW_PANE_ID,
|
||||
RIGHT_RAIL_PREVIEW_TAB_ID,
|
||||
type RightRailTabId,
|
||||
selectRightRailTab
|
||||
} from './layout'
|
||||
import { setPaneOpen } from './panes'
|
||||
import { $activeSessionId, $selectedStoredSessionId } from './session'
|
||||
|
||||
export interface PreviewTarget {
|
||||
|
|
@ -88,10 +95,15 @@ function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null):
|
|||
)
|
||||
}
|
||||
|
||||
function showLivePreviewTab() {
|
||||
setPaneOpen(PREVIEW_PANE_ID, true)
|
||||
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
}
|
||||
|
||||
export function setPreviewTarget(target: PreviewTarget | null) {
|
||||
if (isSamePreviewTarget($previewTarget.get(), target)) {
|
||||
if (target) {
|
||||
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
showLivePreviewTab()
|
||||
}
|
||||
|
||||
return
|
||||
|
|
@ -100,7 +112,7 @@ export function setPreviewTarget(target: PreviewTarget | null) {
|
|||
$previewTarget.set(target)
|
||||
|
||||
if (target) {
|
||||
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
showLivePreviewTab()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,6 +127,7 @@ function openFilePreviewTarget(target: PreviewTarget) {
|
|||
const tab: FilePreviewTab = { id, target }
|
||||
|
||||
$filePreviewTabs.set(index === -1 ? [...current, tab] : current.map((item, i) => (i === index ? tab : item)))
|
||||
setPaneOpen(PREVIEW_PANE_ID, true)
|
||||
selectRightRailTab(id)
|
||||
}
|
||||
|
||||
|
|
@ -372,6 +385,8 @@ export function dismissPreviewTarget() {
|
|||
if ($rightRailActiveTabId.get() === RIGHT_RAIL_PREVIEW_TAB_ID) {
|
||||
selectRightRailTab($filePreviewTabs.get()[0]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
}
|
||||
|
||||
setPaneOpen(PREVIEW_PANE_ID, $filePreviewTabs.get().length > 0)
|
||||
}
|
||||
|
||||
function closeFilePreviewTab(tabId: RightRailTabId) {
|
||||
|
|
@ -393,6 +408,10 @@ function closeFilePreviewTab(tabId: RightRailTabId) {
|
|||
if ($rightRailActiveTabId.get() === tabId) {
|
||||
selectRightRailTab(next[Math.min(index, next.length - 1)]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
}
|
||||
|
||||
if (next.length === 0 && !$previewTarget.get()) {
|
||||
setPaneOpen(PREVIEW_PANE_ID, false)
|
||||
}
|
||||
}
|
||||
|
||||
export function closeRightRailTab(tabId: RightRailTabId) {
|
||||
|
|
@ -416,12 +435,14 @@ export function closeRightRail() {
|
|||
}
|
||||
|
||||
$filePreviewTabs.set([])
|
||||
setPaneOpen(PREVIEW_PANE_ID, false)
|
||||
}
|
||||
|
||||
export function clearSessionPreviewRegistry() {
|
||||
$sessionPreviewRegistry.set({})
|
||||
setPreviewTarget(null)
|
||||
$filePreviewTabs.set([])
|
||||
setPaneOpen(PREVIEW_PANE_ID, false)
|
||||
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -264,7 +264,6 @@
|
|||
);
|
||||
--ui-chat-bubble-opaque-background: var(--ui-bg-editor);
|
||||
--ui-inline-code-background: color-mix(in srgb, #141414 5%, transparent);
|
||||
--ui-inline-code-border: color-mix(in srgb, #141414 8%, transparent);
|
||||
--ui-inline-code-foreground: color-mix(in srgb, #141414 88%, transparent);
|
||||
--ui-selection-background: color-mix(in srgb, #ffd24a 55%, transparent);
|
||||
|
||||
|
|
@ -408,7 +407,6 @@
|
|||
--backdrop-invert-mul: 0;
|
||||
|
||||
--ui-inline-code-background: color-mix(in srgb, #ffffff 7%, transparent);
|
||||
--ui-inline-code-border: color-mix(in srgb, #ffffff 10%, transparent);
|
||||
--ui-inline-code-foreground: color-mix(in srgb, #ffffff 88%, transparent);
|
||||
--ui-selection-background: color-mix(in srgb, #ffd24a 38%, transparent);
|
||||
}
|
||||
|
|
@ -1180,7 +1178,6 @@ canvas {
|
|||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md :not(pre) > code {
|
||||
border: 0.0625rem solid var(--ui-inline-code-border);
|
||||
background: var(--ui-inline-code-background);
|
||||
color: var(--ui-inline-code-foreground);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,13 @@ export interface OAuthPollResponse {
|
|||
status: 'approved' | 'denied' | 'error' | 'expired' | 'pending'
|
||||
}
|
||||
|
||||
export interface MemoryProviderOAuthStatus {
|
||||
auth: 'apikey' | 'oauth' | null
|
||||
connected: boolean
|
||||
detail: string
|
||||
state: 'connected' | 'error' | 'idle' | 'pending'
|
||||
}
|
||||
|
||||
export interface EnvVarInfo {
|
||||
advanced: boolean
|
||||
category: string
|
||||
|
|
@ -579,6 +586,51 @@ export interface ToolsetConfig {
|
|||
active_provider: string | null
|
||||
}
|
||||
|
||||
/** Shape of `GET /api/tools/computer-use/status`.
|
||||
*
|
||||
* cua-driver runs on macOS, Windows, and Linux. `ready` is the single OS-aware
|
||||
* readiness signal: on macOS both TCC grants (Accessibility + Screen
|
||||
* Recording, which attach to cua-driver's own `com.trycua.driver` identity,
|
||||
* not Hermes); elsewhere, driver health from `cua-driver doctor`. `null`
|
||||
* means unknown (binary missing / probe failed). */
|
||||
export interface ComputerUsePermissionSource {
|
||||
attribution?: string
|
||||
executable?: string
|
||||
note?: string
|
||||
pid?: number
|
||||
responsible_ppid?: number
|
||||
}
|
||||
|
||||
export interface ComputerUseCheck {
|
||||
label: string
|
||||
status: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ComputerUseStatus {
|
||||
/** `sys.platform`: "darwin" | "win32" | "linux" | ... */
|
||||
platform: string
|
||||
/** cua-driver has a runtime backend for this platform. */
|
||||
platform_supported: boolean
|
||||
/** cua-driver binary resolved on PATH. */
|
||||
installed: boolean
|
||||
/** e.g. "cua-driver 0.5.1", or null when unknown. */
|
||||
version: string | null
|
||||
/** Unified readiness — both TCC grants (macOS) or driver health (else). */
|
||||
ready: boolean | null
|
||||
/** Whether a permission grant flow exists (macOS-only TCC). */
|
||||
can_grant: boolean
|
||||
/** Cross-platform `cua-driver doctor` probes. */
|
||||
checks: ComputerUseCheck[]
|
||||
/** macOS TCC detail — `null` off macOS or when unknown. */
|
||||
accessibility: boolean | null
|
||||
screen_recording: boolean | null
|
||||
screen_recording_capturable: boolean | null
|
||||
source: ComputerUsePermissionSource | null
|
||||
/** Populated when the status probe itself failed. */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface SessionSearchResult {
|
||||
/** Lineage root of the matched conversation. Stable across compression and
|
||||
* used as the durable pin id; falls back to session_id when absent. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue