mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
Merge pull request #71872 from NousResearch/bb/desktop-ui-polish
Desktop UI polish: link chips, sidebar arc, tab strip rule
This commit is contained in:
commit
02721cc1c0
10 changed files with 139 additions and 46 deletions
|
|
@ -163,7 +163,9 @@ export function SidebarSessionRow({
|
|||
style={style}
|
||||
{...rest}
|
||||
>
|
||||
{sessionShowsRunningArc({ isWorking, needsInput }) && <span aria-hidden="true" className="arc-border" />}
|
||||
{sessionShowsRunningArc({ isWorking, needsInput }) && (
|
||||
<span aria-hidden="true" className="arc-border arc-row" />
|
||||
)}
|
||||
<SidebarRowBody
|
||||
className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')}
|
||||
// Middle-click = open in a new tab (browser muscle memory). Swallow
|
||||
|
|
|
|||
|
|
@ -510,7 +510,7 @@ export const SessionRefLink: FC<{
|
|||
|
||||
return (
|
||||
<a
|
||||
className="font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere"
|
||||
className="link-chip font-semibold wrap-anywhere"
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string
|
|||
return (
|
||||
<span className="block">
|
||||
<button
|
||||
className="mt-2 bg-transparent text-xs font-medium text-muted-foreground underline underline-offset-4 decoration-current/20 hover:text-foreground"
|
||||
className="mt-2 link-chip bg-transparent text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={open}
|
||||
type="button"
|
||||
>
|
||||
|
|
@ -196,7 +196,7 @@ function MediaAttachment({ path }: { path: string }) {
|
|||
return (
|
||||
<span className="wrap-anywhere">
|
||||
<a
|
||||
className="font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere"
|
||||
className="link-chip font-semibold wrap-anywhere"
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
|
|
@ -246,10 +246,7 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a
|
|||
if (!target || !/^https?:\/\//i.test(target)) {
|
||||
return (
|
||||
<a
|
||||
className={cn(
|
||||
'font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere',
|
||||
className
|
||||
)}
|
||||
className={cn('link-chip font-semibold wrap-anywhere', className)}
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
|
|
@ -324,7 +321,7 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
|
|||
<span className="my-2 block text-sm text-muted-foreground">
|
||||
Couldn't load {name}.{' '}
|
||||
<button
|
||||
className="bg-transparent font-medium text-foreground underline underline-offset-4 decoration-current/20 hover:text-foreground"
|
||||
className="link-chip bg-transparent font-medium text-foreground hover:text-foreground"
|
||||
onClick={open}
|
||||
type="button"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { ZoomableImage } from '@/components/chat/zoomable-image'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { FileTypeIcon } from '@/components/ui/file-type-icon'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
|
|
@ -96,6 +97,44 @@ const TOOL_EXPANDED_SHELL_CLASS = 'rounded-[0.3125rem] border border-(--ui-strok
|
|||
|
||||
const TOOL_SECTION_PRE_CLASS = cn(TOOL_SECTION_SURFACE_CLASS, 'font-mono text-[0.7rem] leading-relaxed')
|
||||
|
||||
// Raw args/result dump — reference material, so a notch smaller than a body.
|
||||
const TOOL_PAYLOAD_PRE_CLASS = cn(TOOL_SECTION_SURFACE_CLASS, 'font-mono text-[0.65rem] leading-relaxed')
|
||||
|
||||
/**
|
||||
* Technical-mode raw payload, behind a chevron disclosure.
|
||||
*
|
||||
* Collapsed by default — in technical mode every tool row carries one, and
|
||||
* expanding them all buries the transcript. Uses `DisclosureCaret` rather than
|
||||
* a native `<details>`, whose marker is a browser-drawn triangle matching
|
||||
* nothing else here.
|
||||
*/
|
||||
function ToolPayloadDisclosure({ args, result }: { args: unknown; result: unknown }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
// `py-0.5` tops up the parent's `p-1.5` to an even block on both edges.
|
||||
<div className="max-w-full py-0.5">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
TOOL_SECTION_LABEL_CLASS,
|
||||
'mb-0 flex items-center gap-1 bg-transparent transition-colors hover:text-(--ui-text-secondary)'
|
||||
)}
|
||||
onClick={() => setOpen(value => !value)}
|
||||
type="button"
|
||||
>
|
||||
<DisclosureCaret className="text-(--ui-text-tertiary)" open={open} size="0.625rem" />
|
||||
Tool payload
|
||||
</button>
|
||||
{open && (
|
||||
<pre className={cn(TOOL_PAYLOAD_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
|
||||
{technicalTrace(args, result)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ToolStatusCopy {
|
||||
statusDone: string
|
||||
statusError: string
|
||||
|
|
@ -616,19 +655,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
{toolViewMode === 'technical' && !(isFileEdit && view.inlineDiff) && (
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
||||
{technicalTrace(part.args, part.result)}
|
||||
</pre>
|
||||
)}
|
||||
{toolViewMode === 'technical' && isFileEdit && view.inlineDiff && (
|
||||
<details className="max-w-full">
|
||||
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0 cursor-pointer')}>Tool payload</summary>
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
|
||||
{technicalTrace(part.args, part.result)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{toolViewMode === 'technical' && <ToolPayloadDisclosure args={part.args} result={part.result} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { ComponentProps, ElementType, FC } from 'react'
|
|||
import { memo } from 'react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
import { ExternalLink, ExternalLinkIcon } from '@/lib/external-link'
|
||||
import { ExternalLink } from '@/lib/external-link'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Compact markdown renderer for tool detail bodies. Same Streamdown pipeline
|
||||
|
|
@ -42,20 +42,15 @@ function tagged<T extends keyof typeof TAG_CLASSES>(Tag: T) {
|
|||
function MarkdownAnchor({ children, className, href, ...rest }: ComponentProps<'a'>) {
|
||||
if (!href || !/^https?:\/\//i.test(href)) {
|
||||
return (
|
||||
<a
|
||||
className={cn('font-medium underline underline-offset-4 decoration-current/20', className)}
|
||||
href={href}
|
||||
{...rest}
|
||||
>
|
||||
<a className={cn('link-chip font-medium', className)} href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ExternalLink className={cn('decoration-current/20', className)} href={href} showExternalIcon={false}>
|
||||
<ExternalLink className={className} href={href}>
|
||||
{children}
|
||||
<ExternalLinkIcon />
|
||||
</ExternalLink>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export const GeneratedImage: FC<{ aspectRatio?: string; result?: unknown }> = ({
|
|||
if (failed && image) {
|
||||
return (
|
||||
<a
|
||||
className="mt-2 inline-block font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere"
|
||||
className="mt-2 link-chip inline-block font-semibold wrap-anywhere"
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
|
|
|
|||
|
|
@ -12,17 +12,23 @@ export const PANE_TAB_STRIP_LINE_RIGHT = 'shadow-[inset_-1px_0_0_var(--ui-stroke
|
|||
const TAB =
|
||||
'group/tab relative flex shrink-0 items-center border-transparent bg-(--tab-bg) text-[0.6875rem] font-medium [-webkit-app-region:no-drag]'
|
||||
|
||||
const TAB_HORIZONTAL = 'h-full min-w-0 max-w-48 border-b not-first:border-l not-first:border-l-(--ui-stroke-quaternary)'
|
||||
// Stop 1px short: the strip's rule is an INSET shadow painted in the
|
||||
// container's own last pixel row, so a full-height tab covers it and reads as
|
||||
// overhanging the bar. The container owns the line; nothing redraws it.
|
||||
const TAB_HORIZONTAL =
|
||||
'h-[calc(100%-1px)] min-w-0 max-w-48 not-first:border-l not-first:border-l-(--ui-stroke-quaternary)'
|
||||
|
||||
const TAB_VERTICAL =
|
||||
'w-full max-h-48 justify-center not-first:border-t not-first:border-t-(--ui-stroke-quaternary) [writing-mode:vertical-rl]'
|
||||
|
||||
const TAB_ACTIVE = 'text-foreground [--tab-bg:var(--pane-tab-active-bg,var(--ui-editor-surface-background))]'
|
||||
// Full height, so the active tab alone covers that row and cuts the rule.
|
||||
const TAB_ACTIVE =
|
||||
'h-full text-foreground [--tab-bg:var(--pane-tab-active-bg,var(--ui-editor-surface-background))]'
|
||||
|
||||
// Inactive = gutter. Hover = 4% translucent wash (VS Code/GitHub alpha hover),
|
||||
// not an opaque recolor — and never touch borders.
|
||||
// Inactive = gutter. Hover DARKENS: active is the lighter content surface, so a
|
||||
// lightening wash made the two nearly indistinguishable.
|
||||
const TAB_IDLE =
|
||||
'text-(--ui-text-tertiary) [--tab-bg:var(--pane-tab-strip-bg,var(--theme-card-seed))] hover:shadow-[inset_0_0_0_100vmax_color-mix(in_srgb,var(--ui-base)_4%,transparent)] hover:text-(--ui-text-secondary)'
|
||||
'text-(--ui-text-tertiary) [--tab-bg:var(--pane-tab-strip-bg,var(--theme-card-seed))] hover:shadow-[inset_0_0_0_100vmax_color-mix(in_srgb,#000_var(--ui-tab-hover-darken),transparent)] hover:text-(--ui-text-secondary)'
|
||||
|
||||
interface PaneTabProps extends React.ComponentProps<'div'> {
|
||||
active?: boolean
|
||||
|
|
@ -66,9 +72,9 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
|
|||
},
|
||||
ref
|
||||
) {
|
||||
// Content-facing edge: horizontal cuts the bottom strip line; vertical cuts
|
||||
// the side that faces the editor (left rail → right edge, right rail → left).
|
||||
const edge = vertical ? (side === 'right' ? 'border-l' : 'border-r') : 'border-b'
|
||||
// Vertical rails only. Horizontal tabs draw no bottom border — the strip owns
|
||||
// that rule, and a per-tab border stacked a second translucent line over it.
|
||||
const edge = vertical ? (side === 'right' ? 'border-l' : 'border-r') : undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -76,7 +82,7 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
|
|||
TAB,
|
||||
vertical ? TAB_VERTICAL : TAB_HORIZONTAL,
|
||||
edge,
|
||||
active ? TAB_ACTIVE : cn(TAB_IDLE, `${edge}-(--ui-stroke-tertiary)`),
|
||||
active ? TAB_ACTIVE : cn(TAB_IDLE, edge && `${edge}-(--ui-stroke-tertiary)`),
|
||||
className
|
||||
)}
|
||||
data-active={active}
|
||||
|
|
|
|||
|
|
@ -112,11 +112,24 @@ describe('external link helpers', () => {
|
|||
expect(openExternal).toHaveBeenCalledWith('https://example.com/path/to/resource')
|
||||
})
|
||||
|
||||
it('shows a trailing external-link icon', () => {
|
||||
it('hides the trailing external-link icon by default', () => {
|
||||
installDesktopBridge()
|
||||
|
||||
render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Example link' })
|
||||
expect(link.querySelector('svg')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a trailing external-link icon when opted in', () => {
|
||||
installDesktopBridge()
|
||||
|
||||
render(
|
||||
<ExternalLink href="https://example.com/path/to/resource" showExternalIcon>
|
||||
Example link
|
||||
</ExternalLink>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Example link' })
|
||||
expect(link.querySelector('svg')).toBeTruthy()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -215,14 +215,14 @@ export function ExternalLink({
|
|||
className,
|
||||
href,
|
||||
onClick,
|
||||
showExternalIcon = true,
|
||||
showExternalIcon = false,
|
||||
...rest
|
||||
}: ExternalLinkProps) {
|
||||
const target = normalizeExternalUrl(href)
|
||||
|
||||
return (
|
||||
<a
|
||||
className={cn('font-semibold text-foreground underline underline-offset-4 decoration-current/20', className)}
|
||||
className={cn('link-chip font-semibold', className)}
|
||||
href={target}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
|
|
|
|||
|
|
@ -317,6 +317,10 @@
|
|||
:root.dark bumps the alpha since a dark card swallows shadow. Removed on
|
||||
focus (the focus border carries the state). */
|
||||
--dt-input-inset: inset 0 1px 1px color-mix(in srgb, #000 10%, transparent);
|
||||
/* Hovered inactive tab: deepen it AWAY from the lighter active surface, so
|
||||
hover can never be mistaken for selected. Dark mode needs more — black
|
||||
over a dark gutter shows less. */
|
||||
--ui-tab-hover-darken: 2.5%;
|
||||
--dt-ring: var(--ui-stroke-primary);
|
||||
--dt-midground: var(--theme-midground);
|
||||
--dt-composer-ring: var(--ui-base);
|
||||
|
|
@ -446,6 +450,7 @@
|
|||
--backdrop-invert-mul: 0;
|
||||
/* Dark mode: a dark card needs a stronger black inset to show the recess. */
|
||||
--dt-input-inset: inset 0 1px 1px color-mix(in srgb, #000 38%, transparent);
|
||||
--ui-tab-hover-darken: 6%;
|
||||
/* Dark needs a lighter resting border than light mode. */
|
||||
--dt-input-border: 4%;
|
||||
|
||||
|
|
@ -514,6 +519,32 @@
|
|||
background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 0.125rem 0.125rem;
|
||||
}
|
||||
|
||||
/* Inline content links: a small tinted chip instead of an underline. Tint is
|
||||
currentColor-relative (the old `decoration-current/20` idiom), so text and
|
||||
fill share a hue on every theme from one `color` declaration. */
|
||||
.link-chip {
|
||||
--link-chip-tint: 8%;
|
||||
|
||||
/* `ch`/`em` so the chip tracks the text at any size. Block padding is
|
||||
asymmetric because an inline box's content area is ascent+descent — equal
|
||||
padding paints the glyphs high. Keep it small: `padding-block` doesn't grow
|
||||
the line box, so an over-padded chip creeps into the line above. */
|
||||
padding: 0.05ch 0.5ch 0.2ch;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--dt-primary);
|
||||
background: color-mix(in srgb, currentColor var(--link-chip-tint), transparent);
|
||||
/* Explicit: the base layer underlines every `a` (see the :where(a, …) reset). */
|
||||
text-decoration: none;
|
||||
/* A wrapped link gets a chip per line fragment, not one ragged box. */
|
||||
-webkit-box-decoration-break: clone;
|
||||
box-decoration-break: clone;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.link-chip:hover {
|
||||
--link-chip-tint: 15%;
|
||||
}
|
||||
|
||||
/* Hover-reveal suppression — the shared, declarative escape hatch.
|
||||
A collapsed pane slides in when the pointer dwells on its thin edge trigger.
|
||||
Controls that sit over that edge gutter would drag the panel in by accident.
|
||||
|
|
@ -610,14 +641,19 @@
|
|||
--arc-c2: var(--dt-background);
|
||||
--arc-angle: 160deg;
|
||||
--arc-width: 0.078125rem;
|
||||
--arc-inset: -0.125rem;
|
||||
/* How far the ring stands off its host, per side. */
|
||||
--arc-standoff: 0.125rem;
|
||||
/* An outset ring is only concentric when its radius grows by the standoff
|
||||
(r = r_host + gap); `border-radius: inherit` leaves the corner pinched.
|
||||
Defaults to the shared `rounded-md`; a host on another radius sets it. */
|
||||
--arc-radius: var(--radius-md);
|
||||
--arc-duration: 2.23s;
|
||||
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
inset: var(--arc-inset);
|
||||
border-radius: calc(var(--arc-radius) + var(--arc-standoff));
|
||||
inset: calc(var(--arc-standoff) * -1);
|
||||
padding: var(--arc-width);
|
||||
mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
|
|
@ -719,6 +755,23 @@
|
|||
--arc-duration: 3.27s;
|
||||
}
|
||||
|
||||
/* Sidebar session row — a thin, clipped, single-line host, so it differs twice
|
||||
from the default ring:
|
||||
|
||||
1. Flush, not outset. The sidebar scroller is `overflow-x-hidden` and rows sit
|
||||
`gap-px` apart, so an outset ring loses its left/right runs to the clip.
|
||||
|
||||
2. A visible tail. `--arc-c2` defaults to `--dt-background` — invisible
|
||||
against the sidebar. With c0 a transparent sentinel and c2 invisible, only
|
||||
the bright c1 stop survives each pass, and the gradient has three of them:
|
||||
three moving dots instead of an arc. Tying c2 to c1 gives each spike a
|
||||
falloff. (`arc-nous` already overrides c2, so the hero is unaffected.) */
|
||||
.arc-border.arc-row,
|
||||
:root.dark .arc-border.arc-row {
|
||||
--arc-standoff: 0rem;
|
||||
--arc-c2: color-mix(in srgb, var(--arc-c1) 45%, transparent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.arc-border::before {
|
||||
animation: none;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue