From 9edbc68d0f4d3ff144de7319b4e8e4ae4078cb38 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 12:09:36 -0500 Subject: [PATCH] feat(desktop): open the session a @session ref names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../assistant-ui/directive-text.tsx | 99 +++++++++++++++---- .../markdown-text.session.test.tsx | 14 +-- .../components/assistant-ui/markdown-text.tsx | 4 +- .../assistant-ui/session-ref-open.test.tsx | 43 ++++++++ 4 files changed, 132 insertions(+), 28 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index 6788365da154..6ea2580f16b8 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -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' +}) => ( = ({ id, label }) => { ) } -/** A `@session:/` 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:/` 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 + return 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 ( + { + event.preventDefault() + event.stopPropagation() + openSessionRef(value) + }} + title={value} + > + + {resolved} + + ) +} + +/** 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 }) => ( - - - {label} - -) + onClick?: () => void +}> = ({ type, label, id, onClick }) => { + const body = ( + <> + + {label} + + ) + + 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 ? ( + + ) : ( + {body} + ) +} diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.session.test.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.session.test.tsx index e96ecc1acb60..6c7b02cf7af2 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.session.test.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.session.test.tsx @@ -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() - 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() - 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', () => { diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index 61983dd4c8f4..7684a4c4240c 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -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 + return } const target = href ? normalizeExternalUrl(href) : href diff --git a/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx b/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx new file mode 100644 index 000000000000..de3674f529ca --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx @@ -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() + + 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() + + const chip = screen.getByTitle('work/20260101_abc123') + + expect(chip.tagName).toBe('BUTTON') + fireEvent.click(chip) + + await vi.waitFor(() => expect(openSessionTile).toHaveBeenCalledWith('20260101_abc123', 'center')) + }) +})