Merge remote-tracking branch 'origin/main' into bb/pets-merge

# Conflicts:
#	hermes_cli/commands.py
#	tui_gateway/server.py
This commit is contained in:
Brooklyn Nicholson 2026-06-23 19:05:22 -05:00
commit e495b33bf1
251 changed files with 23395 additions and 2720 deletions

View file

@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { activeTimelineIndex, deriveTimelineEntries, timelinePreview } from './thread-timeline-data'
describe('timelinePreview', () => {
it('collapses whitespace to a single line', () => {
expect(timelinePreview('hello\n\n world\tagain')).toBe('hello world again')
})
it('truncates with an ellipsis past the limit', () => {
const out = timelinePreview('abcdefghij', 5)
expect(out).toBe('abcd…')
expect(out.length).toBe(5)
})
})
describe('deriveTimelineEntries', () => {
it('keeps non-empty user prompts in order', () => {
expect(
deriveTimelineEntries([
{ id: 'u1', role: 'user', text: 'first' },
{ id: 'a1', role: 'assistant', text: 'answer' },
{ id: 'u2', role: 'user', text: ' second ' }
])
).toEqual([
{ id: 'u1', preview: 'first' },
{ id: 'u2', preview: 'second' }
])
})
it('drops blanks and background-process notifications', () => {
expect(
deriveTimelineEntries([
{ id: 'u1', role: 'user', text: ' ' },
{ id: 'u2', role: 'user', text: '[IMPORTANT: Background process 123 finished]' },
{ id: 'u3', role: 'user', text: 'real prompt' }
]).map(e => e.id)
).toEqual(['u3'])
})
})
describe('activeTimelineIndex', () => {
it('returns the last prompt scrolled to or above the top edge', () => {
expect(activeTimelineIndex([-400, -10, 320])).toBe(1)
})
it('falls back to the first rendered entry', () => {
expect(activeTimelineIndex([null, 120, 480])).toBe(1)
expect(activeTimelineIndex([null, null])).toBe(0)
})
})

View file

@ -0,0 +1,75 @@
// Pure timeline helpers — no React/DOM; tested in thread-timeline-data.test.ts.
export interface TimelineSourceMessage {
id: string
role: string
text: string
}
export interface TimelineEntry {
id: string
preview: string
}
// Injected as user messages for alternation; not human prompts (thread.tsx).
const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
const PREVIEW_MAX = 120
export function timelinePreview(text: string, max: number = PREVIEW_MAX): string {
const collapsed = text.replace(/\s+/g, ' ').trim()
if (collapsed.length <= max) {
return collapsed
}
return `${collapsed.slice(0, max - 1).trimEnd()}`
}
export function deriveTimelineEntries(messages: readonly TimelineSourceMessage[]): TimelineEntry[] {
const entries: TimelineEntry[] = []
for (const message of messages) {
if (message.role !== 'user') {
continue
}
const text = message.text.trim()
if (!text || PROCESS_NOTIFICATION_RE.test(text)) {
continue
}
entries.push({ id: message.id, preview: timelinePreview(text) })
}
return entries
}
/** Last user prompt at/above the viewport top (with slack); else first rendered. */
export function activeTimelineIndex(offsets: readonly (number | null)[], slack: number = 8): number {
let active = -1
let firstRendered = -1
for (let i = 0; i < offsets.length; i++) {
const offset = offsets[i]
if (offset == null) {
continue
}
if (firstRendered === -1) {
firstRendered = i
}
if (offset <= slack) {
active = i
}
}
if (active !== -1) {
return active
}
return firstRendered === -1 ? 0 : firstRendered
}

View file

@ -0,0 +1,272 @@
import { useAuiState } from '@assistant-ui/react'
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { composerPanelCard } from '@/components/chat/composer-dock'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { setPaneHoverRevealSuppressed } from '@/store/panes'
import {
activeTimelineIndex,
deriveTimelineEntries,
type TimelineEntry,
type TimelineSourceMessage
} from './thread-timeline-data'
const MIN_ENTRIES = 4
const VIEWPORT = '[data-slot="aui_thread-viewport"]'
const HOVER_CLOSE_MS = 140
const ROW_CLASS =
'relative flex w-full min-w-0 max-w-full cursor-pointer select-none overflow-hidden rounded-md px-2 py-1 text-left outline-hidden transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none'
const POPOVER_SHELL = cn(
'absolute right-full top-1/2 z-50 mr-1.5 max-h-[min(22rem,calc(100vh-8rem))] w-80 max-w-[min(20rem,calc(100vw-2rem))] -translate-y-1/2 overflow-x-hidden overflow-y-auto overscroll-contain p-1 text-popover-foreground transition-[opacity,transform] duration-100 ease-out group-hover/timeline:transition-none',
composerPanelCard,
// Solid fill — composerPanelCard is deliberately translucent; without this,
// directive chips in the transcript bleed through and look like popover overflow.
'bg-(--composer-fill)'
)
function userPromptText(content: unknown): string {
if (typeof content === 'string') {
return content
}
if (!Array.isArray(content)) {
return ''
}
let out = ''
for (const part of content) {
if (typeof part === 'string') {
out += part
continue
}
if (!part || typeof part !== 'object') {
continue
}
const row = part as { text?: unknown; type?: unknown }
if ((!row.type || row.type === 'text') && typeof row.text === 'string') {
out += row.text
}
}
return out
}
function scrollToPrompt(id: string) {
const viewport = document.querySelector<HTMLElement>(VIEWPORT)
const node = viewport?.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
if (!viewport || !node) {
return
}
const top = viewport.scrollTop + (node.getBoundingClientRect().top - viewport.getBoundingClientRect().top) - 8
triggerHaptic('selection')
viewport.scrollTo({ behavior: 'smooth', top: Math.max(0, top) })
}
/** Right-edge prompt rail — hover previews, click to jump. ≥4 user turns only. */
export const ThreadTimeline: FC = () => {
const sourceSignature = useAuiState(s => {
const rows: TimelineSourceMessage[] = []
for (const message of s.thread.messages) {
if (message.role !== 'user') {
continue
}
rows.push({ id: message.id, role: 'user', text: userPromptText(message.content) })
}
return JSON.stringify(rows)
})
const entries = useMemo(
() => deriveTimelineEntries(JSON.parse(sourceSignature) as TimelineSourceMessage[]),
[sourceSignature]
)
const [activeIndex, setActiveIndex] = useState(0)
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
const [open, setOpen] = useState(false)
const closeTimerRef = useRef<number | undefined>(undefined)
const keepOpen = useCallback(() => {
window.clearTimeout(closeTimerRef.current)
setPaneHoverRevealSuppressed(true)
setOpen(true)
}, [])
const closeSoon = useCallback(() => {
window.clearTimeout(closeTimerRef.current)
setHoverIndex(null)
setPaneHoverRevealSuppressed(false)
closeTimerRef.current = window.setTimeout(() => setOpen(false), HOVER_CLOSE_MS)
}, [])
useEffect(
() => () => {
window.clearTimeout(closeTimerRef.current)
setPaneHoverRevealSuppressed(false)
},
[]
)
useEffect(() => {
if (entries.length < MIN_ENTRIES) {
setPaneHoverRevealSuppressed(false)
}
}, [entries.length])
useEffect(() => {
const viewport = document.querySelector<HTMLElement>(VIEWPORT)
if (!viewport || entries.length === 0) {
return
}
let raf = 0
const compute = () => {
raf = 0
const top = viewport.getBoundingClientRect().top
const offsets = entries.map(entry => {
const node = viewport.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(entry.id)}"]`)
return node ? node.getBoundingClientRect().top - top : null
})
const next = activeTimelineIndex(offsets)
setActiveIndex(prev => (prev === next ? prev : next))
}
const onScroll = () => {
if (!raf) {
raf = requestAnimationFrame(compute)
}
}
compute()
viewport.addEventListener('scroll', onScroll, { passive: true })
return () => {
viewport.removeEventListener('scroll', onScroll)
if (raf) {
cancelAnimationFrame(raf)
}
}
}, [entries])
if (entries.length < MIN_ENTRIES) {
return null
}
return (
<div
aria-label="Conversation timeline"
className="group/timeline pointer-events-auto absolute right-0 top-1/2 z-40 flex -translate-y-1/2 flex-col items-end"
data-slot="thread-timeline"
onMouseEnter={keepOpen}
onMouseLeave={closeSoon}
role="navigation"
>
<TimelineTicks
activeIndex={activeIndex}
entries={entries}
onHover={setHoverIndex}
onJump={scrollToPrompt}
/>
<TimelinePopover
activeIndex={activeIndex}
entries={entries}
hoverIndex={hoverIndex}
onHover={setHoverIndex}
onJump={scrollToPrompt}
open={open}
/>
</div>
)
}
const TimelinePopover: FC<{
activeIndex: number
entries: TimelineEntry[]
hoverIndex: number | null
onHover: (index: number) => void
onJump: (id: string) => void
open: boolean
}> = ({ activeIndex, entries, hoverIndex, onHover, onJump, open }) => (
<div
className={cn(
POPOVER_SHELL,
open ? 'pointer-events-auto opacity-100 translate-x-0' : 'pointer-events-none translate-x-1 opacity-0'
)}
data-slot="thread-timeline-popover"
>
{entries.map((entry, index) => {
const hovered = index === hoverIndex
const active = index === activeIndex
return (
<button
aria-label={entry.preview}
className={cn(
ROW_CLASS,
active && 'bg-(--ui-row-active-background) text-foreground',
hovered && 'bg-(--ui-row-hover-background) text-foreground transition-none'
)}
key={entry.id}
onClick={() => onJump(entry.id)}
onMouseEnter={() => onHover(index)}
type="button"
>
<span className="block w-full min-w-0 truncate font-medium leading-snug text-foreground">
{entry.preview}
</span>
</button>
)
})}
</div>
)
const TimelineTicks: FC<{
activeIndex: number
entries: TimelineEntry[]
onHover: (index: number) => void
onJump: (id: string) => void
}> = ({ activeIndex, entries, onHover, onJump }) => (
<div className="flex flex-col items-end py-1" data-slot="thread-timeline-ticks">
{entries.map((entry, index) => (
<button
aria-label={entry.preview}
className="group/tick flex h-2 w-7 cursor-pointer items-center justify-end pr-1"
key={entry.id}
onClick={() => onJump(entry.id)}
onMouseEnter={() => onHover(index)}
type="button"
>
<span
className={cn(
'block h-px w-3 transition-opacity duration-100 ease-out',
index === activeIndex
? 'bg-(--theme-primary)'
: 'dither text-(--ui-text-quaternary) opacity-70 group-hover/tick:opacity-100 group-hover/tick:transition-none'
)}
/>
</button>
))}
</div>
)

View file

@ -64,6 +64,7 @@ import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
import { ThreadMessageList } from '@/components/assistant-ui/thread-list'
import { ThreadTimeline } from '@/components/assistant-ui/thread-timeline'
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
@ -212,6 +213,7 @@ export const Thread: FC<{
sessionKey={sessionKey}
/>
{loading === 'session' && <CenteredThreadSpinner />}
<ThreadTimeline />
</div>
)
}
@ -797,7 +799,15 @@ function messageAttachmentRefs(value: unknown): string[] {
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
}
function StickyHumanMessageContainer({ attachments, children }: { attachments?: ReactNode; children: ReactNode }) {
function StickyHumanMessageContainer({
attachments,
children,
messageId
}: {
attachments?: ReactNode
children: ReactNode
messageId?: string
}) {
return (
// Fragment, not a wrapper: a wrapping element becomes the sticky's
// containing block (it'd stick within its own height = never). The bubble
@ -806,6 +816,7 @@ function StickyHumanMessageContainer({ attachments, children }: { attachments?:
<>
<div
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-1"
data-message-id={messageId}
data-role="user"
data-slot="aui_user-message-root"
>
@ -990,6 +1001,7 @@ const UserMessage: FC<{
return (
<MessagePrimitive.Root asChild>
<StickyHumanMessageContainer
messageId={messageId}
attachments={
// Attachments live BELOW the sticky bubble in normal flow, so they
// scroll away behind the pinned bubble instead of riding along with

View file

@ -2,7 +2,7 @@
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react'
import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useEffect, useMemo } from 'react'
import { AnsiText } from '@/components/assistant-ui/ansi-text'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
@ -10,7 +10,6 @@ import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { CompactMarkdown } from '@/components/chat/compact-markdown'
import { FileDiffPanel } from '@/components/chat/diff-lines'
import { DisclosureRow } from '@/components/chat/disclosure-row'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@ -25,6 +24,8 @@ import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } f
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { recordPreviewArtifact } from '@/store/preview-status'
import { $activeSessionId, $currentCwd } from '@/store/session'
import { $toolInlineDiffs } from '@/store/tool-diffs'
import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss'
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
@ -76,6 +77,8 @@ const TOOL_SECTION_LABEL_CLASS = 'mb-1 text-[0.65rem] font-medium uppercase trac
const TOOL_SECTION_SURFACE_CLASS =
'max-h-20 max-w-full overflow-auto bg-transparent px-2 py-1.5 text-(--ui-text-secondary)'
const TOOL_EXPANDED_SHELL_CLASS = 'rounded-[0.3125rem] border border-(--ui-stroke-tertiary)'
const TOOL_SECTION_PRE_CLASS = cn(TOOL_SECTION_SURFACE_CLASS, 'font-mono text-[0.7rem] leading-relaxed')
interface ToolStatusCopy {
@ -242,6 +245,22 @@ function ToolEntry({ part }: ToolEntryProps) {
return buildToolView(p, inlineDiff)
}, [inlineDiff, isPending, part])
// Surface a previewable artifact (HTML file / localhost URL) as a compact link
// in the composer status stack rather than a bulky inline card. Uses the same
// detected target the old inline card did, keyed to the active session the
// stack reads from. Idempotent + dedup'd, so re-renders don't churn.
const activeSessionId = useStore($activeSessionId)
const currentCwd = useStore($currentCwd)
const previewTarget = view.previewTarget
useEffect(() => {
if (isPending || !activeSessionId || !previewTarget || !isPreviewableTarget(previewTarget)) {
return
}
recordPreviewArtifact(activeSessionId, previewTarget, currentCwd || '')
}, [activeSessionId, currentCwd, isPending, previewTarget])
const detailSections = useMemo(() => {
if (!view.detail) {
return { body: '', summary: '' }
@ -291,12 +310,7 @@ function ToolEntry({ part }: ToolEntryProps) {
Boolean(view.rawResult.trim())
const hasExpandableContent = Boolean(
(view.previewTarget && isPreviewableTarget(view.previewTarget)) ||
view.imageUrl ||
view.inlineDiff ||
showDetail ||
hasSearchHits ||
toolViewMode === 'technical'
view.imageUrl || view.inlineDiff || showDetail || hasSearchHits || toolViewMode === 'technical'
)
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
@ -360,7 +374,7 @@ function ToolEntry({ part }: ToolEntryProps) {
<div
className={cn(
'min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
open && 'rounded-[0.625rem] border border-(--ui-stroke-tertiary)'
open && TOOL_EXPANDED_SHELL_CLASS
)}
data-file-edit={isFileEdit && open ? '' : undefined}
data-slot="tool-block"
@ -425,9 +439,6 @@ function ToolEntry({ part }: ToolEntryProps) {
text={copyAction.text}
/>
)}
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
<PreviewAttachment source="tool-result" target={view.previewTarget} />
)}
{view.imageUrl && (
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
<ZoomableImage alt={copy.outputAlt} className="h-auto w-full object-cover" src={view.imageUrl} />

View file

@ -104,16 +104,15 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
}
return (
<div className="flex w-full max-w-160 flex-wrap items-center gap-2.5 rounded-lg border border-border/55 bg-card/55 px-2.5 py-1.5 text-sm">
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground/85">
<div className="flex w-full max-w-160 items-center gap-2 rounded-lg border border-border/55 bg-card/55 px-2.5 py-1.5 text-sm">
<span className="grid size-6 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground/85">
<MonitorPlay className="size-3.5" />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[0.78rem] font-medium leading-[1.15rem] text-foreground/90">{name}</div>
<div className="truncate font-mono text-[0.66rem] leading-4 text-muted-foreground/70">{target}</div>
</div>
<span className="min-w-0 flex-1 truncate text-[0.78rem] font-medium text-foreground/90" title={target}>
{name}
</span>
<button
className="ml-auto shrink-0 rounded-md border border-border/55 bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50 max-[28rem]:ml-9 max-[28rem]:w-[calc(100%-2.25rem)]"
className="shrink-0 rounded-md border border-border/55 bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50"
disabled={opening}
onClick={() => void togglePreview()}
type="button"

View file

@ -15,7 +15,7 @@ import {
} from 'react'
import { cn } from '@/lib/utils'
import { $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes'
import { $paneHoverRevealSuppressed, $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes'
import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context'
@ -250,6 +250,7 @@ export function Pane({
}: PaneProps) {
const ctx = useContext(PaneShellContext)
const paneStates = useStore($paneStates)
const hoverRevealSuppressed = useStore($paneHoverRevealSuppressed)
const registered = useRef(false)
const paneRef = useRef<HTMLDivElement | null>(null)
// Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS.
@ -378,7 +379,10 @@ export function Pane({
>
<div
aria-hidden="true"
className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]"
className={cn(
'absolute inset-y-0 z-30 [-webkit-app-region:no-drag]',
hoverRevealSuppressed ? 'pointer-events-none' : 'pointer-events-auto'
)}
style={{ [edge]: HOVER_REVEAL_EDGE_GUTTER, width: HOVER_REVEAL_TRIGGER_WIDTH }}
/>
@ -388,7 +392,8 @@ export function Pane({
className={cn(
'pointer-events-none absolute inset-y-0 z-30 overflow-hidden transition-transform delay-0',
offscreen,
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
!hoverRevealSuppressed &&
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
'group-data-[forced]/reveal:pointer-events-auto group-data-[forced]/reveal:translate-x-0 group-data-[forced]/reveal:delay-0 group-data-[forced]/reveal:shadow-[var(--reveal-shadow)]'
)}
key={edge}