mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 08:42:11 +00:00
feat: better composer etc
This commit is contained in:
parent
42db075e10
commit
fcce49db3f
50 changed files with 1299 additions and 668 deletions
|
|
@ -972,11 +972,7 @@ function installDevToolsShortcut(window) {
|
|||
function installPreviewShortcut(window) {
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
const key = String(input.key || '').toLowerCase()
|
||||
const isPreviewCloseShortcut =
|
||||
key === 'w' &&
|
||||
(IS_MAC ? input.meta : input.control) &&
|
||||
!input.alt &&
|
||||
!input.shift
|
||||
const isPreviewCloseShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
|
||||
|
||||
if (!isPreviewCloseShortcut || !previewShortcutActive) return
|
||||
|
||||
|
|
@ -1313,14 +1309,18 @@ function findGitRoot(start) {
|
|||
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
try {
|
||||
if (fs.existsSync(path.join(dir, '.git'))) {return dir}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) {
|
||||
return dir
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir)
|
||||
|
||||
if (parent === dir) {return null}
|
||||
if (parent === dir) {
|
||||
return null
|
||||
}
|
||||
|
||||
dir = parent
|
||||
}
|
||||
|
|
@ -1337,11 +1337,15 @@ function getGitignoreFile(giPath) {
|
|||
return null
|
||||
}
|
||||
|
||||
if (!stat.isFile()) {return null}
|
||||
if (!stat.isFile()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cached = gitignoreCache.get(giPath)
|
||||
|
||||
if (cached && cached.mtime === stat.mtimeMs) {return cached}
|
||||
if (cached && cached.mtime === stat.mtimeMs) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = {
|
||||
|
|
@ -1376,7 +1380,9 @@ function gitignoreRulesFor(root, dir) {
|
|||
for (const ruleDir of dirs) {
|
||||
const rule = getGitignoreFile(path.join(ruleDir, '.gitignore'))
|
||||
|
||||
if (rule) {rules.push(rule)}
|
||||
if (rule) {
|
||||
rules.push(rule)
|
||||
}
|
||||
}
|
||||
|
||||
return rules
|
||||
|
|
@ -1386,11 +1392,15 @@ function ignoredByRules(rules, abs, isDirectory) {
|
|||
for (const rule of rules) {
|
||||
const rel = path.relative(rule.base, abs)
|
||||
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {continue}
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const probe = `${rel.split(path.sep).join('/')}${isDirectory ? '/' : ''}`
|
||||
|
||||
if (rule.ig.ignores(probe)) {return true}
|
||||
if (rule.ig.ignores(probe)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
|
|
@ -1410,12 +1420,16 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => {
|
|||
|
||||
const entries = dirents
|
||||
.filter(d => {
|
||||
if (FS_READDIR_HIDDEN.has(d.name)) {return false}
|
||||
if (FS_READDIR_HIDDEN.has(d.name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (gitignoreRules.length > 0) {
|
||||
const abs = path.join(resolved, d.name)
|
||||
|
||||
if (ignoredByRules(gitignoreRules, abs, d.isDirectory())) {return false}
|
||||
if (ignoredByRules(gitignoreRules, abs, d.isDirectory())) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "SF Pro Text", sans-serif;
|
||||
background: radial-gradient(1200px 600px at 20% 10%, #3a2a1a 0%, #1a1410 40%, #0c0a08 100%);
|
||||
color: #f5e9d7;
|
||||
background: radial-gradient(1200px 600px at 20% 10%, #4a1a33 0%, #2a1020 40%, #120810 100%);
|
||||
color: #ffe4f1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 2rem;
|
||||
|
|
@ -18,9 +18,9 @@
|
|||
.card {
|
||||
max-width: 520px;
|
||||
padding: 2rem 2.25rem;
|
||||
border: 1px solid rgba(245,233,215,0.15);
|
||||
border: 1px solid rgba(255,182,214,0.18);
|
||||
border-radius: 14px;
|
||||
background: rgba(20,16,12,0.6);
|
||||
background: rgba(28,14,22,0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
|
@ -32,8 +32,8 @@
|
|||
p { margin: 0.35rem 0; opacity: 0.85; line-height: 1.5; }
|
||||
.dot {
|
||||
display: inline-block; width: 10px; height: 10px; border-radius: 50%;
|
||||
background: #f4a93c; margin-right: 0.5rem;
|
||||
box-shadow: 0 0 12px #f4a93c;
|
||||
background: #ff6fb5; margin-right: 0.5rem;
|
||||
box-shadow: 0 0 12px #ff6fb5;
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
50% { transform: scale(1.4); opacity: 0.6; }
|
||||
}
|
||||
code {
|
||||
background: rgba(245,233,215,0.08);
|
||||
background: rgba(255,182,214,0.10);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
|
|
|
|||
|
|
@ -103,7 +103,11 @@ function ActivityList({ tasks }: { tasks: readonly RailTask[] }) {
|
|||
return (
|
||||
<OverlayCard className="flex items-start gap-2.5 px-3 py-2" key={task.id}>
|
||||
<Icon
|
||||
className={cn('mt-0.5 size-3.5 shrink-0', STATUS_TONE[task.status], task.status === 'running' && 'animate-spin')}
|
||||
className={cn(
|
||||
'mt-0.5 size-3.5 shrink-0',
|
||||
STATUS_TONE[task.status],
|
||||
task.status === 'running' && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{task.label}</div>
|
||||
|
|
@ -124,9 +128,11 @@ function SectionStub({ label }: { label: string }) {
|
|||
<p className="text-sm font-medium text-foreground">{label} — coming soon</p>
|
||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground">
|
||||
Subagent stores aren't wired into the desktop yet. Once gateway events for{' '}
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">subagent.spawn / progress / complete</code>{' '}
|
||||
land here, this view shows the live spawn tree, replay history, and pause/kill controls — modelled on the TUI's{' '}
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">/agents</code> overlay.
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">
|
||||
subagent.spawn / progress / complete
|
||||
</code>{' '}
|
||||
land here, this view shows the live spawn tree, replay history, and pause/kill controls — modelled on the
|
||||
TUI's <code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">/agents</code> overlay.
|
||||
</p>
|
||||
</div>
|
||||
</OverlayCard>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,13 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||
return
|
||||
}
|
||||
|
||||
const rawTarget = attachment.path || attachment.detail || attachment.refText?.replace(/^@(file|image|url):/, '') || attachment.label || ''
|
||||
const rawTarget =
|
||||
attachment.path ||
|
||||
attachment.detail ||
|
||||
attachment.refText?.replace(/^@(file|image|url):/, '') ||
|
||||
attachment.label ||
|
||||
''
|
||||
|
||||
const target = rawTarget.replace(/^`|`$/g, '')
|
||||
|
||||
if (!target) {
|
||||
|
|
@ -55,7 +61,10 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="group/attachment relative min-w-0 shrink-0" title={attachment.path || attachment.detail || attachment.label}>
|
||||
<div
|
||||
className="group/attachment relative min-w-0 shrink-0"
|
||||
title={attachment.path || attachment.detail || attachment.label}
|
||||
>
|
||||
<button
|
||||
aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label}
|
||||
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
|
||||
|
|
@ -77,8 +86,12 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">{attachment.label}</span>
|
||||
{detail && <span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">{detail}</span>}
|
||||
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
|
||||
{attachment.label}
|
||||
</span>
|
||||
{detail && (
|
||||
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">{detail}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{onRemove && (
|
||||
|
|
|
|||
|
|
@ -38,14 +38,7 @@ import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks'
|
|||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||
import {
|
||||
composerHtml,
|
||||
composerPlainText,
|
||||
escapeHtml,
|
||||
placeCaretEnd,
|
||||
refChipHtml,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
import { composerHtml, composerPlainText, escapeHtml, placeCaretEnd, refChipHtml, RICH_INPUT_SLOT } from './rich-editor'
|
||||
import { SkinSlashPopover } from './skin-slash-popover'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
|
|
@ -112,7 +105,10 @@ function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
|
|||
}
|
||||
|
||||
// Below this composer width the input gets cramped — drop controls onto a second row.
|
||||
const COMPOSER_STACK_BREAKPOINT_PX = 380
|
||||
// Floor matches the natural min-content of contextMenu + 8rem input + controls + gaps;
|
||||
// going higher caused unwanted stacking on empty state when the parent transiently
|
||||
// reported a tiny width before layout settled.
|
||||
const COMPOSER_STACK_BREAKPOINT_PX = 320
|
||||
|
||||
const COMPOSER_SCROLLED_DIM_CLASS =
|
||||
'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
|
|
@ -142,7 +138,9 @@ function textBeforeCaret(editor: HTMLDivElement): string | null {
|
|||
const sel = window.getSelection()
|
||||
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
|
||||
|
||||
if (!range?.collapsed || !editor.contains(range.commonAncestorContainer)) {return null}
|
||||
if (!range?.collapsed || !editor.contains(range.commonAncestorContainer)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const before = range.cloneRange()
|
||||
before.selectNodeContents(editor)
|
||||
|
|
@ -154,7 +152,9 @@ function textBeforeCaret(editor: HTMLDivElement): string | null {
|
|||
function detectTrigger(textBefore: string): TriggerState | null {
|
||||
const match = TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (!match) {return null}
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
|
||||
}
|
||||
|
|
@ -202,20 +202,6 @@ export function ChatBar({
|
|||
|
||||
const narrow = useMediaQuery('(max-width: 480px)')
|
||||
|
||||
const [askPlaceholder] = useState(() => {
|
||||
const lines = [
|
||||
'Hey friend, what can I help with?',
|
||||
"What's on your mind? I'm here with you.",
|
||||
'Need a hand? We can take it one step at a time.',
|
||||
'Want to walk through this bug together?',
|
||||
"Share what you're working on and we'll figure it out.",
|
||||
"Tell me where you're stuck and I'll stay with you.",
|
||||
'Duck mode: gentle debugging, together.'
|
||||
]
|
||||
|
||||
return lines[Math.floor(Math.random() * lines.length)] ?? 'Ask anything'
|
||||
})
|
||||
|
||||
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
|
||||
const slash = useSlashCompletions({ gateway: gateway ?? null })
|
||||
|
||||
|
|
@ -224,13 +210,7 @@ export function ChatBar({
|
|||
const canSubmit = busy || hasComposerPayload
|
||||
const showHelpHint = draft === '?'
|
||||
|
||||
const placeholder = disabled
|
||||
? stacked
|
||||
? 'Starting...'
|
||||
: 'Starting Hermes...'
|
||||
: stacked
|
||||
? 'Ask anything'
|
||||
: askPlaceholder
|
||||
const placeholder = disabled ? 'Starting Hermes…' : 'Ask anything'
|
||||
|
||||
const glassTweaks = useComposerGlassTweaks()
|
||||
|
||||
|
|
@ -280,7 +260,9 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
const wraps = (editorRef.current?.scrollHeight ?? 0) > 42
|
||||
// Threshold deliberately above a single rendered line + padding so font-metric
|
||||
// jitter on an empty/short editor never triggers spurious expansion.
|
||||
const wraps = (editorRef.current?.scrollHeight ?? 0) > 56
|
||||
|
||||
if (draft.includes('\n') || wraps) {
|
||||
setExpanded(true)
|
||||
|
|
@ -294,10 +276,18 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
const update = () => setTight(el.getBoundingClientRect().width < COMPOSER_STACK_BREAKPOINT_PX)
|
||||
// No sync read: getBoundingClientRect() right after mount can return a
|
||||
// transient pre-layout width that briefly flips the composer into stacked
|
||||
// mode. ResizeObserver fires once on observe() with the settled width, then
|
||||
// again on every actual size change.
|
||||
const ro = new ResizeObserver(() => {
|
||||
const width = el.getBoundingClientRect().width
|
||||
|
||||
if (width > 0) {
|
||||
setTight(width < COMPOSER_STACK_BREAKPOINT_PX)
|
||||
}
|
||||
})
|
||||
|
||||
update()
|
||||
const ro = new ResizeObserver(update)
|
||||
ro.observe(el)
|
||||
|
||||
return () => ro.disconnect()
|
||||
|
|
@ -402,9 +392,17 @@ export function ChatBar({
|
|||
return null
|
||||
}
|
||||
|
||||
const kind = candidate.isDirectory ? 'folder' : 'file'
|
||||
const rel = contextPath(candidate.path, cwd || '')
|
||||
|
||||
if (candidate.line) {
|
||||
const { line, lineEnd } = candidate
|
||||
const range = lineEnd && lineEnd > line ? `${line}-${lineEnd}` : `${line}`
|
||||
|
||||
return `@line:${formatRefValue(`${rel}:${range}`)}`
|
||||
}
|
||||
|
||||
const kind = candidate.isDirectory ? 'folder' : 'file'
|
||||
|
||||
return `@${kind}:${formatRefValue(rel)}`
|
||||
}
|
||||
|
||||
|
|
@ -463,7 +461,9 @@ export function ChatBar({
|
|||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {return}
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const detected = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
|
@ -491,7 +491,8 @@ export function ChatBar({
|
|||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null =
|
||||
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger || !triggerAdapter?.search) {
|
||||
|
|
@ -512,95 +513,71 @@ export function ChatBar({
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerItems.length) {
|
||||
setTriggerActive(0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (triggerActive >= triggerItems.length) {
|
||||
setTriggerActive(triggerItems.length - 1)
|
||||
}
|
||||
}, [triggerActive, triggerItems.length])
|
||||
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
|
||||
}, [triggerItems.length])
|
||||
|
||||
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
|
||||
const editor = editorRef.current
|
||||
const sel = window.getSelection()
|
||||
|
||||
if (!editor || !trigger) {
|
||||
return
|
||||
}
|
||||
|
||||
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||
// Starters (`@file:`) drill in: insert verbatim and keep the popover live so
|
||||
// the user can keep typing the path. Chips/simple refs commit and close.
|
||||
const starter = serialized.endsWith(':')
|
||||
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
||||
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
||||
|
||||
const replaceDraftFallback = () => {
|
||||
const finish = () => {
|
||||
draftRef.current = composerPlainText(editor)
|
||||
aui.composer().setText(draftRef.current)
|
||||
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
|
||||
}
|
||||
|
||||
const sel = window.getSelection()
|
||||
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
|
||||
const node = range?.startContainer
|
||||
const offset = range?.startOffset ?? 0
|
||||
|
||||
// No usable caret range — replace from the end of the draft instead.
|
||||
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
|
||||
const current = composerPlainText(editor)
|
||||
|
||||
const nextDraft = `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${serialized}${
|
||||
serialized.endsWith(' ') ? '' : ' '
|
||||
}`
|
||||
|
||||
editor.innerHTML = composerHtml(nextDraft)
|
||||
editor.innerHTML = composerHtml(`${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
|
||||
placeCaretEnd(editor)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
closeTrigger()
|
||||
}
|
||||
|
||||
if (!sel?.rangeCount) {
|
||||
replaceDraftFallback()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const range = sel.getRangeAt(0)
|
||||
const startNode = range.startContainer
|
||||
const startOffset = range.startOffset
|
||||
|
||||
if (startNode.nodeType !== Node.TEXT_NODE || startOffset < trigger.tokenLength) {
|
||||
replaceDraftFallback()
|
||||
|
||||
return
|
||||
return finish()
|
||||
}
|
||||
|
||||
const replaceRange = document.createRange()
|
||||
replaceRange.setStart(startNode, startOffset - trigger.tokenLength)
|
||||
replaceRange.setEnd(startNode, startOffset)
|
||||
replaceRange.setStart(node, offset - trigger.tokenLength)
|
||||
replaceRange.setEnd(node, offset)
|
||||
replaceRange.deleteContents()
|
||||
|
||||
const fragment = document.createDocumentFragment()
|
||||
const directiveMatch = serialized.match(/^@([^:]+):(.+)$/)
|
||||
|
||||
if (directiveMatch) {
|
||||
if (directive) {
|
||||
const holder = document.createElement('span')
|
||||
holder.innerHTML = refChipHtml(directiveMatch[1], directiveMatch[2])
|
||||
const chipNode = holder.firstChild
|
||||
holder.innerHTML = refChipHtml(directive[1], directive[2])
|
||||
const chip = holder.firstChild
|
||||
|
||||
if (chipNode) {
|
||||
fragment.appendChild(chipNode)
|
||||
if (chip) {
|
||||
const space = document.createTextNode(' ')
|
||||
fragment.appendChild(space)
|
||||
|
||||
replaceRange.deleteContents()
|
||||
const fragment = document.createDocumentFragment()
|
||||
fragment.append(chip, space)
|
||||
replaceRange.insertNode(fragment)
|
||||
|
||||
const after = document.createRange()
|
||||
after.setStart(space, 1)
|
||||
after.collapse(true)
|
||||
const caret = document.createRange()
|
||||
caret.setStart(space, 1)
|
||||
caret.collapse(true)
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(after)
|
||||
} else {
|
||||
replaceRange.deleteContents()
|
||||
document.execCommand('insertText', false, `${serialized} `)
|
||||
sel.addRange(caret)
|
||||
|
||||
return finish()
|
||||
}
|
||||
} else {
|
||||
replaceRange.deleteContents()
|
||||
document.execCommand('insertText', false, serialized.endsWith(' ') ? serialized : `${serialized} `)
|
||||
}
|
||||
|
||||
const nextDraft = composerPlainText(editor)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
closeTrigger()
|
||||
document.execCommand('insertText', false, text)
|
||||
finish()
|
||||
}
|
||||
|
||||
const handleEditorKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
|
|
@ -907,21 +884,12 @@ export function ChatBar({
|
|||
|
||||
const input = (
|
||||
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
||||
{!draft && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 pb-1 pr-1 pt-1 leading-normal text-muted-foreground/80',
|
||||
stacked && 'pl-3'
|
||||
)}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
aria-label="Message"
|
||||
className={cn(
|
||||
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none empty:before:content-[attr(data-placeholder)] disabled:cursor-not-allowed **:data-ref-text:cursor-default',
|
||||
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
|
||||
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
||||
'**:data-ref-text:cursor-default',
|
||||
stacked && 'pl-3',
|
||||
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@
|
|||
* fence — without that, typing after a chip would get re-absorbed on the next
|
||||
* plain-text round-trip.
|
||||
*/
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { DIRECTIVE_CHIP_CLASS, directiveIconSvg, formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
|
||||
export const REF_RE = /@(file|folder|url|image|tool):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||
export const REF_RE = /@(file|folder|url|image|tool|line):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||
|
||||
const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
|
||||
|
|
@ -32,11 +32,17 @@ export function refLabel(id: string) {
|
|||
|
||||
/** Always-quote variant of formatRefValue — chips need a fence even for safe values. */
|
||||
export function quoteRefValue(value: string) {
|
||||
if (!value.includes('`')) {return `\`${value}\``}
|
||||
if (!value.includes('`')) {
|
||||
return `\`${value}\``
|
||||
}
|
||||
|
||||
if (!value.includes('"')) {return `"${value}"`}
|
||||
if (!value.includes('"')) {
|
||||
return `"${value}"`
|
||||
}
|
||||
|
||||
if (!value.includes("'")) {return `'${value}'`}
|
||||
if (!value.includes("'")) {
|
||||
return `'${value}'`
|
||||
}
|
||||
|
||||
return formatRefValue(value)
|
||||
}
|
||||
|
|
@ -45,7 +51,7 @@ export function refChipHtml(kind: string, rawValue: string) {
|
|||
const id = unquoteRef(rawValue)
|
||||
const text = `@${kind}:${quoteRefValue(id)}`
|
||||
|
||||
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${kind}" class="mx-0.5 inline-flex max-w-56 items-center gap-1 border border-primary/20 bg-primary/8 px-1.5 py-0.5 align-[0.02em] text-[0.86em] font-medium leading-none text-primary"><span class="truncate">${escapeHtml(refLabel(id))}</span></span>`
|
||||
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${kind}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(refLabel(id))}</span></span>`
|
||||
}
|
||||
|
||||
/** Serialize a draft string into chip-HTML for the contenteditable surface. */
|
||||
|
|
@ -67,15 +73,23 @@ export function composerHtml(text: string) {
|
|||
|
||||
/** Walk a DOM subtree back to the plain `@kind:value` text it represents. */
|
||||
export function composerPlainText(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {return node.textContent || ''}
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent || ''
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {return ''}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const el = node as HTMLElement
|
||||
|
||||
if (el.dataset.refText) {return el.dataset.refText}
|
||||
if (el.dataset.refText) {
|
||||
return el.dataset.refText
|
||||
}
|
||||
|
||||
if (el.tagName === 'BR') {return '\n'}
|
||||
if (el.tagName === 'BR') {
|
||||
return '\n'
|
||||
}
|
||||
|
||||
const text = Array.from(node.childNodes).map(composerPlainText).join('')
|
||||
const block = el.tagName === 'DIV' || el.tagName === 'P'
|
||||
|
|
|
|||
|
|
@ -13,7 +13,14 @@ interface ComposerTriggerPopoverProps {
|
|||
onPick: (item: Unstable_TriggerItem) => void
|
||||
}
|
||||
|
||||
export function ComposerTriggerPopover({ activeIndex, items, kind, loading, onHover, onPick }: ComposerTriggerPopoverProps) {
|
||||
export function ComposerTriggerPopover({
|
||||
activeIndex,
|
||||
items,
|
||||
kind,
|
||||
loading,
|
||||
onHover,
|
||||
onPick
|
||||
}: ComposerTriggerPopoverProps) {
|
||||
return (
|
||||
<div
|
||||
className={COMPLETION_DRAWER_CLASS}
|
||||
|
|
@ -54,7 +61,9 @@ export function ComposerTriggerPopover({ activeIndex, items, kind, loading, onHo
|
|||
type="button"
|
||||
>
|
||||
<span className="shrink-0 truncate font-mono font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && <span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>}
|
||||
{description && (
|
||||
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -37,10 +37,14 @@ export interface DroppedFile {
|
|||
path: string
|
||||
/** True if the entry is a directory. Currently only set by in-app drags. */
|
||||
isDirectory?: boolean
|
||||
/** First line number for in-app line-ref drags (source view gutter). */
|
||||
line?: number
|
||||
/** Last line number for line-range drags (`line..lineEnd` inclusive). */
|
||||
lineEnd?: number
|
||||
}
|
||||
|
||||
/** MIME emitted by in-app drag sources (project tree, etc.). Payload is JSON
|
||||
* `{ path: string; isDirectory?: boolean }[]`. */
|
||||
/** MIME emitted by in-app drag sources (project tree, gutter line numbers).
|
||||
* Payload is JSON `{ path; isDirectory?; line?; lineEnd? }[]`. */
|
||||
export const HERMES_PATHS_MIME = 'application/x-hermes-paths'
|
||||
|
||||
/**
|
||||
|
|
@ -64,15 +68,31 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
|||
const internalRaw = transfer.getData(HERMES_PATHS_MIME)
|
||||
|
||||
if (internalRaw) {
|
||||
const parsed = JSON.parse(internalRaw) as { path?: unknown; isDirectory?: unknown }[]
|
||||
const parsed = JSON.parse(internalRaw) as {
|
||||
path?: unknown
|
||||
isDirectory?: unknown
|
||||
line?: unknown
|
||||
lineEnd?: unknown
|
||||
}[]
|
||||
|
||||
const positiveInt = (value: unknown) => (typeof value === 'number' && value > 0 ? Math.floor(value) : undefined)
|
||||
|
||||
for (const entry of parsed) {
|
||||
if (!entry || typeof entry.path !== 'string' || !entry.path || seenPaths.has(entry.path)) {
|
||||
if (!entry || typeof entry.path !== 'string' || !entry.path) {
|
||||
continue
|
||||
}
|
||||
|
||||
seenPaths.add(entry.path)
|
||||
result.push({ isDirectory: entry.isDirectory === true, path: entry.path })
|
||||
const line = positiveInt(entry.line)
|
||||
const rawEnd = positiveInt(entry.lineEnd)
|
||||
const lineEnd = line && rawEnd && rawEnd > line ? rawEnd : undefined
|
||||
const dedupKey = line ? `${entry.path}:${line}-${lineEnd ?? line}` : entry.path
|
||||
|
||||
if (seenPaths.has(dedupKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seenPaths.add(dedupKey)
|
||||
result.push({ isDirectory: entry.isDirectory === true, line, lineEnd, path: entry.path })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -335,7 +355,9 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||
|
||||
const attachContextFolderPath = useCallback(
|
||||
(folderPath: string) => {
|
||||
if (!folderPath) {return false}
|
||||
if (!folderPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const rel = contextPath(folderPath, currentCwd)
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export { ChatPreviewRail, PREVIEW_RAIL_PANE_WIDTH } from './preview'
|
||||
export { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH, PREVIEW_RAIL_PANE_WIDTH } from './preview'
|
||||
|
|
|
|||
|
|
@ -50,7 +50,9 @@ export function createPreviewConsoleState() {
|
|||
$selectedLogIds.set(new Set())
|
||||
},
|
||||
clearSelection() {
|
||||
if ($selectedLogIds.get().size === 0) {return}
|
||||
if ($selectedLogIds.get().size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
$selectedLogIds.set(new Set())
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import type {
|
|||
ComponentProps,
|
||||
CSSProperties,
|
||||
MutableRefObject,
|
||||
DragEvent as ReactDragEvent,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
RefObject
|
||||
|
|
@ -11,6 +13,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
import ShikiHighlighter from 'react-shiki'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { Bug, PanelBottom, RefreshCw, Send, Trash2, X } from '@/lib/icons'
|
||||
|
|
@ -21,6 +24,8 @@ import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget } f
|
|||
|
||||
import { type ConsoleEntry, createPreviewConsoleState, type PreviewConsoleState } from './preview-console-state'
|
||||
|
||||
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
|
||||
|
||||
type PreviewWebview = HTMLElement & {
|
||||
closeDevTools?: () => void
|
||||
getURL?: () => string
|
||||
|
|
@ -36,7 +41,6 @@ interface PreviewPaneProps {
|
|||
reloadRequest?: number
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
target: PreviewTarget
|
||||
titlebarToolGroupId?: string
|
||||
}
|
||||
|
||||
interface PreviewLoadErrorState {
|
||||
|
|
@ -194,9 +198,23 @@ function PreviewConsoleTitlebarIcon({ consoleState }: { consoleState: PreviewCon
|
|||
)
|
||||
}
|
||||
|
||||
function PreviewCubeIcon() {
|
||||
type EmptyStateTone = 'neutral' | 'warning'
|
||||
|
||||
const TONE_STYLES: Record<EmptyStateTone, { cube: string; primary: string }> = {
|
||||
neutral: {
|
||||
cube: 'text-muted-foreground/35',
|
||||
primary: 'border-border bg-background text-foreground hover:bg-accent'
|
||||
},
|
||||
warning: {
|
||||
cube: 'text-amber-500/70 dark:text-amber-300/70',
|
||||
primary:
|
||||
'border-amber-400/40 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-300/30 dark:bg-amber-300/15 dark:text-amber-100 dark:hover:bg-amber-300/20'
|
||||
}
|
||||
}
|
||||
|
||||
function PreviewCubeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg aria-hidden="true" className="size-16 text-muted-foreground/35" viewBox="0 0 64 64">
|
||||
<svg aria-hidden="true" className={cn('size-16', className)} viewBox="0 0 64 64">
|
||||
<path
|
||||
d="M32 5 56 18.5v27L32 59 8 45.5v-27L32 5Z"
|
||||
fill="none"
|
||||
|
|
@ -222,25 +240,38 @@ interface PreviewEmptyStateProps {
|
|||
primaryAction?: { disabled?: boolean; label: string; onClick: () => void }
|
||||
secondaryAction?: { disabled?: boolean; label: string; onClick: () => void }
|
||||
title: string
|
||||
tone?: EmptyStateTone
|
||||
}
|
||||
|
||||
function PreviewEmptyState({ body, consoleHeight = 0, primaryAction, secondaryAction, title }: PreviewEmptyStateProps) {
|
||||
function PreviewEmptyState({
|
||||
body,
|
||||
consoleHeight = 0,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
title,
|
||||
tone = 'neutral'
|
||||
}: PreviewEmptyStateProps) {
|
||||
const styles = TONE_STYLES[tone]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 z-10 grid place-items-center bg-background px-6 text-center bottom-(--preview-error-bottom)"
|
||||
className="absolute inset-x-0 top-0 z-10 grid place-items-center bg-background px-8 py-10 text-center bottom-(--preview-error-bottom)"
|
||||
style={{ '--preview-error-bottom': `${consoleHeight}px` } as CSSProperties}
|
||||
>
|
||||
<div className="grid max-w-72 justify-items-center gap-4">
|
||||
<PreviewCubeIcon />
|
||||
<div className="grid gap-1.5">
|
||||
<div className="grid max-w-sm justify-items-center gap-5">
|
||||
<PreviewCubeIcon className={styles.cube} />
|
||||
<div className="grid gap-2">
|
||||
<div className="text-sm font-medium text-foreground">{title}</div>
|
||||
{body}
|
||||
{body && <div className="text-xs leading-relaxed text-muted-foreground">{body}</div>}
|
||||
</div>
|
||||
{(primaryAction || secondaryAction) && (
|
||||
<div className="grid justify-items-center gap-2">
|
||||
{primaryAction && (
|
||||
<button
|
||||
className="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground shadow-xs transition-colors hover:bg-accent disabled:cursor-default disabled:opacity-60"
|
||||
className={cn(
|
||||
'rounded-full border px-3.5 py-1.5 text-xs font-medium shadow-xs transition-colors disabled:cursor-default disabled:opacity-60',
|
||||
styles.primary
|
||||
)}
|
||||
disabled={primaryAction.disabled}
|
||||
onClick={primaryAction.onClick}
|
||||
type="button"
|
||||
|
|
@ -282,20 +313,18 @@ function PreviewLoadError({
|
|||
<PreviewEmptyState
|
||||
body={
|
||||
<>
|
||||
<div className="text-xs leading-5 text-muted-foreground">
|
||||
<a
|
||||
className="pointer-events-auto cursor-pointer font-mono text-muted-foreground/90 underline decoration-muted-foreground/30 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/70"
|
||||
href={error.url}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
void window.hermesDesktop?.openExternal(error.url)
|
||||
}}
|
||||
>
|
||||
{compactUrl(error.url)}
|
||||
</a>
|
||||
<a
|
||||
className="pointer-events-auto block cursor-pointer font-mono text-muted-foreground/90 underline decoration-muted-foreground/30 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/70"
|
||||
href={error.url}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
void window.hermesDesktop?.openExternal(error.url)
|
||||
}}
|
||||
>
|
||||
{compactUrl(error.url)}
|
||||
{error.code ? ` (${error.code})` : ''}
|
||||
</div>
|
||||
<div className="text-[0.6875rem] leading-5 text-muted-foreground/70">{error.description}</div>
|
||||
</a>
|
||||
<div className="mt-1 text-[0.6875rem] text-muted-foreground/70">{error.description}</div>
|
||||
</>
|
||||
}
|
||||
consoleHeight={consoleHeight}
|
||||
|
|
@ -541,98 +570,204 @@ async function readTextPreview(filePath: string) {
|
|||
}
|
||||
}
|
||||
|
||||
function MarkdownPreview({ text }: { text: string }) {
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
h1: ({ className, ...props }: ComponentProps<'h1'>) => (
|
||||
<h1 className={cn('mb-3 mt-6 text-3xl font-bold leading-tight tracking-tight first:mt-0', className)} {...props} />
|
||||
),
|
||||
h2: ({ className, ...props }: ComponentProps<'h2'>) => (
|
||||
<h2 className={cn('mb-2.5 mt-5 text-2xl font-semibold leading-snug tracking-tight first:mt-0', className)} {...props} />
|
||||
),
|
||||
h3: ({ className, ...props }: ComponentProps<'h3'>) => (
|
||||
<h3 className={cn('mb-2 mt-4 text-xl font-semibold leading-snug first:mt-0', className)} {...props} />
|
||||
),
|
||||
h4: ({ className, ...props }: ComponentProps<'h4'>) => (
|
||||
<h4 className={cn('mb-2 mt-3 text-base font-semibold leading-snug first:mt-0', className)} {...props} />
|
||||
),
|
||||
p: ({ className, ...props }: ComponentProps<'p'>) => (
|
||||
<p className={cn('mb-4 leading-relaxed text-foreground last:mb-0', className)} {...props} />
|
||||
),
|
||||
ul: ({ className, ...props }: ComponentProps<'ul'>) => (
|
||||
<ul className={cn('mb-4 list-disc pl-6 marker:text-muted-foreground/70 last:mb-0', className)} {...props} />
|
||||
),
|
||||
ol: ({ className, ...props }: ComponentProps<'ol'>) => (
|
||||
<ol className={cn('mb-4 list-decimal pl-6 marker:text-muted-foreground/70 last:mb-0', className)} {...props} />
|
||||
),
|
||||
li: ({ className, ...props }: ComponentProps<'li'>) => <li className={cn('mt-1 leading-relaxed', className)} {...props} />,
|
||||
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
|
||||
<blockquote
|
||||
className={cn('mb-4 border-l-2 border-border pl-3 text-muted-foreground italic last:mb-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
code: ({ className, children, ...props }: ComponentProps<'code'>) => {
|
||||
const language = /language-([^\s]+)/.exec(className || '')?.[1]
|
||||
// Lightweight markdown renderer for file previews. Streamdown does the parse;
|
||||
// our components keep typography simple and route fenced code through Shiki
|
||||
// without the library's copy/download/fullscreen chrome.
|
||||
const MD_TAG_CLASSES = {
|
||||
h1: 'mb-3 mt-6 text-3xl font-bold leading-tight tracking-tight first:mt-0',
|
||||
h2: 'mb-2.5 mt-5 text-2xl font-semibold leading-snug tracking-tight first:mt-0',
|
||||
h3: 'mb-2 mt-4 text-xl font-semibold leading-snug first:mt-0',
|
||||
h4: 'mb-2 mt-3 text-base font-semibold leading-snug first:mt-0',
|
||||
p: 'mb-4 leading-relaxed text-foreground last:mb-0',
|
||||
ul: 'mb-4 list-disc pl-6 marker:text-muted-foreground/70 last:mb-0',
|
||||
ol: 'mb-4 list-decimal pl-6 marker:text-muted-foreground/70 last:mb-0',
|
||||
li: 'mt-1 leading-relaxed',
|
||||
blockquote: 'mb-4 border-l-2 border-border pl-3 text-muted-foreground italic last:mb-0',
|
||||
pre: 'mb-4 overflow-hidden rounded-lg border border-border bg-card font-mono text-xs leading-relaxed last:mb-0 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:font-mono'
|
||||
} as const
|
||||
|
||||
if (!language) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
'rounded bg-muted px-1 py-0.5 font-mono text-[0.86em] text-pink-700 dark:text-pink-300',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
function tagged<T extends keyof typeof MD_TAG_CLASSES>(Tag: T) {
|
||||
const base = MD_TAG_CLASSES[Tag]
|
||||
|
||||
return (
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={80}
|
||||
language={language}
|
||||
showLanguage={false}
|
||||
theme={{
|
||||
dark: 'github-dark-default',
|
||||
light: 'github-light-default'
|
||||
}}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</ShikiHighlighter>
|
||||
)
|
||||
},
|
||||
pre: ({ className, ...props }: ComponentProps<'pre'>) => (
|
||||
<pre
|
||||
className={cn(
|
||||
'mb-4 overflow-hidden rounded-lg border border-border bg-card font-mono text-xs leading-relaxed last:mb-0 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:font-mono',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}),
|
||||
[]
|
||||
)
|
||||
const Component = (({ className, ...rest }: ComponentProps<T>) => {
|
||||
const Element = Tag as React.ElementType
|
||||
|
||||
return <Element className={cn(base, className)} {...rest} />
|
||||
}) as React.FC<ComponentProps<T>>
|
||||
|
||||
Component.displayName = `Md.${Tag}`
|
||||
|
||||
return Component
|
||||
}
|
||||
|
||||
function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>) {
|
||||
const language = /language-([^\s]+)/.exec(className || '')?.[1]
|
||||
|
||||
if (!language) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
'rounded bg-muted px-1 py-0.5 font-mono text-[0.86em] text-pink-700 dark:text-pink-300',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={80}
|
||||
language={language}
|
||||
showLanguage={false}
|
||||
theme={SHIKI_THEME}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</ShikiHighlighter>
|
||||
)
|
||||
}
|
||||
|
||||
const MARKDOWN_COMPONENTS = {
|
||||
h1: tagged('h1'),
|
||||
h2: tagged('h2'),
|
||||
h3: tagged('h3'),
|
||||
h4: tagged('h4'),
|
||||
p: tagged('p'),
|
||||
ul: tagged('ul'),
|
||||
ol: tagged('ol'),
|
||||
li: tagged('li'),
|
||||
blockquote: tagged('blockquote'),
|
||||
pre: tagged('pre'),
|
||||
code: MarkdownCode
|
||||
}
|
||||
|
||||
function MarkdownPreview({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
|
||||
<Streamdown
|
||||
components={components}
|
||||
controls={false}
|
||||
mode="static"
|
||||
parseIncompleteMarkdown={false}
|
||||
>
|
||||
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
|
||||
{text}
|
||||
</Streamdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-background/90 px-3 py-1 backdrop-blur">
|
||||
<button
|
||||
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{asSource ? 'PREVIEW' : 'SOURCE'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so
|
||||
// each line aligns vertically. The selection overlay relies on the same
|
||||
// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself.
|
||||
const SOURCE_LINE_HEIGHT_REM = 1.21875
|
||||
const SOURCE_PAD_Y_REM = 0.75
|
||||
|
||||
interface LineSelection {
|
||||
end: number
|
||||
start: number
|
||||
}
|
||||
|
||||
function startLineDrag(event: ReactDragEvent<HTMLElement>, filePath: string, { end, start }: LineSelection) {
|
||||
const lineEnd = end > start ? end : undefined
|
||||
const label = lineEnd ? `${filePath}:${start}-${end}` : `${filePath}:${start}`
|
||||
|
||||
event.dataTransfer.setData(HERMES_PATHS_MIME, JSON.stringify([{ line: start, lineEnd, path: filePath }]))
|
||||
event.dataTransfer.setData('text/plain', label)
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
|
||||
const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
|
||||
const [selection, setSelection] = useState<LineSelection | null>(null)
|
||||
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
|
||||
|
||||
const handleLineClick = (event: ReactMouseEvent, line: number) => {
|
||||
if (event.shiftKey && selection) {
|
||||
setSelection({ end: Math.max(selection.end, line), start: Math.min(selection.start, line) })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (selection?.start === line && selection.end === line) {
|
||||
setSelection(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSelection({ end: line, start: line })
|
||||
}
|
||||
|
||||
const handleDragStart = (event: ReactDragEvent<HTMLElement>, line: number) => {
|
||||
startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
|
||||
<div className="select-none py-3 text-right text-muted-foreground/55">
|
||||
{Array.from({ length: lineCount }, (_, index) => {
|
||||
const line = index + 1
|
||||
const selected = inSelection(line)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'cursor-pointer px-3 tabular-nums transition-colors',
|
||||
selected
|
||||
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
|
||||
: 'hover:text-foreground'
|
||||
)}
|
||||
draggable
|
||||
key={line}
|
||||
onClick={event => handleLineClick(event, line)}
|
||||
onDragStart={event => handleDragStart(event, line)}
|
||||
title="Click to select · shift-click to extend · drag to composer"
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3">
|
||||
{selection && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 bg-amber-200/35 dark:bg-amber-300/10"
|
||||
style={{
|
||||
top: `calc(${SOURCE_PAD_Y_REM}rem + ${selection.start - 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`,
|
||||
height: `calc(${selection.end - selection.start + 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={80}
|
||||
language={language || 'text'}
|
||||
showLanguage={false}
|
||||
theme={SHIKI_THEME}
|
||||
>
|
||||
{text}
|
||||
</ShikiHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
|
||||
const [state, setState] = useState<LocalPreviewState>({ loading: true })
|
||||
const [forcePreview, setForcePreview] = useState(false)
|
||||
|
|
@ -643,8 +778,7 @@ function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: Pr
|
|||
// HTML files are rendered as source code, not in a webview — so they take
|
||||
// the same path as plain text files. `previewKind === 'binary'` arrives
|
||||
// when the file is forcibly previewed past the binary refusal screen.
|
||||
const isText =
|
||||
target.previewKind === 'text' || target.previewKind === 'binary' || target.previewKind === 'html'
|
||||
const isText = target.previewKind === 'text' || target.previewKind === 'binary' || target.previewKind === 'html'
|
||||
|
||||
const blockedByTarget = !isImage && !forcePreview && (target.binary || target.large)
|
||||
|
||||
|
|
@ -713,12 +847,7 @@ function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: Pr
|
|||
}
|
||||
|
||||
if (state.error) {
|
||||
return (
|
||||
<PreviewEmptyState
|
||||
body={<div className="text-xs leading-5 text-muted-foreground">{state.error}</div>}
|
||||
title="Preview unavailable"
|
||||
/>
|
||||
)
|
||||
return <PreviewEmptyState body={state.error} title="Preview unavailable" />
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
@ -732,14 +861,13 @@ function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: Pr
|
|||
return (
|
||||
<PreviewEmptyState
|
||||
body={
|
||||
<div className="text-xs leading-5 text-muted-foreground">
|
||||
{binary
|
||||
? `Previewing ${target.label} may show unreadable text.`
|
||||
: `${target.label} is ${formatBytes(size)}. Hermes will show the first 512 KB.`}
|
||||
</div>
|
||||
binary
|
||||
? `Previewing ${target.label} may show unreadable text.`
|
||||
: `${target.label} is ${formatBytes(size)}. Hermes will only show the first 512 KB.`
|
||||
}
|
||||
primaryAction={{ label: 'Preview anyway', onClick: () => setForcePreview(true) }}
|
||||
title={binary ? 'This looks like a binary file' : 'This file is large'}
|
||||
tone="warning"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -759,84 +887,41 @@ function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: Pr
|
|||
|
||||
if (isText && state.text !== undefined) {
|
||||
const isMarkdown = (state.language || target.language) === 'markdown'
|
||||
|
||||
const truncatedBanner = state.truncated ? (
|
||||
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
|
||||
Showing first 512 KB.
|
||||
</div>
|
||||
) : null
|
||||
|
||||
if (isMarkdown && !renderMarkdownAsSource) {
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-background">
|
||||
{truncatedBanner}
|
||||
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-background/90 px-3 py-1 backdrop-blur">
|
||||
<button
|
||||
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55"
|
||||
onClick={() => setRenderMarkdownAsSource(true)}
|
||||
type="button"
|
||||
>
|
||||
SOURCE
|
||||
</button>
|
||||
</div>
|
||||
<MarkdownPreview text={state.text} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const showRendered = isMarkdown && !renderMarkdownAsSource
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-background">
|
||||
{truncatedBanner}
|
||||
{isMarkdown && (
|
||||
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-background/90 px-3 py-1 backdrop-blur">
|
||||
<button
|
||||
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55"
|
||||
onClick={() => setRenderMarkdownAsSource(false)}
|
||||
type="button"
|
||||
>
|
||||
PREVIEW
|
||||
</button>
|
||||
{state.truncated && (
|
||||
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
|
||||
Showing first 512 KB.
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-max font-mono text-xs leading-relaxed [&_pre]:m-0 [&_pre]:p-3">
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={80}
|
||||
language={state.language || 'text'}
|
||||
showLanguage={false}
|
||||
theme={{
|
||||
dark: 'github-dark-default',
|
||||
light: 'github-light-default'
|
||||
}}
|
||||
>
|
||||
{state.text}
|
||||
</ShikiHighlighter>
|
||||
</div>
|
||||
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
|
||||
{showRendered ? (
|
||||
<MarkdownPreview text={state.text} />
|
||||
) : (
|
||||
<SourceView filePath={filePath} language={state.language || 'text'} text={state.text} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewEmptyState
|
||||
body={
|
||||
<div className="text-xs leading-5 text-muted-foreground">
|
||||
{target.mimeType || 'This file type'} can still be attached as context.
|
||||
</div>
|
||||
}
|
||||
body={`${target.mimeType || 'This file type'} can still be attached as context.`}
|
||||
title="No inline preview"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const TITLEBAR_GROUP_ID = 'preview'
|
||||
|
||||
export function PreviewPane({
|
||||
onClose,
|
||||
onRestartServer,
|
||||
reloadRequest = 0,
|
||||
setTitlebarToolGroup,
|
||||
target,
|
||||
titlebarToolGroupId = 'preview'
|
||||
target
|
||||
}: PreviewPaneProps) {
|
||||
const [consoleState] = useState(() => createPreviewConsoleState())
|
||||
const consoleBodyRef = useRef<HTMLDivElement | null>(null)
|
||||
|
|
@ -1002,14 +1087,14 @@ export function PreviewPane({
|
|||
{
|
||||
active: consoleOpen,
|
||||
icon: <PreviewConsoleTitlebarIcon consoleState={consoleState} />,
|
||||
id: `${titlebarToolGroupId}-console`,
|
||||
id: `${TITLEBAR_GROUP_ID}-console`,
|
||||
label: consoleOpen ? 'Hide preview console' : 'Show preview console',
|
||||
onSelect: () => consoleState.setOpen(open => !open)
|
||||
},
|
||||
{
|
||||
active: devtoolsOpen,
|
||||
icon: <Bug />,
|
||||
id: `${titlebarToolGroupId}-devtools`,
|
||||
id: `${TITLEBAR_GROUP_ID}-devtools`,
|
||||
label: devtoolsOpen ? 'Hide preview DevTools' : 'Open preview DevTools',
|
||||
onSelect: toggleDevTools
|
||||
}
|
||||
|
|
@ -1017,21 +1102,21 @@ export function PreviewPane({
|
|||
: []),
|
||||
{
|
||||
icon: <RefreshCw className={cn(loading && 'animate-spin')} />,
|
||||
id: `${titlebarToolGroupId}-reload`,
|
||||
id: `${TITLEBAR_GROUP_ID}-reload`,
|
||||
label: 'Reload preview',
|
||||
onSelect: reloadPreview
|
||||
},
|
||||
{
|
||||
icon: <X />,
|
||||
id: `${titlebarToolGroupId}-close`,
|
||||
id: `${TITLEBAR_GROUP_ID}-close`,
|
||||
label: 'Close preview',
|
||||
onSelect: onClose
|
||||
}
|
||||
]
|
||||
|
||||
setTitlebarToolGroup(titlebarToolGroupId, tools)
|
||||
setTitlebarToolGroup(TITLEBAR_GROUP_ID, tools)
|
||||
|
||||
return () => setTitlebarToolGroup(titlebarToolGroupId, [])
|
||||
return () => setTitlebarToolGroup(TITLEBAR_GROUP_ID, [])
|
||||
}, [
|
||||
consoleOpen,
|
||||
consoleState,
|
||||
|
|
@ -1041,7 +1126,6 @@ export function PreviewPane({
|
|||
onClose,
|
||||
reloadPreview,
|
||||
setTitlebarToolGroup,
|
||||
titlebarToolGroupId,
|
||||
toggleDevTools
|
||||
])
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ import {
|
|||
|
||||
import { PreviewPane } from './preview-pane'
|
||||
|
||||
const INTRINSIC = 'clamp(18rem, 36vw, 38rem)'
|
||||
export const PREVIEW_RAIL_MIN_WIDTH = '18rem'
|
||||
export const PREVIEW_RAIL_MAX_WIDTH = '38rem'
|
||||
|
||||
const INTRINSIC = `clamp(${PREVIEW_RAIL_MIN_WIDTH}, 36vw, 32rem)`
|
||||
|
||||
// Track for <Pane id="preview">. Folds the intrinsic clamp with a min-floor
|
||||
// against --chat-min-width so the chat surface never gets squeezed below it.
|
||||
|
|
@ -30,7 +33,9 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
|||
const previewTarget = useStore($previewTarget)
|
||||
const target = filePreviewTarget ?? previewTarget
|
||||
|
||||
if (!target) {return null}
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewPane
|
||||
|
|
|
|||
|
|
@ -9,7 +9,16 @@ import { useSkinCommand } from '@/themes/use-skin-command'
|
|||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
import { getSessionMessages, listSessions } from '../hermes'
|
||||
import { toChatMessages } from '../lib/chat-messages'
|
||||
import { $pinnedSessionIds, FILE_BROWSER_DEFAULT_WIDTH, pinSession, SIDEBAR_DEFAULT_WIDTH, unpinSession } from '../store/layout'
|
||||
import {
|
||||
$pinnedSessionIds,
|
||||
FILE_BROWSER_DEFAULT_WIDTH,
|
||||
FILE_BROWSER_MAX_WIDTH,
|
||||
FILE_BROWSER_MIN_WIDTH,
|
||||
pinSession,
|
||||
SIDEBAR_DEFAULT_WIDTH,
|
||||
SIDEBAR_MAX_WIDTH,
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { $filePreviewTarget, $previewTarget, dismissFilePreviewTarget, dismissPreviewTarget } from '../store/preview'
|
||||
import {
|
||||
$activeSessionId,
|
||||
|
|
@ -30,7 +39,7 @@ import { AgentsView } from './agents'
|
|||
import { ArtifactsView } from './artifacts'
|
||||
import { ChatView } from './chat'
|
||||
import { useComposerActions } from './chat/hooks/use-composer-actions'
|
||||
import { ChatPreviewRail, PREVIEW_RAIL_PANE_WIDTH } from './chat/right-rail'
|
||||
import { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH, PREVIEW_RAIL_PANE_WIDTH } from './chat/right-rail'
|
||||
import { ChatSidebar } from './chat/sidebar'
|
||||
import { CommandCenterView } from './command-center'
|
||||
import { FileBrowserPane } from './file-browser'
|
||||
|
|
@ -464,7 +473,14 @@ export function DesktopController() {
|
|||
statusbarItems={statusbarItems}
|
||||
titlebarTools={titlebarToolGroups.flat.right}
|
||||
>
|
||||
<Pane id="chat-sidebar" side="left" width={`${SIDEBAR_DEFAULT_WIDTH}px`}>
|
||||
<Pane
|
||||
id="chat-sidebar"
|
||||
maxWidth={SIDEBAR_MAX_WIDTH}
|
||||
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
||||
resizable
|
||||
side="left"
|
||||
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
||||
>
|
||||
{sidebar}
|
||||
</Pane>
|
||||
<PaneMain>
|
||||
|
|
@ -479,7 +495,10 @@ export function DesktopController() {
|
|||
/>
|
||||
<Route
|
||||
element={
|
||||
<ArtifactsView setStatusbarItemGroup={setStatusbarItemGroup} setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
<ArtifactsView
|
||||
setStatusbarItemGroup={setStatusbarItemGroup}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
/>
|
||||
}
|
||||
path="artifacts"
|
||||
/>
|
||||
|
|
@ -491,12 +510,28 @@ export function DesktopController() {
|
|||
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />
|
||||
</Routes>
|
||||
</PaneMain>
|
||||
<Pane disabled={!chatOpen || (!previewTarget && !filePreviewTarget)} id="preview" side="right" width={PREVIEW_RAIL_PANE_WIDTH}>
|
||||
<Pane
|
||||
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
|
||||
id="preview"
|
||||
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
|
||||
minWidth={PREVIEW_RAIL_MIN_WIDTH}
|
||||
resizable
|
||||
side="right"
|
||||
width={PREVIEW_RAIL_PANE_WIDTH}
|
||||
>
|
||||
{chatOpen ? (
|
||||
<ChatPreviewRail onRestartServer={restartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
) : null}
|
||||
</Pane>
|
||||
<Pane defaultOpen={false} id="file-browser" side="right" width={FILE_BROWSER_DEFAULT_WIDTH}>
|
||||
<Pane
|
||||
defaultOpen={false}
|
||||
id="file-browser"
|
||||
maxWidth={FILE_BROWSER_MAX_WIDTH}
|
||||
minWidth={FILE_BROWSER_MIN_WIDTH}
|
||||
resizable
|
||||
side="right"
|
||||
width={FILE_BROWSER_DEFAULT_WIDTH}
|
||||
>
|
||||
<FileBrowserPane onActivateFile={composer.attachContextFilePath} onChangeCwd={changeSessionCwd} />
|
||||
</Pane>
|
||||
</AppShell>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,12 @@ interface FileBrowserPaneProps {
|
|||
export function FileBrowserPane({ onActivateFile, onChangeCwd }: FileBrowserPaneProps) {
|
||||
const currentCwd = useStore($currentCwd).trim()
|
||||
const hasCwd = currentCwd.length > 0
|
||||
const cwdName = hasCwd ? (currentCwd.split(/[\\/]+/).filter(Boolean).pop() ?? currentCwd) : 'No folder selected'
|
||||
const cwdName = hasCwd
|
||||
? (currentCwd
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? currentCwd)
|
||||
: 'No folder selected'
|
||||
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
|
||||
|
||||
const chooseFolder = async () => {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ function decodeDataUrl(dataUrl: string) {
|
|||
const data = match?.[1] || ''
|
||||
const isBase64 = dataUrl.slice(0, dataUrl.indexOf(',')).includes(';base64')
|
||||
|
||||
if (!isBase64) {return decodeURIComponent(data)}
|
||||
if (!isBase64) {
|
||||
return decodeURIComponent(data)
|
||||
}
|
||||
|
||||
const bytes = Uint8Array.from(atob(data), ch => ch.charCodeAt(0))
|
||||
|
||||
|
|
@ -33,7 +35,9 @@ function relativeTo(root: string, child: string) {
|
|||
const r = clean(root)
|
||||
const c = clean(child)
|
||||
|
||||
if (c === r) {return ''}
|
||||
if (c === r) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return c.startsWith(`${r}/`) ? c.slice(r.length + 1) : null
|
||||
}
|
||||
|
|
@ -43,7 +47,9 @@ function ancestorDirs(root: string, dir: string) {
|
|||
const r = clean(root)
|
||||
const rel = relativeTo(r, dir)
|
||||
|
||||
if (rel === null || rel === '') {return [r]}
|
||||
if (rel === null || rel === '') {
|
||||
return [r]
|
||||
}
|
||||
|
||||
const dirs = [r]
|
||||
let current = r
|
||||
|
|
@ -57,7 +63,9 @@ function ancestorDirs(root: string, dir: string) {
|
|||
}
|
||||
|
||||
async function gitRootFor(start: string) {
|
||||
if (!window.hermesDesktop?.gitRoot) {return null}
|
||||
if (!window.hermesDesktop?.gitRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const key = clean(start)
|
||||
let cached = gitRootCache.get(key)
|
||||
|
|
@ -72,12 +80,16 @@ async function gitRootFor(start: string) {
|
|||
|
||||
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
|
||||
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
|
||||
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {return null}
|
||||
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const listing = await window.hermesDesktop.readDir(dir)
|
||||
|
||||
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {return null}
|
||||
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
|
||||
|
||||
|
|
@ -103,7 +115,9 @@ function ignoredBy(rules: GitignoreRule[], entry: HermesReadDirEntry) {
|
|||
return rules.some(rule => {
|
||||
const rel = relativeTo(rule.base, entry.path)
|
||||
|
||||
if (rel === null || rel === '') {return false}
|
||||
if (rel === null || rel === '') {
|
||||
return false
|
||||
}
|
||||
|
||||
return rule.ig.ignores(entry.isDirectory ? `${rel}/` : rel)
|
||||
})
|
||||
|
|
@ -112,17 +126,21 @@ function ignoredBy(rules: GitignoreRule[], entry: HermesReadDirEntry) {
|
|||
async function filterIgnored(entries: HermesReadDirEntry[], rootPath: string, dirPath: string) {
|
||||
const root = await gitRootFor(rootPath)
|
||||
|
||||
if (!root) {return entries}
|
||||
if (!root) {
|
||||
return entries
|
||||
}
|
||||
|
||||
const rules = (await Promise.all(ancestorDirs(root, dirPath).map(gitignoreFor))).filter(
|
||||
(r): r is GitignoreRule => Boolean(r)
|
||||
const rules = (await Promise.all(ancestorDirs(root, dirPath).map(gitignoreFor))).filter((r): r is GitignoreRule =>
|
||||
Boolean(r)
|
||||
)
|
||||
|
||||
return rules.length > 0 ? entries.filter(entry => !ignoredBy(rules, entry)) : entries
|
||||
}
|
||||
|
||||
export async function readProjectDir(dirPath: string, rootPath = dirPath): Promise<HermesReadDirResult> {
|
||||
if (!window.hermesDesktop) {return { entries: [], error: 'no-bridge' }}
|
||||
if (!window.hermesDesktop) {
|
||||
return { entries: [], error: 'no-bridge' }
|
||||
}
|
||||
|
||||
const result = await window.hermesDesktop.readDir(dirPath)
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ export function ProjectTree({
|
|||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
|
||||
if (!el || typeof ResizeObserver === 'undefined') {return}
|
||||
if (!el || typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const { height, width } = entry.contentRect
|
||||
|
|
@ -49,7 +51,9 @@ export function ProjectTree({
|
|||
(id: string) => {
|
||||
const node = treeRef.current?.get(id)
|
||||
|
||||
if (!node) {return}
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
onNodeOpenChange(id, node.isOpen)
|
||||
|
||||
|
|
@ -121,7 +125,9 @@ function ProjectTreeRow({
|
|||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
|
||||
if (isPlaceholder) {return}
|
||||
if (isPlaceholder) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isFolder) {
|
||||
node.toggle()
|
||||
|
|
|
|||
|
|
@ -25,10 +25,14 @@ function makeNode(path: string, name: string, isDirectory: boolean): TreeNode {
|
|||
}
|
||||
|
||||
function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n: TreeNode) => TreeNode): TreeNode[] {
|
||||
if (!nodes) {return []}
|
||||
if (!nodes) {
|
||||
return []
|
||||
}
|
||||
|
||||
return nodes.map(n => {
|
||||
if (n.id === id) {return patch(n)}
|
||||
if (n.id === id) {
|
||||
return patch(n)
|
||||
}
|
||||
|
||||
if (n.children && n.children.length > 0) {
|
||||
return { ...n, children: patchNode(n.children, id, patch) }
|
||||
|
|
@ -170,37 +174,46 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
|||
[cwd]
|
||||
)
|
||||
|
||||
const loadChildren = useCallback(async (id: string) => {
|
||||
if (!cwd || inflight.has(id)) {return}
|
||||
inflight.add(id)
|
||||
|
||||
setProjectTree(current => {
|
||||
if (current.cwd !== cwd) {return current}
|
||||
|
||||
return {
|
||||
...current,
|
||||
data: patchNode(current.data, id, n => ({ ...n, loading: true, children: [placeholderChild(n.id)] }))
|
||||
const loadChildren = useCallback(
|
||||
async (id: string) => {
|
||||
if (!cwd || inflight.has(id)) {
|
||||
return
|
||||
}
|
||||
})
|
||||
inflight.add(id)
|
||||
|
||||
const { entries, error } = await readProjectDir(id, cwd)
|
||||
setProjectTree(current => {
|
||||
if (current.cwd !== cwd) {
|
||||
return current
|
||||
}
|
||||
|
||||
inflight.delete(id)
|
||||
return {
|
||||
...current,
|
||||
data: patchNode(current.data, id, n => ({ ...n, loading: true, children: [placeholderChild(n.id)] }))
|
||||
}
|
||||
})
|
||||
|
||||
setProjectTree(current => {
|
||||
if (current.cwd !== cwd) {return current}
|
||||
const { entries, error } = await readProjectDir(id, cwd)
|
||||
|
||||
return {
|
||||
...current,
|
||||
data: patchNode(current.data, id, n => ({
|
||||
...n,
|
||||
loading: false,
|
||||
error: error || undefined,
|
||||
children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
|
||||
}))
|
||||
}
|
||||
})
|
||||
}, [cwd])
|
||||
inflight.delete(id)
|
||||
|
||||
setProjectTree(current => {
|
||||
if (current.cwd !== cwd) {
|
||||
return current
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
data: patchNode(current.data, id, n => ({
|
||||
...n,
|
||||
loading: false,
|
||||
error: error || undefined,
|
||||
children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
|
||||
}))
|
||||
}
|
||||
})
|
||||
},
|
||||
[cwd]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void loadRoot(cwd)
|
||||
|
|
@ -216,6 +229,16 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
|||
rootLoading: state.cwd === cwd ? state.rootLoading : false,
|
||||
setNodeOpen
|
||||
}),
|
||||
[cwd, loadChildren, refreshRoot, setNodeOpen, state.cwd, state.data, state.openState, state.rootError, state.rootLoading]
|
||||
[
|
||||
cwd,
|
||||
loadChildren,
|
||||
refreshRoot,
|
||||
setNodeOpen,
|
||||
state.cwd,
|
||||
state.data,
|
||||
state.openState,
|
||||
state.rootError,
|
||||
state.rootLoading
|
||||
]
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,13 +40,19 @@ export function useContextSuggestions({
|
|||
cwd: cwd || undefined
|
||||
})
|
||||
|
||||
if (stillCurrent()) {setContextSuggestions((result.items || []).filter(i => i.text))}
|
||||
if (stillCurrent()) {
|
||||
setContextSuggestions((result.items || []).filter(i => i.text))
|
||||
}
|
||||
} catch {
|
||||
if (stillCurrent()) {setContextSuggestions([])}
|
||||
if (stillCurrent()) {
|
||||
setContextSuggestions([])
|
||||
}
|
||||
}
|
||||
}, [activeSessionId, activeSessionIdRef, currentCwd, requestGateway])
|
||||
|
||||
useEffect(() => {
|
||||
if (gatewayState === 'open' && activeSessionId) {void refresh()}
|
||||
if (gatewayState === 'open' && activeSessionId) {
|
||||
void refresh()
|
||||
}
|
||||
}, [activeSessionId, gatewayState, refresh])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,10 +16,15 @@ export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd,
|
|||
async (cwd: string) => {
|
||||
const target = cwd.trim()
|
||||
|
||||
if (!target || activeSessionIdRef.current) {return}
|
||||
if (!target || activeSessionIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
|
||||
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', {
|
||||
key: 'project',
|
||||
cwd: target
|
||||
})
|
||||
|
||||
if (!activeSessionIdRef.current && ($currentCwd.get() || target) === (info.cwd || target)) {
|
||||
setCurrentBranch(info.branch || '')
|
||||
|
|
@ -35,7 +40,9 @@ export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd,
|
|||
async (cwd: string) => {
|
||||
const trimmed = cwd.trim()
|
||||
|
||||
if (!trimmed) {return}
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
const persistGlobal = async () => {
|
||||
const info = await requestGateway<{ branch?: string; cwd?: string; value?: string }>('config.set', {
|
||||
|
|
@ -46,7 +53,9 @@ export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd,
|
|||
|
||||
setCurrentCwd(info.cwd || info.value || trimmed)
|
||||
|
||||
if (!activeSessionId) {setCurrentBranch(info.branch || '')}
|
||||
if (!activeSessionId) {
|
||||
setCurrentBranch(info.branch || '')
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeSessionId) {
|
||||
|
|
@ -101,7 +110,9 @@ export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd,
|
|||
multiple: false
|
||||
})
|
||||
|
||||
if (paths?.[0]) {await changeSessionCwd(paths[0])}
|
||||
if (paths?.[0]) {
|
||||
await changeSessionCwd(paths[0])
|
||||
}
|
||||
}, [changeSessionCwd, currentCwd])
|
||||
|
||||
return { browseSessionCwd, changeSessionCwd, refreshProjectBranch }
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
|||
|
||||
queryClient.setQueryData<ModelOptionsResponse>(['model-options', activeSessionId || 'global'], patch)
|
||||
|
||||
if (includeGlobal) {queryClient.setQueryData<ModelOptionsResponse>(['model-options', 'global'], patch)}
|
||||
if (includeGlobal) {
|
||||
queryClient.setQueryData<ModelOptionsResponse>(['model-options', 'global'], patch)
|
||||
}
|
||||
},
|
||||
[activeSessionId, queryClient]
|
||||
)
|
||||
|
|
@ -34,9 +36,13 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
|||
try {
|
||||
const result = await getGlobalModelInfo()
|
||||
|
||||
if (typeof result.model === 'string') {setCurrentModel(result.model)}
|
||||
if (typeof result.model === 'string') {
|
||||
setCurrentModel(result.model)
|
||||
}
|
||||
|
||||
if (typeof result.provider === 'string') {setCurrentProvider(result.provider)}
|
||||
if (typeof result.provider === 'string') {
|
||||
setCurrentProvider(result.provider)
|
||||
}
|
||||
} catch {
|
||||
// The delayed session.info event still updates this once the agent is ready.
|
||||
}
|
||||
|
|
@ -56,7 +62,9 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
|||
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
|
||||
})
|
||||
|
||||
if (selection.persistGlobal) {void refreshCurrentModel()}
|
||||
if (selection.persistGlobal) {
|
||||
void refreshCurrentModel()
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
|
||||
})
|
||||
|
|
|
|||
|
|
@ -87,7 +87,13 @@ describe('usePreviewRouting', () => {
|
|||
const target = previewTarget('/work/demo.html')
|
||||
|
||||
registerSessionPreview('session-1', target, 'tool-result')
|
||||
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
|
||||
render(
|
||||
<PreviewRoutingHarness
|
||||
onEvent={handler => {
|
||||
handleEvent = handler
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect($previewTarget.get()).toEqual({ ...target, renderMode: 'preview' })
|
||||
|
|
@ -95,7 +101,13 @@ describe('usePreviewRouting', () => {
|
|||
})
|
||||
|
||||
it('does not infer previews from assistant prose', async () => {
|
||||
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
|
||||
render(
|
||||
<PreviewRoutingHarness
|
||||
onEvent={handler => {
|
||||
handleEvent = handler
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
act(() => {
|
||||
$messages.set([
|
||||
|
|
@ -109,7 +121,13 @@ describe('usePreviewRouting', () => {
|
|||
})
|
||||
|
||||
it('registers structured tool-result preview targets', async () => {
|
||||
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
|
||||
render(
|
||||
<PreviewRoutingHarness
|
||||
onEvent={handler => {
|
||||
handleEvent = handler
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
act(() =>
|
||||
handleEvent({
|
||||
|
|
@ -127,7 +145,13 @@ describe('usePreviewRouting', () => {
|
|||
})
|
||||
|
||||
it('registers html previews from edit inline diffs', async () => {
|
||||
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
|
||||
render(
|
||||
<PreviewRoutingHarness
|
||||
onEvent={handler => {
|
||||
handleEvent = handler
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
act(() =>
|
||||
handleEvent({
|
||||
|
|
|
|||
|
|
@ -113,18 +113,32 @@ export function usePreviewRouting({
|
|||
|
||||
const registerStructuredPreview = useCallback(
|
||||
async (event: RpcEvent) => {
|
||||
if (event.session_id && event.session_id !== activeSessionIdRef.current && event.session_id !== previewSessionId) {return}
|
||||
if (
|
||||
event.session_id &&
|
||||
event.session_id !== activeSessionIdRef.current &&
|
||||
event.session_id !== previewSessionId
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.type.startsWith('tool.')) {return}
|
||||
if (!event.type.startsWith('tool.')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!previewSessionId) {return}
|
||||
if (!previewSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidate = structuredPreviewCandidate(event.payload)
|
||||
|
||||
if (!candidate) {return}
|
||||
if (!candidate) {
|
||||
return
|
||||
}
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.normalizePreviewTarget) {return}
|
||||
if (!desktop?.normalizePreviewTarget) {
|
||||
return
|
||||
}
|
||||
const sessionId = previewSessionId
|
||||
const cwd = currentCwd || ''
|
||||
const target = await desktop.normalizePreviewTarget(candidate, cwd || undefined).catch(() => null)
|
||||
|
|
@ -146,7 +160,9 @@ export function usePreviewRouting({
|
|||
async (url: string, context?: string) => {
|
||||
const sessionId = activeSessionIdRef.current
|
||||
|
||||
if (!sessionId) {throw new Error('No active session for background restart')}
|
||||
if (!sessionId) {
|
||||
throw new Error('No active session for background restart')
|
||||
}
|
||||
|
||||
const cwd = $currentCwd.get() || currentCwd || ''
|
||||
|
||||
|
|
@ -159,7 +175,9 @@ export function usePreviewRouting({
|
|||
|
||||
const taskId = result.task_id || ''
|
||||
|
||||
if (!taskId) {throw new Error('Background restart did not return a task id')}
|
||||
if (!taskId) {
|
||||
throw new Error('Background restart did not return a task id')
|
||||
}
|
||||
|
||||
beginPreviewServerRestart(taskId, url)
|
||||
|
||||
|
|
@ -175,18 +193,26 @@ export function usePreviewRouting({
|
|||
if (event.type === 'preview.restart.complete') {
|
||||
const { task_id, text } = asRecord(event.payload)
|
||||
|
||||
if (typeof task_id === 'string' && task_id) {completePreviewServerRestart(task_id, typeof text === 'string' ? text : '')}
|
||||
if (typeof task_id === 'string' && task_id) {
|
||||
completePreviewServerRestart(task_id, typeof text === 'string' ? text : '')
|
||||
}
|
||||
} else if (event.type === 'preview.restart.progress') {
|
||||
const { task_id, text } = asRecord(event.payload)
|
||||
|
||||
if (typeof task_id === 'string' && task_id) {progressPreviewServerRestart(task_id, typeof text === 'string' ? text : '')}
|
||||
if (typeof task_id === 'string' && task_id) {
|
||||
progressPreviewServerRestart(task_id, typeof text === 'string' ? text : '')
|
||||
}
|
||||
}
|
||||
|
||||
if (event.session_id && event.session_id !== activeSessionIdRef.current) {return}
|
||||
if (event.session_id && event.session_id !== activeSessionIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
void registerStructuredPreview(event)
|
||||
|
||||
if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) {requestPreviewReload()}
|
||||
if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) {
|
||||
requestPreviewReload()
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, baseHandleGatewayEvent, registerStructuredPreview]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,10 +22,14 @@ interface RouteResumeOptions {
|
|||
// parsed. If the hash references a real session, defer; resume picks it up
|
||||
// next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states.
|
||||
function rawHashLooksLikeSession(): boolean {
|
||||
if (typeof window === 'undefined') {return false}
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
const hash = window.location.hash.replace(/^#/, '')
|
||||
|
||||
if (!hash || hash === '/') {return false}
|
||||
if (!hash || hash === '/') {
|
||||
return false
|
||||
}
|
||||
|
||||
return !hash.startsWith('/settings') && !hash.startsWith('/skills') && !hash.startsWith('/artifacts')
|
||||
}
|
||||
|
|
@ -46,7 +50,9 @@ export function useRouteResume({
|
|||
startFreshSessionDraft
|
||||
}: RouteResumeOptions) {
|
||||
useEffect(() => {
|
||||
if (currentView !== 'chat' || gatewayState !== 'open') {return}
|
||||
if (currentView !== 'chat' || gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
if (routedSessionId) {
|
||||
const cachedRuntime = runtimeIdByStoredSessionIdRef.current.get(routedSessionId)
|
||||
|
|
@ -56,7 +62,9 @@ export function useRouteResume({
|
|||
Boolean(cachedRuntime) &&
|
||||
cachedRuntime === activeSessionIdRef.current
|
||||
|
||||
if (!alreadyActive) {void resumeSession(routedSessionId, true)}
|
||||
if (!alreadyActive) {
|
||||
void resumeSession(routedSessionId, true)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -655,16 +655,16 @@ export function useSessionActions({
|
|||
setFreshDraftReady(false)
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
|
||||
if (stored) {
|
||||
setCurrentUsage(current => ({
|
||||
...current,
|
||||
input: stored.input_tokens || 0,
|
||||
output: stored.output_tokens || 0,
|
||||
total: (stored.input_tokens || 0) + (stored.output_tokens || 0)
|
||||
}))
|
||||
}
|
||||
if (stored) {
|
||||
setCurrentUsage(current => ({
|
||||
...current,
|
||||
input: stored.input_tokens || 0,
|
||||
output: stored.output_tokens || 0,
|
||||
total: (stored.input_tokens || 0) + (stored.output_tokens || 0)
|
||||
}))
|
||||
}
|
||||
|
||||
setMessages(previousMessages)
|
||||
navigate(sessionRoute(storedSessionId), { replace: true })
|
||||
|
|
|
|||
|
|
@ -1,19 +1,16 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import type { CSSProperties, ReactNode, PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
|
||||
import { PaneShell } from '@/components/pane-shell'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$sidebarOpen,
|
||||
$sidebarWidth,
|
||||
FILE_BROWSER_DEFAULT_WIDTH,
|
||||
setSidebarOpen,
|
||||
setSidebarResizing,
|
||||
setSidebarWidth
|
||||
FILE_BROWSER_PANE_ID,
|
||||
setSidebarOpen
|
||||
} from '@/store/layout'
|
||||
import { $paneWidthOverride } from '@/store/panes'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
|
||||
|
|
@ -39,9 +36,9 @@ export function AppShell({
|
|||
statusbarItems,
|
||||
titlebarTools
|
||||
}: AppShellProps) {
|
||||
const sidebarWidth = useStore($sidebarWidth)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
|
||||
const connection = useStore($connection)
|
||||
|
||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition)
|
||||
|
|
@ -57,12 +54,15 @@ export function AppShell({
|
|||
const paneToolCount = titlebarTools?.filter(tool => !tool.hidden).length ?? 0
|
||||
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * var(--titlebar-control-size))`
|
||||
|
||||
const fileBrowserWidth =
|
||||
fileBrowserWidthOverride !== undefined ? `${fileBrowserWidthOverride}px` : FILE_BROWSER_DEFAULT_WIDTH
|
||||
|
||||
// Where the pane-tool cluster's right edge sits, measured from the inner
|
||||
// titlebar padding (--titlebar-tools-right). Two anchors:
|
||||
// - file-browser closed → flush against static cluster's left edge
|
||||
// - file-browser open → flush against the file-browser pane's left edge
|
||||
// (= preview pane's right edge)
|
||||
const previewToolbarGap = fileBrowserOpen ? FILE_BROWSER_DEFAULT_WIDTH : systemToolsWidth
|
||||
const previewToolbarGap = fileBrowserOpen ? fileBrowserWidth : systemToolsWidth
|
||||
|
||||
// Used by the drag region to know where the rightmost interactive element
|
||||
// ends. When pane tools are present, that's `gap + paneCount * controlSize`
|
||||
|
|
@ -73,38 +73,6 @@ export function AppShell({
|
|||
? `calc(${previewToolbarGap} + ${paneToolCount} * var(--titlebar-control-size))`
|
||||
: systemToolsWidth
|
||||
|
||||
const startSidebarResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
setSidebarResizing(true)
|
||||
|
||||
const startX = event.clientX
|
||||
const startWidth = sidebarWidth
|
||||
const previousCursor = document.body.style.cursor
|
||||
const previousUserSelect = document.body.style.userSelect
|
||||
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
setSidebarWidth(startWidth + moveEvent.clientX - startX)
|
||||
}
|
||||
|
||||
const handleUp = () => {
|
||||
setSidebarResizing(false)
|
||||
triggerHaptic('crisp')
|
||||
document.body.style.cursor = previousCursor
|
||||
document.body.style.userSelect = previousUserSelect
|
||||
window.removeEventListener('pointermove', handleMove)
|
||||
window.removeEventListener('pointerup', handleUp)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handleMove)
|
||||
window.addEventListener('pointerup', handleUp, { once: true })
|
||||
},
|
||||
[sidebarWidth]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
className="h-screen min-h-0 bg-background"
|
||||
|
|
@ -143,19 +111,6 @@ export function AppShell({
|
|||
/>
|
||||
|
||||
{children}
|
||||
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
aria-label="Resize sidebar"
|
||||
aria-orientation="vertical"
|
||||
className="group absolute bottom-0 top-0 left-[calc(var(--pane-chat-sidebar-width)-0.5rem)] z-5 w-4 cursor-col-resize [-webkit-app-region:no-drag]"
|
||||
onPointerDown={startSidebarResize}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="absolute left-1/2 top-1/2 h-23 w-0.75 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.65] group-focus-visible:opacity-[0.65]" />
|
||||
</div>
|
||||
)}
|
||||
</PaneShell>
|
||||
|
||||
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ export function useStatusSnapshot(gatewayState: string | undefined) {
|
|||
getLogs({ file: 'gateway', lines: LOG_TAIL }).catch(() => ({ lines: [] }))
|
||||
])
|
||||
|
||||
if (cancelled) {return}
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setStatusSnapshot(next)
|
||||
setGatewayLogLines(logs.lines.map(line => line.trim()).filter(Boolean))
|
||||
|
|
|
|||
|
|
@ -201,7 +201,18 @@ export function useStatusbarItems({
|
|||
variant: 'text'
|
||||
}
|
||||
],
|
||||
[browseSessionCwd, busy, contextBar, contextUsage, currentBranch, currentCwd, currentModel, currentProvider, sessionStartedAt, turnStartedAt]
|
||||
[
|
||||
browseSessionCwd,
|
||||
busy,
|
||||
contextBar,
|
||||
contextUsage,
|
||||
currentBranch,
|
||||
currentCwd,
|
||||
currentModel,
|
||||
currentProvider,
|
||||
sessionStartedAt,
|
||||
turnStartedAt
|
||||
]
|
||||
)
|
||||
|
||||
const leftStatusbarItems = useMemo(
|
||||
|
|
|
|||
|
|
@ -1,12 +1,7 @@
|
|||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface StatusbarMenuItem {
|
||||
|
|
@ -62,26 +57,24 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
|
|||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
|
||||
{leftItems.filter(item => !item.hidden).map(item => (
|
||||
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
{leftItems
|
||||
.filter(item => !item.hidden)
|
||||
.map(item => (
|
||||
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
|
||||
{items.filter(item => !item.hidden).map(item => (
|
||||
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
{items
|
||||
.filter(item => !item.hidden)
|
||||
.map(item => (
|
||||
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusbarItemView({
|
||||
item,
|
||||
navigate
|
||||
}: {
|
||||
item: StatusbarItem
|
||||
navigate: ReturnType<typeof useNavigate>
|
||||
}) {
|
||||
function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType<typeof useNavigate> }) {
|
||||
const content = (
|
||||
<>
|
||||
{item.icon}
|
||||
|
|
@ -147,7 +140,12 @@ function StatusbarItemView({
|
|||
|
||||
if (item.variant === 'text' && !item.onSelect && !item.to && !item.href) {
|
||||
return (
|
||||
<div className={cn('inline-flex h-5 items-center gap-1 px-0.5 text-[0.68rem] text-muted-foreground/90', item.className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex h-5 items-center gap-1 px-0.5 text-[0.68rem] text-muted-foreground/90',
|
||||
item.className
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -65,7 +65,11 @@ interface SkillsViewProps extends React.ComponentProps<'section'> {
|
|||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, setTitlebarToolGroup, ...props }: SkillsViewProps) {
|
||||
export function SkillsView({
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
setTitlebarToolGroup,
|
||||
...props
|
||||
}: SkillsViewProps) {
|
||||
const [mode, setMode] = useState<SkillsMode>('skills')
|
||||
const [query, setQuery] = useState('')
|
||||
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
|
||||
|
|
@ -168,10 +172,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, setT
|
|||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
{...props}
|
||||
className="flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
|
||||
>
|
||||
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background">
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Skills</h2>
|
||||
<span className="pointer-events-auto text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -2,25 +2,85 @@
|
|||
|
||||
import type { Unstable_DirectiveFormatter, Unstable_DirectiveSegment, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import type { TextMessagePartComponent, TextMessagePartProps } from '@assistant-ui/react'
|
||||
import type { ComponentType, FC } from 'react'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
|
||||
import { extractEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { AtSign, FileText, FolderOpen, ImageIcon, Link as LinkIcon, Wrench } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool'] as const
|
||||
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line'] as const
|
||||
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
|
||||
|
||||
const ICONS: Record<HermesRefType, ComponentType<{ className?: string }>> = {
|
||||
file: FileText,
|
||||
folder: FolderOpen,
|
||||
url: LinkIcon,
|
||||
image: ImageIcon,
|
||||
tool: Wrench
|
||||
/** Single source of truth for chip icon glyphs (Tabler outline @ 24×24).
|
||||
* Used both by the rendered <DirectiveIcon> and the raw SVG markup the
|
||||
* contenteditable composer embeds via `directiveIconSvg`. */
|
||||
const ICON_PATHS: Record<HermesRefType, string[]> = {
|
||||
file: [
|
||||
'M14 3v4a1 1 0 0 0 1 1h4',
|
||||
'M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2',
|
||||
'M9 9l1 0',
|
||||
'M9 13l6 0',
|
||||
'M9 17l6 0'
|
||||
],
|
||||
folder: [
|
||||
'M5 19l2.757 -7.351a1 1 0 0 1 .936 -.649h12.307a1 1 0 0 1 .986 1.164l-.996 5.211a2 2 0 0 1 -1.964 1.625h-14.026a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v2'
|
||||
],
|
||||
url: [
|
||||
'M9 15l6 -6',
|
||||
'M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464',
|
||||
'M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463'
|
||||
],
|
||||
image: [
|
||||
'M15 8h.01',
|
||||
'M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12',
|
||||
'M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5',
|
||||
'M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3'
|
||||
],
|
||||
tool: ['M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5'],
|
||||
line: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16']
|
||||
}
|
||||
|
||||
const ICON_FALLBACK = ['M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28']
|
||||
|
||||
const ICON_CLASS = 'size-3 shrink-0 opacity-80'
|
||||
|
||||
const SVG_ATTRS =
|
||||
'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'
|
||||
|
||||
const iconPathsFor = (type: string) => ICON_PATHS[type as HermesRefType] ?? ICON_FALLBACK
|
||||
|
||||
/** SVG markup string for embedding directly in HTML (composer contenteditable). */
|
||||
export function directiveIconSvg(type: string) {
|
||||
const inner = iconPathsFor(type)
|
||||
.map(d => `<path d="${d}"/>`)
|
||||
.join('')
|
||||
|
||||
return `<svg ${SVG_ATTRS} class="${ICON_CLASS}">${inner}</svg>`
|
||||
}
|
||||
|
||||
const DirectiveIcon: FC<{ type: string }> = ({ type }) => (
|
||||
<svg
|
||||
className={ICON_CLASS}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{iconPathsFor(type).map(d => (
|
||||
<path d={d} key={d} />
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Shared chip styling — used by both the rendered <DirectiveChip> and the
|
||||
* raw HTML composer chips in `rich-editor.ts`. Neutral subtle wash + plain
|
||||
* muted-foreground text so chips read as quiet tags on any bubble color. */
|
||||
export const DIRECTIVE_CHIP_CLASS =
|
||||
'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-[0.02em] text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground'
|
||||
|
||||
/**
|
||||
* Parses our composer's `@type:value` references into directive segments
|
||||
* so they render as inline chips in user messages instead of raw text.
|
||||
|
|
@ -34,7 +94,7 @@ const ICONS: Record<HermesRefType, ComponentType<{ className?: string }>> = {
|
|||
const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/g
|
||||
|
||||
const HERMES_DIRECTIVE_RE = new RegExp(
|
||||
'@(file|folder|url|image|tool):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')',
|
||||
'@(file|folder|url|image|tool|line):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')',
|
||||
'g'
|
||||
)
|
||||
|
||||
|
|
@ -198,6 +258,8 @@ export function DirectiveContent({ text }: { text: string }) {
|
|||
{segments.map((segment, index) =>
|
||||
segment.kind === 'text' ? (
|
||||
<Fragment key={`t-${index}`}>{segment.text}</Fragment>
|
||||
) : segment.type === 'image' ? (
|
||||
<DirectiveImage id={segment.id} key={`img-${index}-${segment.id}`} label={segment.label} />
|
||||
) : (
|
||||
<DirectiveChip id={segment.id} key={`m-${index}-${segment.id}`} label={segment.label} type={segment.type} />
|
||||
)
|
||||
|
|
@ -225,25 +287,67 @@ export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePar
|
|||
<DirectiveContent text={text ?? ''} />
|
||||
)
|
||||
|
||||
/** Image refs render as a thumbnail rather than a chip — matches how persisted
|
||||
* messages render after the backend embeds the data URL, so the UX is stable
|
||||
* across initial send and refresh. */
|
||||
const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
|
||||
const remote = /^(?:https?|data):/i.test(id)
|
||||
const [src, setSrc] = useState<string | null>(remote ? id : null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (remote || !id) {
|
||||
return
|
||||
}
|
||||
|
||||
let alive = true
|
||||
void window.hermesDesktop
|
||||
?.readFileDataUrl(id)
|
||||
.then(url => alive && setSrc(url))
|
||||
.catch(() => alive && setFailed(true))
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [id, remote])
|
||||
|
||||
if (failed) {
|
||||
return <DirectiveChip id={id} label={label} type="image" />
|
||||
}
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block size-12 shrink-0 animate-pulse rounded-md bg-[color-mix(in_srgb,currentColor_8%,transparent)]"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ZoomableImage
|
||||
alt={label}
|
||||
className="max-h-32 max-w-48 rounded-md border border-border/40 object-contain"
|
||||
draggable={false}
|
||||
slot="aui_directive-image"
|
||||
src={src}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DirectiveChip: FC<{
|
||||
type: string
|
||||
label: string
|
||||
id: string
|
||||
}> = ({ type, label, id }) => {
|
||||
const Icon = ICONS[type as HermesRefType] ?? AtSign
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'mx-0.5 inline-flex max-w-56 items-center gap-1 border border-primary/20 bg-primary/8 px-1.5 py-0.5 align-[0.02em] text-[0.86em] font-medium leading-none text-primary'
|
||||
)}
|
||||
data-directive-id={id}
|
||||
data-directive-type={type}
|
||||
data-slot="aui_directive-chip"
|
||||
title={id}
|
||||
>
|
||||
{Icon && <Icon className="size-3 shrink-0 text-primary" />}
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}> = ({ type, label, id }) => (
|
||||
<span
|
||||
className={DIRECTIVE_CHIP_CLASS}
|
||||
data-directive-id={id}
|
||||
data-directive-type={type}
|
||||
data-slot="aui_directive-chip"
|
||||
title={id}
|
||||
>
|
||||
<DirectiveIcon type={type} />
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ describe('preprocessMarkdown', () => {
|
|||
const input = ['```Heads up - a bunny got added', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join(
|
||||
'\n'
|
||||
)
|
||||
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).not.toContain('```heads')
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@ import { MonitorPlay } from '@/lib/icons'
|
|||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { previewName } from '@/lib/preview-targets'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $previewTarget, dismissPreviewTarget, type PreviewRecordSource, setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import {
|
||||
$previewTarget,
|
||||
dismissPreviewTarget,
|
||||
type PreviewRecordSource,
|
||||
setCurrentSessionPreviewTarget
|
||||
} from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
export function PreviewAttachment({ source = 'manual', target }: { source?: PreviewRecordSource; target: string }) {
|
||||
|
|
|
|||
|
|
@ -561,7 +561,10 @@ function toolSubtitle(
|
|||
firstStringField(argsRecord, ['path', 'file', 'filepath']) ||
|
||||
htmlPathFromInlineDiff(firstStringField(resultRecord, ['inline_diff']))
|
||||
|
||||
return path || (firstStringField(resultRecord, ['inline_diff']) ? 'Changed file' : fallbackDetailText(argsRecord, resultRecord))
|
||||
return (
|
||||
path ||
|
||||
(firstStringField(resultRecord, ['inline_diff']) ? 'Changed file' : fallbackDetailText(argsRecord, resultRecord))
|
||||
)
|
||||
}
|
||||
|
||||
if (toolName === 'web_extract') {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { cleanup, render } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $paneStates, setPaneOpen, setPaneWidthOverride } from '@/store/panes'
|
||||
|
|
@ -19,6 +19,23 @@ function getColumnTemplate(container: HTMLElement): string[] {
|
|||
return (container.style.gridTemplateColumns ?? '').split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
function mockWidth(element: HTMLElement, width: number) {
|
||||
Object.defineProperty(element, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: width,
|
||||
top: 0,
|
||||
width,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('PaneShell composition', () => {
|
||||
beforeEach(() => {
|
||||
$paneStates.set({})
|
||||
|
|
@ -244,4 +261,73 @@ describe('PaneShell composition', () => {
|
|||
|
||||
expect(rendered.getByTestId('floating-overlay')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows a resize handle only when resizable', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<Pane id="preview" resizable side="right" width="320px">
|
||||
preview
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(rendered.queryByLabelText('Resize files')).toBeNull()
|
||||
expect(rendered.getByLabelText('Resize preview')).toBeDefined()
|
||||
})
|
||||
|
||||
it('dragging a left-pane separator stores a wider width override', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" maxWidth={360} minWidth={200} resizable side="left" width="240px">
|
||||
<span data-testid="files-content">files</span>
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const paneCell = rendered.getByTestId('files-content').parentElement
|
||||
|
||||
if (!(paneCell instanceof HTMLElement)) {
|
||||
throw new Error('Expected pane cell element')
|
||||
}
|
||||
|
||||
mockWidth(paneCell, 240)
|
||||
const separator = rendered.getByLabelText('Resize files')
|
||||
|
||||
fireEvent.pointerDown(separator, { clientX: 240, pointerId: 1 })
|
||||
fireEvent.pointerMove(window, { clientX: 300 })
|
||||
fireEvent.pointerUp(window, { clientX: 300 })
|
||||
|
||||
expect($paneStates.get().files?.widthOverride).toBe(300)
|
||||
})
|
||||
|
||||
it('dragging a right-pane separator clamps to max width', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<PaneMain>main</PaneMain>
|
||||
<Pane id="preview" maxWidth={340} minWidth={220} resizable side="right" width="320px">
|
||||
<span data-testid="preview-content">preview</span>
|
||||
</Pane>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const paneCell = rendered.getByTestId('preview-content').parentElement
|
||||
|
||||
if (!(paneCell instanceof HTMLElement)) {
|
||||
throw new Error('Expected pane cell element')
|
||||
}
|
||||
|
||||
mockWidth(paneCell, 320)
|
||||
const separator = rendered.getByLabelText('Resize preview')
|
||||
|
||||
fireEvent.pointerDown(separator, { clientX: 900, pointerId: 1 })
|
||||
fireEvent.pointerMove(window, { clientX: 760 })
|
||||
fireEvent.pointerUp(window, { clientX: 760 })
|
||||
|
||||
expect($paneStates.get().preview?.widthOverride).toBe(340)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ import {
|
|||
Children,
|
||||
type CSSProperties,
|
||||
isValidElement,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
|
|
@ -12,7 +14,7 @@ import {
|
|||
} from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $paneStates, ensurePaneRegistered } from '@/store/panes'
|
||||
import { $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes'
|
||||
|
||||
import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context'
|
||||
|
||||
|
|
@ -57,10 +59,26 @@ interface CollectedPane {
|
|||
}
|
||||
|
||||
const DEFAULT_WIDTH = '16rem'
|
||||
const DEFAULT_RESIZE_MIN_WIDTH = 160
|
||||
|
||||
const widthToCss = (value: WidthValue | undefined, fallback: string) =>
|
||||
value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value
|
||||
|
||||
const remPx = () =>
|
||||
typeof window === 'undefined'
|
||||
? 16
|
||||
: Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16
|
||||
|
||||
// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem") to pixels for drag clamping.
|
||||
function widthToPx(value: WidthValue | undefined) {
|
||||
if (typeof value === 'number') {return Number.isFinite(value) ? value : undefined}
|
||||
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/)
|
||||
|
||||
if (!match) {return undefined}
|
||||
|
||||
return Number.parseFloat(match[1]) * (match[2] === 'rem' ? remPx() : 1)
|
||||
}
|
||||
|
||||
function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement {
|
||||
return isValidElement(child) && (child.type as PaneRoleMarker)?.__paneShellRole === role
|
||||
}
|
||||
|
|
@ -159,9 +177,19 @@ export function PaneShell({ children, className, style }: PaneShellProps) {
|
|||
)
|
||||
}
|
||||
|
||||
export function Pane({ children, className, defaultOpen = true, disabled = false, id }: PaneProps) {
|
||||
export function Pane({
|
||||
children,
|
||||
className,
|
||||
defaultOpen = true,
|
||||
disabled = false,
|
||||
id,
|
||||
maxWidth,
|
||||
minWidth,
|
||||
resizable = false
|
||||
}: PaneProps) {
|
||||
const ctx = useContext(PaneShellContext)
|
||||
const registered = useRef(false)
|
||||
const paneRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (registered.current) {return}
|
||||
|
|
@ -169,27 +197,86 @@ export function Pane({ children, className, defaultOpen = true, disabled = false
|
|||
ensurePaneRegistered(id, { open: defaultOpen })
|
||||
}, [defaultOpen, id])
|
||||
|
||||
const slot = ctx?.paneById.get(id)
|
||||
const open = Boolean(slot?.open && !disabled)
|
||||
const canResize = open && resizable
|
||||
const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH
|
||||
const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY
|
||||
const side = slot?.side ?? 'left'
|
||||
|
||||
const startResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const paneWidth = paneRef.current?.getBoundingClientRect().width ?? 0
|
||||
|
||||
if (!canResize || paneWidth <= 0) {return}
|
||||
event.preventDefault()
|
||||
|
||||
const handle = event.currentTarget
|
||||
const { pointerId, clientX: startX } = event
|
||||
const dir = side === 'left' ? 1 : -1
|
||||
const restoreCursor = document.body.style.cursor
|
||||
const restoreSelect = document.body.style.userSelect
|
||||
|
||||
handle.setPointerCapture?.(pointerId)
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const next = paneWidth + (e.clientX - startX) * dir
|
||||
setPaneWidthOverride(id, Math.round(Math.min(hi, Math.max(lo, next))))
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
document.body.style.cursor = restoreCursor
|
||||
document.body.style.userSelect = restoreSelect
|
||||
handle.releasePointerCapture?.(pointerId)
|
||||
window.removeEventListener('pointermove', onMove, true)
|
||||
window.removeEventListener('pointerup', cleanup, true)
|
||||
window.removeEventListener('pointercancel', cleanup, true)
|
||||
window.removeEventListener('blur', cleanup)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove, true)
|
||||
window.addEventListener('pointerup', cleanup, true)
|
||||
window.addEventListener('pointercancel', cleanup, true)
|
||||
window.addEventListener('blur', cleanup)
|
||||
},
|
||||
[canResize, hi, id, lo, side]
|
||||
)
|
||||
|
||||
if (!ctx) {
|
||||
if (import.meta.env.DEV) {console.warn(`[Pane:${id}] must be rendered inside <PaneShell>`)}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const slot = ctx.paneById.get(id)
|
||||
|
||||
if (!slot) {return null}
|
||||
|
||||
const open = slot.open && !disabled
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!open}
|
||||
className={cn('row-start-1 min-w-0 overflow-hidden', !open && 'pointer-events-none', className)}
|
||||
className={cn('relative row-start-1 min-w-0 overflow-hidden', !open && 'pointer-events-none', className)}
|
||||
data-pane-id={id}
|
||||
data-pane-open={open ? 'true' : 'false'}
|
||||
data-pane-side={slot.side}
|
||||
ref={paneRef}
|
||||
style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }}
|
||||
>
|
||||
{canResize && (
|
||||
<div
|
||||
aria-label={`Resize ${id}`}
|
||||
aria-orientation="vertical"
|
||||
className={cn(
|
||||
'group absolute bottom-0 top-0 z-10 w-3 cursor-col-resize [-webkit-app-region:no-drag]',
|
||||
slot.side === 'left' ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2'
|
||||
)}
|
||||
onPointerDown={startResize}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="absolute left-1/2 top-1/2 h-18 w-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.65] group-focus-visible:opacity-[0.65]" />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ export function CopyButton({
|
|||
|
||||
const Icon = status === 'copied' ? Check : status === 'error' ? X : Copy
|
||||
const icon = <Icon className={cn('size-3.5', iconClassName)} />
|
||||
|
||||
const visibleChildren =
|
||||
(showLabel ?? (appearance !== 'icon' && appearance !== 'tool-row'))
|
||||
? status === 'copied'
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export function FadeText({ children, className, fadeWidth = '3rem', style, ...re
|
|||
WebkitMaskImage: `linear-gradient(to right, black calc(100% - ${fadeWidth}), transparent)`,
|
||||
...style
|
||||
}
|
||||
: style ?? {}
|
||||
: (style ?? {})
|
||||
|
||||
return (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ function asRecord(payload: unknown): Record<string, unknown> {
|
|||
}
|
||||
|
||||
export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean {
|
||||
if (event.type !== 'tool.complete') {return false}
|
||||
if (event.type !== 'tool.complete') {
|
||||
return false
|
||||
}
|
||||
const diff = asRecord(event.payload).inline_diff
|
||||
|
||||
return typeof diff === 'string' && diff.trim().length > 0
|
||||
|
|
@ -20,7 +22,14 @@ export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean {
|
|||
|
||||
export function buildGatewayLogItems(lines: readonly string[]): readonly StatusbarMenuItem[] {
|
||||
if (lines.length === 0) {
|
||||
return [{ className: 'text-muted-foreground', disabled: true, id: 'gateway-log-empty', label: 'No recent gateway log lines' }]
|
||||
return [
|
||||
{
|
||||
className: 'text-muted-foreground',
|
||||
disabled: true,
|
||||
id: 'gateway-log-empty',
|
||||
label: 'No recent gateway log lines'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return lines.slice(-LOG_TAIL).map((line, index) => ({
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
IconGitBranch as GitBranch,
|
||||
IconGitBranch as GitBranchIcon,
|
||||
IconGlobe as Globe,
|
||||
IconHash as Hash,
|
||||
IconHelpCircle as HelpCircle,
|
||||
IconPhoto as ImageIcon,
|
||||
IconInfoCircle as Info,
|
||||
|
|
@ -120,6 +121,7 @@ export {
|
|||
GitBranch,
|
||||
GitBranchIcon,
|
||||
Globe,
|
||||
Hash,
|
||||
HelpCircle,
|
||||
ImageIcon,
|
||||
Info,
|
||||
|
|
|
|||
|
|
@ -50,13 +50,18 @@ function extension(value: string) {
|
|||
}
|
||||
|
||||
function joinPath(base: string, rel: string) {
|
||||
if (!base) {return rel}
|
||||
if (!base) {
|
||||
return rel
|
||||
}
|
||||
|
||||
return `${base.replace(/\/+$/, '')}/${rel.replace(/^\.?\//, '')}`
|
||||
}
|
||||
|
||||
function pathToFileUrl(path: string) {
|
||||
const encoded = path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||
const encoded = path
|
||||
.split('/')
|
||||
.map(part => encodeURIComponent(part))
|
||||
.join('/')
|
||||
|
||||
return `file://${encoded.startsWith('/') ? encoded : `/${encoded}`}`
|
||||
}
|
||||
|
|
@ -64,7 +69,9 @@ function pathToFileUrl(path: string) {
|
|||
export function localPreviewTarget(rawTarget: string, cwd?: string | null): PreviewTarget | null {
|
||||
const raw = rawTarget.trim().replace(/^`|`$/g, '')
|
||||
|
||||
if (!raw) {return null}
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
return { kind: 'url', label: basename(raw), source: raw, url: raw }
|
||||
|
|
@ -100,7 +107,10 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev
|
|||
}
|
||||
}
|
||||
|
||||
export async function normalizeOrLocalPreviewTarget(rawTarget: string, cwd?: string | null): Promise<PreviewTarget | null> {
|
||||
export async function normalizeOrLocalPreviewTarget(
|
||||
rawTarget: string,
|
||||
cwd?: string | null
|
||||
): Promise<PreviewTarget | null> {
|
||||
try {
|
||||
const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
extractPreviewTargets,
|
||||
previewTargetFromMarkdownHref,
|
||||
stripPreviewTargets
|
||||
} from './preview-targets'
|
||||
import { extractPreviewTargets, previewTargetFromMarkdownHref, stripPreviewTargets } from './preview-targets'
|
||||
|
||||
describe('preview target detection', () => {
|
||||
it('does not infer preview targets from raw paths or URLs', () => {
|
||||
|
|
|
|||
|
|
@ -61,4 +61,3 @@ export function previewDisplayLabel(target: string): string {
|
|||
|
||||
return `Preview: ${escaped}`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import './styles.css'
|
||||
import 'streamdown/styles.css'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { StrictMode } from 'react'
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ export function buildRailTasks(
|
|||
id: `preview:${previewRestart.taskId}`,
|
||||
label: 'Preview restart',
|
||||
detail: previewRestart.message || previewRestart.url,
|
||||
status: previewRestart.status === 'error' ? 'error' : previewRestart.status === 'running' ? 'running' : 'success',
|
||||
status:
|
||||
previewRestart.status === 'error' ? 'error' : previewRestart.status === 'running' ? 'running' : 'success',
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,17 +2,13 @@ import { atom, computed, type ReadableAtom } from 'nanostores'
|
|||
|
||||
import { arraysEqual, insertUniqueId, persistStringArray, storedStringArray } from '@/lib/storage'
|
||||
|
||||
import {
|
||||
$paneStates,
|
||||
ensurePaneRegistered,
|
||||
setPaneOpen,
|
||||
setPaneWidthOverride,
|
||||
togglePane
|
||||
} from './panes'
|
||||
import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride, togglePane } from './panes'
|
||||
|
||||
export const SIDEBAR_DEFAULT_WIDTH = 224
|
||||
export const SIDEBAR_MAX_WIDTH = 320
|
||||
export const FILE_BROWSER_DEFAULT_WIDTH = '17rem'
|
||||
export const FILE_BROWSER_MIN_WIDTH = '14rem'
|
||||
export const FILE_BROWSER_MAX_WIDTH = '20rem'
|
||||
|
||||
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
|
||||
|
||||
|
|
|
|||
|
|
@ -13,16 +13,22 @@ export interface PaneRegisterDefaults {
|
|||
const STORAGE_KEY = 'hermes.desktop.paneStates.v1'
|
||||
|
||||
function isSnapshot(value: unknown): value is PaneStateSnapshot {
|
||||
if (!value || typeof value !== 'object') {return false}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const r = value as Record<string, unknown>
|
||||
|
||||
if (typeof r.open !== 'boolean') {return false}
|
||||
if (typeof r.open !== 'boolean') {
|
||||
return false
|
||||
}
|
||||
|
||||
return r.widthOverride === undefined || (typeof r.widthOverride === 'number' && Number.isFinite(r.widthOverride))
|
||||
}
|
||||
|
||||
function load(): Record<string, PaneStateSnapshot> {
|
||||
if (typeof window === 'undefined') {return {}}
|
||||
if (typeof window === 'undefined') {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
|
|
@ -34,7 +40,9 @@ function load(): Record<string, PaneStateSnapshot> {
|
|||
const out: Record<string, PaneStateSnapshot> = {}
|
||||
|
||||
for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (isSnapshot(value)) {out[id] = { open: value.open, widthOverride: value.widthOverride }}
|
||||
if (isSnapshot(value)) {
|
||||
out[id] = { open: value.open, widthOverride: value.widthOverride }
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
|
|
@ -49,10 +57,14 @@ function load(): Record<string, PaneStateSnapshot> {
|
|||
|
||||
// widthOverride is in-memory only — phase 2 can add per-pane persistWidth opt-in.
|
||||
function persist(states: Record<string, PaneStateSnapshot>) {
|
||||
if (typeof window === 'undefined') {return}
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
const minimal: Record<string, { open: boolean }> = {}
|
||||
|
||||
for (const [id, s] of Object.entries(states)) {minimal[id] = { open: s.open }}
|
||||
for (const [id, s] of Object.entries(states)) {
|
||||
minimal[id] = { open: s.open }
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(minimal))
|
||||
|
|
@ -66,7 +78,11 @@ export const $paneStates = atom<Record<string, PaneStateSnapshot>>(load())
|
|||
$paneStates.subscribe(persist)
|
||||
|
||||
// Cached per-pane derived atoms keep useStore subscriptions referentially stable.
|
||||
function memoized<T>(cache: Map<string, ReadableAtom<T>>, id: string, selector: (s: PaneStateSnapshot | undefined) => T) {
|
||||
function memoized<T>(
|
||||
cache: Map<string, ReadableAtom<T>>,
|
||||
id: string,
|
||||
selector: (s: PaneStateSnapshot | undefined) => T
|
||||
) {
|
||||
let cached = cache.get(id)
|
||||
|
||||
if (!cached) {
|
||||
|
|
@ -88,7 +104,9 @@ export const $paneWidthOverride = (id: string) => memoized(widthCache, id, s =>
|
|||
export function ensurePaneRegistered(id: string, defaults: PaneRegisterDefaults) {
|
||||
const current = $paneStates.get()
|
||||
|
||||
if (current[id] !== undefined) {return}
|
||||
if (current[id] !== undefined) {
|
||||
return
|
||||
}
|
||||
$paneStates.set({ ...current, [id]: { open: defaults.open, widthOverride: defaults.widthOverride } })
|
||||
}
|
||||
|
||||
|
|
@ -96,7 +114,9 @@ export function setPaneOpen(id: string, open: boolean) {
|
|||
const current = $paneStates.get()
|
||||
const existing = current[id]
|
||||
|
||||
if (existing?.open === open) {return}
|
||||
if (existing?.open === open) {
|
||||
return
|
||||
}
|
||||
$paneStates.set({ ...current, [id]: { open, widthOverride: existing?.widthOverride } })
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +130,9 @@ export function setPaneWidthOverride(id: string, width: number | undefined) {
|
|||
const current = $paneStates.get()
|
||||
const existing = current[id] ?? { open: false }
|
||||
|
||||
if (existing.widthOverride === width) {return}
|
||||
if (existing.widthOverride === width) {
|
||||
return
|
||||
}
|
||||
$paneStates.set({ ...current, [id]: { open: existing.open, widthOverride: width } })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,13 @@ function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null):
|
|||
return false
|
||||
}
|
||||
|
||||
return a.kind === b.kind && a.label === b.label && a.renderMode === b.renderMode && a.source === b.source && a.url === b.url
|
||||
return (
|
||||
a.kind === b.kind &&
|
||||
a.label === b.label &&
|
||||
a.renderMode === b.renderMode &&
|
||||
a.source === b.source &&
|
||||
a.url === b.url
|
||||
)
|
||||
}
|
||||
|
||||
export function setPreviewTarget(target: PreviewTarget | null) {
|
||||
|
|
@ -72,7 +78,7 @@ export function setPreviewTarget(target: PreviewTarget | null) {
|
|||
$previewTarget.set(target)
|
||||
}
|
||||
|
||||
export function setFilePreviewTarget(target: PreviewTarget | null) {
|
||||
function setFilePreviewTarget(target: PreviewTarget | null) {
|
||||
if (isSamePreviewTarget($filePreviewTarget.get(), target)) {
|
||||
return
|
||||
}
|
||||
|
|
@ -80,8 +86,33 @@ export function setFilePreviewTarget(target: PreviewTarget | null) {
|
|||
$filePreviewTarget.set(target)
|
||||
}
|
||||
|
||||
// Manual/file-browser opens are "peeking at a file" → source view in the file
|
||||
// pane. Tool/explicit-link opens are runnable artifacts → live preview pane.
|
||||
function isFilePreviewSource(source: PreviewRecordSource): boolean {
|
||||
return source === 'file-browser' || source === 'manual'
|
||||
}
|
||||
|
||||
function previewTargetForSource(target: PreviewTarget, source: PreviewRecordSource): PreviewTarget {
|
||||
if (target.kind !== 'file' || target.previewKind !== 'html') {
|
||||
return target
|
||||
}
|
||||
|
||||
return { ...target, renderMode: isFilePreviewSource(source) ? 'source' : 'preview' }
|
||||
}
|
||||
|
||||
function tryOpenFilePreview(target: PreviewTarget, source: PreviewRecordSource): boolean {
|
||||
if (target.kind !== 'file' || !isFilePreviewSource(source)) {
|
||||
return false
|
||||
}
|
||||
setFilePreviewTarget(previewTargetForSource(target, source))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function isPreviewTarget(value: unknown): value is PreviewTarget {
|
||||
if (!value || typeof value !== 'object') {return false}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const r = value as Record<string, unknown>
|
||||
|
||||
return (
|
||||
|
|
@ -93,7 +124,9 @@ function isPreviewTarget(value: unknown): value is PreviewTarget {
|
|||
}
|
||||
|
||||
function isPreviewRecord(value: unknown): value is SessionPreviewRecord {
|
||||
if (!value || typeof value !== 'object') {return false}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const r = value as Record<string, unknown>
|
||||
|
||||
return (
|
||||
|
|
@ -108,22 +141,32 @@ function isPreviewRecord(value: unknown): value is SessionPreviewRecord {
|
|||
}
|
||||
|
||||
function loadSessionPreviewRegistry(): SessionPreviewRegistry {
|
||||
if (typeof window === 'undefined') {return {}}
|
||||
if (typeof window === 'undefined') {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(REGISTRY_STORAGE_KEY)
|
||||
|
||||
if (!raw) {return {}}
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {return {}}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return {}
|
||||
}
|
||||
const out: SessionPreviewRegistry = {}
|
||||
|
||||
for (const [sessionId, records] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (!Array.isArray(records)) {continue}
|
||||
if (!Array.isArray(records)) {
|
||||
continue
|
||||
}
|
||||
const valid = records.filter(isPreviewRecord).slice(0, MAX_RECORDS_PER_SESSION)
|
||||
|
||||
if (valid.length > 0) {out[sessionId] = valid}
|
||||
if (valid.length > 0) {
|
||||
out[sessionId] = valid
|
||||
}
|
||||
}
|
||||
|
||||
return pruneRegistry(out)
|
||||
|
|
@ -133,7 +176,9 @@ function loadSessionPreviewRegistry(): SessionPreviewRegistry {
|
|||
}
|
||||
|
||||
function persistSessionPreviewRegistry(registry: SessionPreviewRegistry) {
|
||||
if (typeof window === 'undefined') {return}
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(pruneRegistry(registry)))
|
||||
|
|
@ -144,10 +189,10 @@ function persistSessionPreviewRegistry(registry: SessionPreviewRegistry) {
|
|||
|
||||
function pruneRegistry(registry: SessionPreviewRegistry): SessionPreviewRegistry {
|
||||
const entries = Object.entries(registry)
|
||||
.map(([sessionId, records]) => [
|
||||
sessionId,
|
||||
[...records].sort((a, b) => b.createdAt - a.createdAt).slice(0, MAX_RECORDS_PER_SESSION)
|
||||
] as const)
|
||||
.map(
|
||||
([sessionId, records]) =>
|
||||
[sessionId, [...records].sort((a, b) => b.createdAt - a.createdAt).slice(0, MAX_RECORDS_PER_SESSION)] as const
|
||||
)
|
||||
.filter(([, records]) => records.length > 0)
|
||||
.sort(([, a], [, b]) => (b[0]?.createdAt ?? 0) - (a[0]?.createdAt ?? 0))
|
||||
.slice(0, MAX_SESSIONS)
|
||||
|
|
@ -171,7 +216,9 @@ export function registerSessionPreview(
|
|||
): SessionPreviewRecord | null {
|
||||
const id = sessionId?.trim()
|
||||
|
||||
if (!id) {return null}
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
|
||||
const current = $sessionPreviewRegistry.get()
|
||||
const now = Date.now()
|
||||
|
|
@ -199,38 +246,13 @@ export function registerSessionPreview(
|
|||
return nextRecord
|
||||
}
|
||||
|
||||
function previewTargetForSource(target: PreviewTarget, source: PreviewRecordSource): PreviewTarget {
|
||||
if (target.kind !== 'file' || target.previewKind !== 'html') {
|
||||
return target
|
||||
}
|
||||
|
||||
return {
|
||||
...target,
|
||||
renderMode: source === 'file-browser' || source === 'manual' ? 'source' : 'preview'
|
||||
}
|
||||
}
|
||||
|
||||
function shouldOpenAsFilePreview(target: PreviewTarget, source: PreviewRecordSource): boolean {
|
||||
return target.kind === 'file' && (source === 'file-browser' || source === 'manual')
|
||||
}
|
||||
|
||||
export function registerCurrentSessionPreview(
|
||||
target: PreviewTarget,
|
||||
source: PreviewRecordSource,
|
||||
rawTarget = target.source
|
||||
): SessionPreviewRecord | null {
|
||||
return registerSessionPreview(currentPreviewSessionId(), target, source, rawTarget)
|
||||
}
|
||||
|
||||
export function setSessionPreviewTarget(
|
||||
sessionId: string | null | undefined,
|
||||
target: PreviewTarget,
|
||||
source: PreviewRecordSource,
|
||||
rawTarget = target.source
|
||||
): SessionPreviewRecord | null {
|
||||
if (shouldOpenAsFilePreview(target, source)) {
|
||||
setFilePreviewTarget(previewTargetForSource(target, source))
|
||||
|
||||
if (tryOpenFilePreview(target, source)) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -247,24 +269,15 @@ export function setCurrentSessionPreviewTarget(
|
|||
source: PreviewRecordSource,
|
||||
rawTarget = target.source
|
||||
): SessionPreviewRecord | null {
|
||||
if (shouldOpenAsFilePreview(target, source)) {
|
||||
setFilePreviewTarget(previewTargetForSource(target, source))
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const record = registerCurrentSessionPreview(target, source, rawTarget)
|
||||
|
||||
setFilePreviewTarget(null)
|
||||
setPreviewTarget(record?.normalized ?? previewTargetForSource(target, source))
|
||||
|
||||
return record
|
||||
return setSessionPreviewTarget(currentPreviewSessionId(), target, source, rawTarget)
|
||||
}
|
||||
|
||||
export function getSessionPreviewRecord(sessionId: string | null | undefined): SessionPreviewRecord | null {
|
||||
const id = sessionId?.trim()
|
||||
|
||||
if (!id) {return null}
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return $sessionPreviewRegistry.get()[id]?.find(record => !record.dismissedAt && record.autoOpen !== false) ?? null
|
||||
}
|
||||
|
|
@ -272,15 +285,21 @@ export function getSessionPreviewRecord(sessionId: string | null | undefined): S
|
|||
export function dismissSessionPreview(sessionId: string | null | undefined, url?: string) {
|
||||
const id = sessionId?.trim()
|
||||
|
||||
if (!id) {return}
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
const current = $sessionPreviewRegistry.get()
|
||||
const records = current[id]
|
||||
|
||||
if (!records?.length) {return}
|
||||
if (!records?.length) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
const targetUrl = url || records.find(record => !record.dismissedAt)?.normalized.url
|
||||
|
||||
if (!targetUrl) {return}
|
||||
if (!targetUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
// The preview rail is a single active file, not a back stack. Dismissing the
|
||||
// current preview should leave the rail closed instead of revealing an older
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ const TOOL_VIEW_TECHNICAL_STORAGE_KEY = 'hermes.desktop.toolView.technical'
|
|||
const TOOL_DISCLOSURE_STORAGE_KEY = 'hermes.desktop.toolDisclosure.v1'
|
||||
const MAX_DISCLOSURE_STATES = 240
|
||||
|
||||
export const $toolViewMode = atom<ToolViewMode>(storedBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, false) ? 'technical' : 'product')
|
||||
export const $toolViewMode = atom<ToolViewMode>(
|
||||
storedBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, false) ? 'technical' : 'product'
|
||||
)
|
||||
export const $toolDisclosureStates = atom<ToolDisclosureStates>(loadToolDisclosureStates())
|
||||
|
||||
$toolViewMode.subscribe(mode => persistBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, mode === 'technical'))
|
||||
|
|
@ -21,15 +23,21 @@ export function setToolViewMode(mode: ToolViewMode) {
|
|||
}
|
||||
|
||||
function loadToolDisclosureStates(): ToolDisclosureStates {
|
||||
if (typeof window === 'undefined') {return {}}
|
||||
if (typeof window === 'undefined') {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(TOOL_DISCLOSURE_STORAGE_KEY)
|
||||
|
||||
if (!raw) {return {}}
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {return {}}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed as Record<string, unknown>)
|
||||
|
|
@ -42,7 +50,9 @@ function loadToolDisclosureStates(): ToolDisclosureStates {
|
|||
}
|
||||
|
||||
function persistToolDisclosureStates(states: ToolDisclosureStates) {
|
||||
if (typeof window === 'undefined') {return}
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = Object.entries(states).slice(-MAX_DISCLOSURE_STATES)
|
||||
|
|
@ -54,10 +64,14 @@ function persistToolDisclosureStates(states: ToolDisclosureStates) {
|
|||
}
|
||||
|
||||
export function setToolDisclosureOpen(id: string, open: boolean) {
|
||||
if (!id) {return}
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
const current = $toolDisclosureStates.get()
|
||||
|
||||
if (current[id] === open) {return}
|
||||
if (current[id] === open) {
|
||||
return
|
||||
}
|
||||
|
||||
$toolDisclosureStates.set({ ...current, [id]: open })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -344,22 +344,16 @@ canvas {
|
|||
max-width: 100%;
|
||||
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
font-size: 0.86em;
|
||||
padding: 0.01rem 0.16rem;
|
||||
border-radius: 0.2rem;
|
||||
background: #f4f4f5;
|
||||
color: #be185d;
|
||||
padding: 0.01rem 0.2rem;
|
||||
border-radius: 0.25rem;
|
||||
background: color-mix(in srgb, var(--dt-muted) 80%, transparent);
|
||||
color: var(--dt-muted-foreground);
|
||||
border: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
:root.dark [data-slot='aui_assistant-message-content'] .aui-md code {
|
||||
background: #27272a;
|
||||
color: #f9a8d4;
|
||||
border-color: #831843;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] code {
|
||||
max-width: none;
|
||||
font-family: inherit;
|
||||
|
|
|
|||
|
|
@ -14,9 +14,14 @@ export function useSkinCommand() {
|
|||
(rawArg: string) => {
|
||||
const arg = rawArg.trim()
|
||||
|
||||
if (!availableThemes.length) {return 'No desktop themes are available.'}
|
||||
if (!availableThemes.length) {
|
||||
return 'No desktop themes are available.'
|
||||
}
|
||||
|
||||
const activeIndex = Math.max(0, availableThemes.findIndex(t => t.name === themeName))
|
||||
const activeIndex = Math.max(
|
||||
0,
|
||||
availableThemes.findIndex(t => t.name === themeName)
|
||||
)
|
||||
|
||||
if (!arg || arg === 'next') {
|
||||
const next = availableThemes[(activeIndex + 1) % availableThemes.length]
|
||||
|
|
@ -33,9 +38,13 @@ export function useSkinCommand() {
|
|||
|
||||
const normalized = arg.toLowerCase()
|
||||
const targetName = ALIASES[normalized] || normalized
|
||||
const target = availableThemes.find(t => t.name.toLowerCase() === targetName || t.label.toLowerCase() === normalized)
|
||||
const target = availableThemes.find(
|
||||
t => t.name.toLowerCase() === targetName || t.label.toLowerCase() === normalized
|
||||
)
|
||||
|
||||
if (!target) {return `Unknown desktop theme: ${arg}\nAvailable: ${availableThemes.map(t => t.name).join(', ')}`}
|
||||
if (!target) {
|
||||
return `Unknown desktop theme: ${arg}\nAvailable: ${availableThemes.map(t => t.name).join(', ')}`
|
||||
}
|
||||
|
||||
setTheme(target.name)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue