Skip to content

Commit 146f238

Browse files
authored
feat(agents-desktop): UI improvements (#4527)
## Summary This PR collects several agents desktop/server UI improvements: - Adds a nested Horton model settings menu that groups model, reasoning, and speed choices, with the closed trigger showing the model and lighter thinking-level text. - Fixes select/menu presentation issues, including cropped descenders in select triggers and excess dropdown bottom spacing. - Adds and corrects tooltips for spawn form/chat timeline controls, including attach, model, working directory, send, runner, and sandbox controls. - Renames the local sandbox option to `No sandbox`. - Refines macOS desktop sidebar vibrancy by using transparent chrome with backdrop-filter tinting, visible backdrop-aware hover/active states for sidebar rows and controls, blended full-width dividers, and light-mode-only content edge shadows. - Moves the timeline “Fork from here” action from a hover-only historical prompt button to an always-visible assistant response footer action beside copy response. ## Validation - `git diff --check` - `asdf exec corepack pnpm --filter @electric-ax/agents-server-ui typecheck`
1 parent d15852d commit 146f238

27 files changed

Lines changed: 1000 additions & 269 deletions

‎.changeset/fuzzy-agents-ui.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@electric-ax/agents-server-ui": patch
3+
---
4+
5+
Polish the agents UI with improved spawn-form model controls, tooltips,
6+
macOS sidebar vibrancy styling, select sizing fixes, and a response-footer
7+
fork action in the timeline.

‎packages/agents-server-ui/src/components/AgentResponse.module.css‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,22 @@
2929
width: 100%;
3030
}
3131

32-
.copyButton {
32+
.metaActions {
3333
margin-left: auto;
34+
display: inline-flex;
35+
align-items: center;
36+
gap: 2px;
37+
}
38+
39+
.tooltipTrigger {
40+
display: inline-flex;
41+
}
42+
43+
.metaActionButton {
3444
color: var(--ds-text-4);
3545
opacity: 0.7;
3646
}
3747

38-
.copyButton:hover {
48+
.metaActionButton:hover {
3949
opacity: 1;
4050
}

‎packages/agents-server-ui/src/components/AgentResponse.tsx‎

Lines changed: 76 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Check, Copy } from 'lucide-react'
1+
import { Check, Copy, GitFork } from 'lucide-react'
22
import {
33
memo,
44
useEffect,
@@ -28,6 +28,7 @@ import { ThinkingIndicator } from './ThinkingIndicator'
2828
import { ElapsedTime } from './ElapsedTime'
2929
import { formatElapsedDuration, toMillis } from '../lib/formatTime'
3030
import styles from './AgentResponse.module.css'
31+
import type { ForkFromHereAction } from './UserMessage'
3132
import type {
3233
EntityTimelineContentItem,
3334
EntityTimelineRunRow,
@@ -383,13 +384,15 @@ export const AgentResponseLive = memo(function AgentResponseLive({
383384
isStreaming,
384385
timestamp,
385386
renderWidth = 0,
387+
forkFromHere,
386388
onSearchTextChange,
387389
}: {
388390
rowKey: string
389391
run: EntityTimelineRunRow
390392
isStreaming: boolean
391393
timestamp?: number | null
392394
renderWidth?: number
395+
forkFromHere?: ForkFromHereAction
393396
onSearchTextChange?: (rowKey: string, text: string) => void
394397
}): React.ReactElement {
395398
const { data: items = [] } = useLiveQuery(
@@ -433,6 +436,7 @@ export const AgentResponseLive = memo(function AgentResponseLive({
433436
isStreaming && !done && !failureText && !lastTextHasContent
434437
const showTimestamp = timestamp != null && !isStreaming
435438
const hasLeadingMeta = showThinking || done || Boolean(failureText)
439+
const showCopyAction = Boolean((done || failureText) && copyText)
436440
const [copied, setCopied] = useState(false)
437441
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
438442

@@ -542,39 +546,88 @@ export const AgentResponseLive = memo(function AgentResponseLive({
542546
<TimeText ts={timestamp} className={styles.timeText} />
543547
</>
544548
)}
545-
{(done || failureText) && copyText && (
546-
<Tooltip content={copied ? `Copied!` : `Copy response`} side="top">
549+
<ResponseMetaActions
550+
showCopy={showCopyAction}
551+
copied={copied}
552+
onCopy={() => void copyResponseText()}
553+
forkFromHere={done ? forkFromHere : undefined}
554+
/>
555+
</Stack>
556+
</Stack>
557+
)
558+
})
559+
560+
function ResponseMetaActions({
561+
showCopy,
562+
copied,
563+
onCopy,
564+
forkFromHere,
565+
}: {
566+
showCopy: boolean
567+
copied: boolean
568+
onCopy: () => void
569+
forkFromHere?: ForkFromHereAction
570+
}): React.ReactElement | null {
571+
const showFork = forkFromHere !== undefined
572+
if (!showCopy && !showFork) return null
573+
574+
const forkDisabled = forkFromHere?.disabled === true || !forkFromHere?.onFork
575+
const forkLabel = forkDisabled ? `Fork permission required` : `Fork from here`
576+
577+
return (
578+
<span className={styles.metaActions}>
579+
{showFork && (
580+
<Tooltip content={forkLabel} side="top">
581+
<span className={styles.tooltipTrigger}>
547582
<IconButton
548583
size={1}
549584
variant="ghost"
550585
tone="neutral"
551-
className={styles.copyButton}
552-
onClick={() => void copyResponseText()}
553-
aria-label="Copy response text"
586+
className={styles.metaActionButton}
587+
disabled={forkDisabled}
588+
onClick={forkFromHere?.onFork}
589+
aria-label="Fork from here"
590+
title={forkLabel}
554591
>
555-
{copied ? (
556-
<Icon icon={Check} size={1} />
557-
) : (
558-
<Icon icon={Copy} size={1} />
559-
)}
592+
<Icon icon={GitFork} size={1} />
560593
</IconButton>
561-
</Tooltip>
562-
)}
563-
</Stack>
564-
</Stack>
594+
</span>
595+
</Tooltip>
596+
)}
597+
{showCopy && (
598+
<Tooltip content={copied ? `Copied!` : `Copy response`} side="top">
599+
<IconButton
600+
size={1}
601+
variant="ghost"
602+
tone="neutral"
603+
className={styles.metaActionButton}
604+
onClick={onCopy}
605+
aria-label="Copy response text"
606+
>
607+
{copied ? (
608+
<Icon icon={Check} size={1} />
609+
) : (
610+
<Icon icon={Copy} size={1} />
611+
)}
612+
</IconButton>
613+
</Tooltip>
614+
)}
615+
</span>
565616
)
566-
})
617+
}
567618

568619
export const AgentResponse = memo(function AgentResponse({
569620
section,
570621
isStreaming,
571622
timestamp,
572623
renderWidth = 0,
624+
forkFromHere,
573625
}: {
574626
section: AgentResponseSection
575627
isStreaming: boolean
576628
timestamp?: number | null
577629
renderWidth?: number
630+
forkFromHere?: ForkFromHereAction
578631
}): React.ReactElement {
579632
const canCache = !isStreaming && section.done === true
580633
const [copied, setCopied] = useState(false)
@@ -616,6 +669,7 @@ export const AgentResponse = memo(function AgentResponse({
616669
isStreaming && !section.done && !section.error && !lastTextHasContent
617670
const showTimestamp = timestamp != null && !isStreaming
618671
const hasLeadingMeta = showThinking || section.done || Boolean(section.error)
672+
const showCopyAction = Boolean(section.done && copyText)
619673

620674
// Mirror of the `sawStreamingRef` / `finalDurationMs` capture from
621675
// `AgentResponseLive` — see the comment there for why we only
@@ -696,36 +750,12 @@ export const AgentResponse = memo(function AgentResponse({
696750
<TimeText ts={timestamp} className={styles.timeText} />
697751
</>
698752
)}
699-
{section.done && copyText && (
700-
<Tooltip content={copied ? `Copied!` : `Copy response`} side="top">
701-
<IconButton
702-
size={1}
703-
variant="ghost"
704-
tone="neutral"
705-
className={styles.copyButton}
706-
onClick={() => void copyResponseText()}
707-
aria-label="Copy response text"
708-
>
709-
{copied ? (
710-
<Icon icon={Check} size={1} />
711-
) : (
712-
<Icon icon={Copy} size={1} />
713-
)}
714-
</IconButton>
715-
</Tooltip>
716-
)}
717-
{section.done && copyText && (
718-
<button
719-
type="button"
720-
className={styles.copyButton}
721-
onClick={() => void copyResponseText()}
722-
aria-label="Copy response text"
723-
title={copied ? `Copied` : `Copy all response text`}
724-
>
725-
{copied ? <Check size={12} /> : <Copy size={12} />}
726-
<span>{copied ? `Copied` : `Copy`}</span>
727-
</button>
728-
)}
753+
<ResponseMetaActions
754+
showCopy={showCopyAction}
755+
copied={copied}
756+
onCopy={() => void copyResponseText()}
757+
forkFromHere={section.done ? forkFromHere : undefined}
758+
/>
729759
</Stack>
730760
</Stack>
731761
)

‎packages/agents-server-ui/src/components/AttachmentDrafts.tsx‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
X,
88
} from 'lucide-react'
99
import { formatAttachmentSize } from '../lib/attachments'
10-
import { Icon, Menu, Text } from '../ui'
10+
import { Icon, Menu, Text, Tooltip } from '../ui'
1111
import styles from './AttachmentDrafts.module.css'
1212

1313
type Focusable = {
@@ -294,17 +294,22 @@ export function AttachmentActionMenu({
294294
/>
295295
<Menu.Root>
296296
<Menu.Trigger
297-
render={
298-
<button
299-
type="button"
300-
aria-label="Message actions"
301-
title="Message actions"
302-
className={styles.addMenuTrigger}
303-
disabled={disabled}
304-
>
305-
<Icon icon={Plus} size={2} />
306-
</button>
307-
}
297+
disabled={disabled}
298+
render={(triggerProps) => (
299+
<Tooltip content="Attach files" side="top" align="start">
300+
<button
301+
{...triggerProps}
302+
type="button"
303+
aria-label="Message actions"
304+
className={[triggerProps.className, styles.addMenuTrigger]
305+
.filter(Boolean)
306+
.join(` `)}
307+
disabled={disabled}
308+
>
309+
<Icon icon={Plus} size={2} />
310+
</button>
311+
</Tooltip>
312+
)}
308313
/>
309314
<Menu.Content side="top" align="start" sideOffset={8}>
310315
<Menu.Item disabled={disabled} onSelect={onAttach}>

‎packages/agents-server-ui/src/components/EntityTimeline.tsx‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -933,8 +933,9 @@ const TimelineRow = memo(function TimelineRow({
933933
stopUserMessageKey: string | null
934934
stopPending: boolean
935935
onStopGeneration?: () => void
936-
/** When set on a user-message row, enables the "Fork from here" hover
937-
* button. Caller pre-resolved the pointer; we just invoke. */
936+
/** When set on a completed run row, shows the always-visible
937+
* "Fork from here" footer action. Caller pre-resolved the pointer;
938+
* we just invoke. */
938939
onForkFromHere?: ForkFromHereAction
939940
onRunSearchTextChange: (rowKey: string, text: string) => void
940941
}): React.ReactElement {
@@ -960,7 +961,6 @@ const TimelineRow = memo(function TimelineRow({
960961
}
961962
stopPending={stopPending}
962963
onStop={onStopGeneration}
963-
forkFromHere={onForkFromHere}
964964
/>
965965
)
966966
}
@@ -1004,6 +1004,7 @@ const TimelineRow = memo(function TimelineRow({
10041004
isStreaming={!entityStopped && isStreaming}
10051005
timestamp={responseTimestamp}
10061006
renderWidth={renderWidth}
1007+
forkFromHere={onForkFromHere}
10071008
onSearchTextChange={onRunSearchTextChange}
10081009
/>
10091010
)
@@ -1022,7 +1023,7 @@ export function EntityTimeline({
10221023
scrollToBottomSignal = 0,
10231024
stopPending = false,
10241025
onStopGeneration,
1025-
forkFromHereByInboxKey,
1026+
forkFromHereByRunKey,
10261027
}: {
10271028
rows: Array<EntityTimelineQueryRow>
10281029
loading: boolean
@@ -1037,12 +1038,12 @@ export function EntityTimeline({
10371038
stopPending?: boolean
10381039
onStopGeneration?: () => void
10391040
/**
1040-
* Per-inbox-row click handlers for the "Fork from here" hover button.
1041+
* Per-run-row click handlers for the "Fork from here" footer button.
10411042
* The map is keyed by the row's `$key`; rows not in the map (or when
10421043
* the prop is omitted) get no fork affordance. The caller resolves
10431044
* the fork pointer and runs the fork → navigate flow.
10441045
*/
1045-
forkFromHereByInboxKey?: Map<string, ForkFromHereAction>
1046+
forkFromHereByRunKey?: Map<string, ForkFromHereAction>
10461047
}): React.ReactElement {
10471048
const { entitiesCollection, runnersCollection, usersCollection } =
10481049
useElectricAgents()
@@ -1745,7 +1746,7 @@ export function EntityTimeline({
17451746
stopUserMessageKey={stopUserMessageKey}
17461747
stopPending={stopPending}
17471748
onStopGeneration={onStopGeneration}
1748-
onForkFromHere={forkFromHereByInboxKey?.get(rowKey)}
1749+
onForkFromHere={forkFromHereByRunKey?.get(rowKey)}
17491750
onRunSearchTextChange={updateRunSearchText}
17501751
/>
17511752
</TimelineRowErrorBoundary>

‎packages/agents-server-ui/src/components/MessageInput.module.css‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,10 @@
359359
line-height: var(--ds-text-xs-lh);
360360
}
361361

362+
.tooltipTrigger {
363+
display: inline-flex;
364+
}
365+
362366
.composerSend {
363367
all: unset;
364368
display: inline-flex;

0 commit comments

Comments
 (0)