mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
feat(desktop): open the session a @session ref names
An agent-written ref rendered as a chip that went nowhere on click. It now renders as an ordinary inline link — the agent wrote it mid-sentence, so it should read like one — with the funnel icon leading the resolved title. Clicking either surface (that link, or the chip in the user's own message) opens the session as a tab, the way its sidebar row does. The tile store loads on click rather than at import: the composer's rich editor pulls this module in, so a static import would boot the profile store and its REST routing along with every transcript render.
This commit is contained in:
parent
99ab036291
commit
9edbc68d0f
4 changed files with 132 additions and 28 deletions
|
|
@ -7,9 +7,11 @@ import { Fragment, useEffect, useMemo, useState } from 'react'
|
|||
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { extractEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { gatewayMediaDataUrl, isRemoteGateway } from '@/lib/media'
|
||||
import { useSessionLinkTitle } from '@/lib/session-link-title'
|
||||
import { sessionRefFallbackLabel } from '@/lib/session-refs'
|
||||
import { parseSessionRefValue, sessionRefFallbackLabel } from '@/lib/session-refs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const
|
||||
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
|
||||
|
|
@ -121,9 +123,12 @@ export function slashIconElement(kind: SlashChipKind) {
|
|||
return iconElementFromPaths(SLASH_ICON_PATHS[kind])
|
||||
}
|
||||
|
||||
const DirectiveIcon: FC<{ type: string }> = ({ type }) => (
|
||||
const DirectiveIcon: FC<{ type: string; className?: string }> = ({
|
||||
type,
|
||||
className = 'size-3 shrink-0 opacity-80'
|
||||
}) => (
|
||||
<svg
|
||||
className="size-3 shrink-0 opacity-80"
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
|
|
@ -447,31 +452,87 @@ const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
|
|||
)
|
||||
}
|
||||
|
||||
/** A `@session:<profile>/<id>` reference, rendered as the session's title once
|
||||
* resolved. Shared by the user transcript (directive segments) and assistant
|
||||
* markdown (`#session/` links rewritten in `preprocessMarkdown`). */
|
||||
/** Opens the referenced session as a tab — same as middle-clicking its sidebar
|
||||
* row. The tile store loads on click, not at import: the composer's rich
|
||||
* editor pulls this module in, and a static import would boot the profile
|
||||
* store (and its REST routing) along with it. */
|
||||
function openSessionRef(value: string) {
|
||||
const { sessionId } = parseSessionRefValue(value)
|
||||
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('selection')
|
||||
void import('@/store/session-states').then(({ openSessionTile }) => openSessionTile(sessionId, 'center'))
|
||||
}
|
||||
|
||||
/** A `@session:<profile>/<id>` reference in the user transcript (directive
|
||||
* segments), rendered as a chip like the other composer refs. Clicking it
|
||||
* opens the session as a tab. */
|
||||
export const SessionRefChip: FC<{
|
||||
label?: string
|
||||
value: string
|
||||
}> = ({ label, value }) => {
|
||||
const resolved = useSessionLinkTitle(value, label)
|
||||
|
||||
return <DirectiveChip id={value} label={resolved} type="session" />
|
||||
return <DirectiveChip id={value} label={resolved} onClick={() => openSessionRef(value)} type="session" />
|
||||
}
|
||||
|
||||
/** A `@session:` reference in assistant markdown (`#session/` links rewritten
|
||||
* in `preprocessMarkdown`). Reads as an ordinary inline link — the agent wrote
|
||||
* it mid-sentence — with the funnel icon leading the resolved title. */
|
||||
export const SessionRefLink: FC<{
|
||||
label?: string
|
||||
value: string
|
||||
}> = ({ label, value }) => {
|
||||
const resolved = useSessionLinkTitle(value, label)
|
||||
|
||||
return (
|
||||
<a
|
||||
className="font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere"
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openSessionRef(value)
|
||||
}}
|
||||
title={value}
|
||||
>
|
||||
<DirectiveIcon className="mr-1 inline size-[0.82em] align-[-0.08em] opacity-70" type="session" />
|
||||
{resolved}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/** Inert by default; `onClick` promotes the chip to a real button (session
|
||||
* refs, which open the session they name). */
|
||||
const DirectiveChip: FC<{
|
||||
type: string
|
||||
label: string
|
||||
id: string
|
||||
}> = ({ 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>
|
||||
)
|
||||
onClick?: () => void
|
||||
}> = ({ type, label, id, onClick }) => {
|
||||
const body = (
|
||||
<>
|
||||
<DirectiveIcon type={type} />
|
||||
<span className="truncate">{label}</span>
|
||||
</>
|
||||
)
|
||||
|
||||
const props = {
|
||||
className: cn(DIRECTIVE_CHIP_CLASS, onClick && 'cursor-pointer transition-colors hover:text-foreground'),
|
||||
'data-directive-id': id,
|
||||
'data-directive-type': type,
|
||||
'data-slot': 'aui_directive-chip',
|
||||
title: id
|
||||
}
|
||||
|
||||
return onClick ? (
|
||||
<button {...props} onClick={onClick} type="button">
|
||||
{body}
|
||||
</button>
|
||||
) : (
|
||||
<span {...props}>{body}</span>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,26 +35,26 @@ afterEach(() => {
|
|||
|
||||
// End-to-end for the agent-authored path: a bare ref in assistant markdown has
|
||||
// to survive preprocessMarkdown -> Streamdown -> MarkdownLink and come out as
|
||||
// the session chip, not as literal text or a dead anchor.
|
||||
// an inline link titled after the session, not as literal text.
|
||||
describe('MarkdownTextContent session refs', () => {
|
||||
it('renders an agent-written @session ref as a chip showing the session title', async () => {
|
||||
it('renders an agent-written @session ref as a link showing the session title', async () => {
|
||||
$sessions.set([makeSession()])
|
||||
|
||||
render(<MarkdownTextContent isRunning={false} text="Context lives in @session:work/20260101_abc123 today." />)
|
||||
|
||||
const chip = await screen.findByTitle('work/20260101_abc123')
|
||||
const link = await screen.findByTitle('work/20260101_abc123')
|
||||
|
||||
expect(chip.dataset.directiveType).toBe('session')
|
||||
expect(chip.textContent).toBe('Branch plan')
|
||||
expect(link.tagName).toBe('A')
|
||||
expect(link.textContent).toBe('Branch plan')
|
||||
expect(screen.queryByText(/@session:/)).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to a short id when the session is unknown', async () => {
|
||||
render(<MarkdownTextContent isRunning={false} text="See @session:work/20260101_abc123 for context." />)
|
||||
|
||||
const chip = await screen.findByTitle('work/20260101_abc123')
|
||||
const link = await screen.findByTitle('work/20260101_abc123')
|
||||
|
||||
expect(chip.textContent).toBe('20260101…')
|
||||
expect(link.textContent).toBe('20260101…')
|
||||
})
|
||||
|
||||
it('leaves a ref inside inline code as literal text', () => {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
|
|||
import { sessionRefFromMarkdownHref } from '@/lib/session-refs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { SessionRefChip } from './directive-text'
|
||||
import { SessionRefLink } from './directive-text'
|
||||
import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds'
|
||||
|
||||
// Math rendering plugin (KaTeX). Configured once at module scope — the
|
||||
|
|
@ -238,7 +238,7 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a
|
|||
const sessionRef = sessionRefFromMarkdownHref(href)
|
||||
|
||||
if (sessionRef) {
|
||||
return <SessionRefChip value={sessionRef} />
|
||||
return <SessionRefLink value={sessionRef} />
|
||||
}
|
||||
|
||||
const target = href ? normalizeExternalUrl(href) : href
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { __resetSessionLinkTitleCache } from '@/lib/session-link-title'
|
||||
|
||||
import { DirectiveContent } from './directive-text'
|
||||
import { MarkdownTextContent } from './markdown-text'
|
||||
|
||||
const openSessionTile = vi.fn()
|
||||
|
||||
vi.mock('@/store/session-states', () => ({
|
||||
openSessionTile: (...args: unknown[]) => openSessionTile(...args)
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
openSessionTile.mockClear()
|
||||
__resetSessionLinkTitleCache()
|
||||
})
|
||||
|
||||
// Both surfaces render a session ref differently — an inline link in agent
|
||||
// prose, a chip in the user's own message — but either one opens the session
|
||||
// it names, the way its sidebar row would.
|
||||
describe('session refs open the session', () => {
|
||||
it('opens the session from an agent-written link', async () => {
|
||||
render(<MarkdownTextContent isRunning={false} text="Picked up in @session:work/20260101_abc123 last night." />)
|
||||
|
||||
fireEvent.click(await screen.findByTitle('work/20260101_abc123'))
|
||||
|
||||
await vi.waitFor(() => expect(openSessionTile).toHaveBeenCalledWith('20260101_abc123', 'center'))
|
||||
})
|
||||
|
||||
it('opens the session from a chip in the user transcript', async () => {
|
||||
render(<DirectiveContent text="pick up @session:work/20260101_abc123 please" />)
|
||||
|
||||
const chip = screen.getByTitle('work/20260101_abc123')
|
||||
|
||||
expect(chip.tagName).toBe('BUTTON')
|
||||
fireEvent.click(chip)
|
||||
|
||||
await vi.waitFor(() => expect(openSessionTile).toHaveBeenCalledWith('20260101_abc123', 'center'))
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue