mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
refactor(desktop): give live tool runs a one-line ticker
A live run was the settled view under a CSS max-height: the real rows, capped at ~6.75rem, with an escape hatch that let an open diff lift the cap entirely. So it was neither a single line nor an honest expanded block, and a run could balloon mid-turn. Split the two presentations instead. Live, a run is its summary plus a one-line reel that slides each finished action up and out, so a turn touching thirty files reads as one line ticking over in place. Settled, the summary is the whole of it until opened. Cards — file edits, clarify, image_generate — leave the run entirely and stay on screen where they happened, since the diff or the question IS the point of the turn. Drops useToolWindow, the bounded-window threshold, the scroll container, the fade mask and the :has([data-tool-open]) escape hatch, all of which existed to make "the real rows, but shorter" work. Also unifies transcript scaffolding on one colour: thinking headers painted --ui-text-secondary and tool summaries --ui-text-tertiary under a shared opacity, which is two greys for one kind of line. Both now render through ScaffoldRow at a token pitched between them.
This commit is contained in:
parent
6ba2f3e644
commit
a3c46ac09e
15 changed files with 436 additions and 254 deletions
|
|
@ -50,6 +50,7 @@ import {
|
|||
import { pruneDelegateFallbackSubagents, pruneFinishedSessionSubagents, upsertSubagent } from '@/store/subagents'
|
||||
import { clearActiveSessionTodos } from '@/store/todos'
|
||||
import { recordToolDiff } from '@/store/tool-diffs'
|
||||
import { setSessionDraftingTool } from '@/store/tool-drafting'
|
||||
import { reportInstallMethodWarning } from '@/store/updates'
|
||||
import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events'
|
||||
// Leaf import (not the `@/themes` barrel) to avoid pulling the ThemeProvider
|
||||
|
|
@ -441,6 +442,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
flushQueuedDeltas(sessionId)
|
||||
pruneFinishedSessionSubagents(sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
setSessionDraftingTool(sessionId, '')
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
nativeSubagentSessionsRef.current.delete(sessionId)
|
||||
// A fresh turn on this session optimistically clears its billing wall;
|
||||
|
|
@ -611,6 +613,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
// last item stuck pending/in_progress. Finished lists keep their linger.
|
||||
clearActiveSessionTodos(sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
setSessionDraftingTool(sessionId, '')
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
|
|
@ -677,12 +680,28 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
if (storedId && nextTitle) {
|
||||
setSessions(prev => prev.map(s => (sessionMatchesStoredId(s, storedId) ? { ...s, title: nextTitle } : s)))
|
||||
}
|
||||
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
|
||||
} else if (event.type === 'tool.generating') {
|
||||
// Announced while the model is still emitting the call's JSON, so it
|
||||
// carries a name and nothing else — no id, no args. Materializing a row
|
||||
// from it strands an argless placeholder whenever the bubble is sealed
|
||||
// before the real `tool.start` arrives, because the two can no longer be
|
||||
// reconciled across the boundary. It's a status, so say it as one.
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
setSessionDraftingTool(sessionId, typeof payload?.name === 'string' ? payload.name : '')
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: false, toolRunning: true })
|
||||
}
|
||||
} else if (event.type === 'tool.start' || event.type === 'tool.progress') {
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
setSessionDraftingTool(sessionId, '')
|
||||
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running', event.type)
|
||||
|
||||
if (isActiveEvent) {
|
||||
|
|
@ -691,6 +710,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
} else if (event.type === 'tool.complete') {
|
||||
if (sessionId) {
|
||||
flushQueuedDeltas(sessionId)
|
||||
setSessionDraftingTool(sessionId, '')
|
||||
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type)
|
||||
|
||||
if (isActiveEvent) {
|
||||
|
|
@ -982,6 +1002,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
clearClarifyRequest(undefined, sessionId)
|
||||
clearActiveSessionTodos(sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
setSessionDraftingTool(sessionId, '')
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/mar
|
|||
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool/fallback'
|
||||
import { formatElapsed, useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { GeneratedImage } from '@/components/chat/generated-image-result'
|
||||
import { SCAFFOLD_LABEL_CLASS, SCAFFOLD_META_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { generatedImageFromResult } from '@/lib/generated-images'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
|
|
@ -89,14 +89,23 @@ const ThinkingDisclosure: FC<{
|
|||
}
|
||||
}, [elapsed, pending, watching])
|
||||
|
||||
// The timer counts whole seconds, so a block that finished inside one reports
|
||||
// "0s" — accurate and useless. Say it was quick and leave the number out.
|
||||
const thoughtLabel =
|
||||
pending || thoughtFor === null
|
||||
? t.assistant.thread.thinking
|
||||
: thoughtFor < 1
|
||||
? t.assistant.thread.thoughtBriefly
|
||||
: t.assistant.thread.thoughtFor(formatElapsed(thoughtFor))
|
||||
// Three ways a finished block can report itself. With a measured duration it
|
||||
// says so, unless the timer's whole seconds round it to "0s" — accurate and
|
||||
// useless — in which case it just says it was quick. With no duration at all
|
||||
// (rehydrated history, or reasoning that arrived already complete so we never
|
||||
// saw it run) it still has to read as finished; a turn that ended must not go
|
||||
// on saying "Thinking".
|
||||
let thoughtLabel = t.assistant.thread.thinking
|
||||
|
||||
if (!pending) {
|
||||
if (thoughtFor === null) {
|
||||
thoughtLabel = t.assistant.thread.thought
|
||||
} else if (thoughtFor < 1) {
|
||||
thoughtLabel = t.assistant.thread.thoughtBriefly
|
||||
} else {
|
||||
thoughtLabel = t.assistant.thread.thoughtFor(formatElapsed(thoughtFor))
|
||||
}
|
||||
}
|
||||
|
||||
// While the preview is live, pin the scroll container to the bottom on
|
||||
// every content growth so the latest tokens are always visible.
|
||||
|
|
@ -144,24 +153,10 @@ const ThinkingDisclosure: FC<{
|
|||
data-slot="aui_thinking-disclosure"
|
||||
ref={enterRef}
|
||||
>
|
||||
<DisclosureRow onToggle={() => setUserOpen(!open)} open={open}>
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)',
|
||||
pending && 'shimmer text-foreground/55'
|
||||
)}
|
||||
>
|
||||
{thoughtLabel}
|
||||
</span>
|
||||
{pending && (
|
||||
<ActivityTimerText
|
||||
className="text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)"
|
||||
seconds={elapsed}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</DisclosureRow>
|
||||
<ScaffoldRow onToggle={() => setUserOpen(!open)} open={open}>
|
||||
<span className={cn(SCAFFOLD_LABEL_CLASS, pending && 'shimmer')}>{thoughtLabel}</span>
|
||||
{pending && <ActivityTimerText className={SCAFFOLD_META_CLASS} seconds={elapsed} />}
|
||||
</ScaffoldRow>
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react'
|
|||
import { type FC, type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { toolPresentVerb } from '@/components/assistant-ui/tool/run-summary'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
|
|
@ -13,6 +14,7 @@ import { $backgroundResume } from '@/store/background-delegation'
|
|||
import { sessionCompacting } from '@/store/compaction'
|
||||
import { sessionAwaitingInput } from '@/store/prompts'
|
||||
import { $turnStartedAt } from '@/store/session'
|
||||
import { type DraftingTool, sessionDraftingTool } from '@/store/tool-drafting'
|
||||
|
||||
const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({
|
||||
children,
|
||||
|
|
@ -34,8 +36,8 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
|
|||
// Fixed label while auto-compaction runs — decoupled from backend status text.
|
||||
const COMPACTION_LABEL = 'Summarizing thread'
|
||||
|
||||
const CompactionHint: FC = () => (
|
||||
<span className="shimmer min-w-0 truncate text-muted-foreground/55">{COMPACTION_LABEL}</span>
|
||||
const HintText: FC<{ children: ReactNode }> = ({ children }) => (
|
||||
<span className="shimmer min-w-0 truncate text-muted-foreground/55">{children}</span>
|
||||
)
|
||||
|
||||
/** These indicators render inside whichever transcript mounted them, so every
|
||||
|
|
@ -45,6 +47,7 @@ function useThreadSessionStatus() {
|
|||
const sessionId = useStore(useSessionView().$runtimeId)
|
||||
const turnStartedAt = useStore($turnStartedAt)
|
||||
const compacting = useStore(useMemo(() => sessionCompacting(sessionId), [sessionId]))
|
||||
const drafting = useStore(useMemo(() => sessionDraftingTool(sessionId), [sessionId]))
|
||||
// A pending clarify / approval / sudo / secret means the turn is paused on the
|
||||
// user, not working — so don't resurrect the "thinking" timer while they
|
||||
// decide (matches the pet's awaitingInput pose taking priority over busy).
|
||||
|
|
@ -53,10 +56,42 @@ function useThreadSessionStatus() {
|
|||
return {
|
||||
awaitingInput,
|
||||
compacting,
|
||||
drafting,
|
||||
turnTimerKey: sessionId && turnStartedAt ? `turn:${sessionId}:${turnStartedAt}` : undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Long enough that a tool whose arguments arrive in a few frames never gets to
|
||||
// strobe a label, short enough that a real wait is named almost immediately.
|
||||
const DRAFTING_REVEAL_MS = 200
|
||||
|
||||
/**
|
||||
* What to call the wait, if it deserves a name. Compaction outranks a draft —
|
||||
* it's rarer, slower, and explains a transcript that looks like it reset.
|
||||
*/
|
||||
function useStatusHint(compacting: boolean, drafting: DraftingTool | null): string {
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const name = drafting?.name ?? ''
|
||||
|
||||
useEffect(() => {
|
||||
setRevealed(false)
|
||||
|
||||
if (!name) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setTimeout(() => setRevealed(true), DRAFTING_REVEAL_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}, [name])
|
||||
|
||||
if (compacting) {
|
||||
return COMPACTION_LABEL
|
||||
}
|
||||
|
||||
return revealed && name ? toolPresentVerb(name) : ''
|
||||
}
|
||||
|
||||
export const CenteredThreadSpinner: FC = () => {
|
||||
const { t } = useI18n()
|
||||
|
||||
|
|
@ -80,17 +115,18 @@ export const CenteredThreadSpinner: FC = () => {
|
|||
|
||||
export const ResponseLoadingIndicator: FC = () => {
|
||||
const { t } = useI18n()
|
||||
const { compacting, turnTimerKey } = useThreadSessionStatus()
|
||||
const { compacting, drafting, turnTimerKey } = useThreadSessionStatus()
|
||||
const elapsed = useElapsedSeconds(true, turnTimerKey)
|
||||
const hint = useStatusHint(compacting, drafting)
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
className="text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height)"
|
||||
data-slot="aui_response-loading"
|
||||
label={compacting ? COMPACTION_LABEL : t.assistant.thread.loadingResponse}
|
||||
label={hint || t.assistant.thread.loadingResponse}
|
||||
>
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
{compacting && <CompactionHint />}
|
||||
{hint && <HintText>{hint}</HintText>}
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
|
|
@ -159,7 +195,8 @@ export const StreamStallIndicator: FC = () => {
|
|||
// what lets the timer read "quiet for 12s" rather than the age of this
|
||||
// component, which is the whole turn so far.
|
||||
const [quietSince, setQuietSince] = useState<number | undefined>(undefined)
|
||||
const { awaitingInput, compacting, turnTimerKey } = useThreadSessionStatus()
|
||||
const { awaitingInput, compacting, drafting, turnTimerKey } = useThreadSessionStatus()
|
||||
const hint = useStatusHint(compacting, drafting)
|
||||
|
||||
useEffect(() => {
|
||||
setQuietSince(undefined)
|
||||
|
|
@ -169,24 +206,28 @@ export const StreamStallIndicator: FC = () => {
|
|||
return () => window.clearTimeout(id)
|
||||
}, [activity])
|
||||
|
||||
const active = (quietSince !== undefined || compacting) && !awaitingInput
|
||||
// A named wait doesn't have to earn the stall threshold first — we already
|
||||
// know what the turn is doing, so say it as soon as the label is ready rather
|
||||
// than leaving the transcript silent for STREAM_STALL_S.
|
||||
const active = (quietSince !== undefined || Boolean(hint)) && !awaitingInput
|
||||
|
||||
// Compaction owns the whole turn, so it keeps counting from the turn's start;
|
||||
// a plain stall counts from the last thing the stream produced.
|
||||
const elapsed = useElapsedSeconds(active, compacting ? turnTimerKey : undefined, compacting ? undefined : quietSince)
|
||||
// anything else counts from the moment the stream went quiet — the stall's own
|
||||
// mark, or the draft's, whichever named the wait first.
|
||||
const elapsed = useElapsedSeconds(
|
||||
active,
|
||||
compacting ? turnTimerKey : undefined,
|
||||
compacting ? undefined : (quietSince ?? drafting?.since)
|
||||
)
|
||||
|
||||
if (!active) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
className="mt-1.5"
|
||||
data-slot="aui_stream-stall"
|
||||
label={compacting ? COMPACTION_LABEL : 'Hermes is thinking'}
|
||||
>
|
||||
<StatusRow className="mt-1.5" data-slot="aui_stream-stall" label={hint || 'Hermes is thinking'}>
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
{compacting && <CompactionHint />}
|
||||
{hint && <HintText>{hint}</HintText>}
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,42 +1,56 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isUnboundableTool, shouldBoundToolGroup, technicalTrace } from './fallback'
|
||||
import { isCardTool, splitRunItems, technicalTrace } from './fallback'
|
||||
|
||||
describe('shouldBoundToolGroup', () => {
|
||||
it('bounds long runs of ordinary tool calls', () => {
|
||||
expect(shouldBoundToolGroup(3, false)).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves short runs unbounded', () => {
|
||||
expect(shouldBoundToolGroup(2, false)).toBe(false)
|
||||
})
|
||||
|
||||
it('never bounds a run holding an unboundable tool', () => {
|
||||
expect(shouldBoundToolGroup(3, true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUnboundableTool', () => {
|
||||
it('exempts clarify forms and generated images from the window', () => {
|
||||
expect(isUnboundableTool('clarify')).toBe(true)
|
||||
expect(isUnboundableTool('image_generate')).toBe(true)
|
||||
})
|
||||
|
||||
// Everything ToolEntry renders carries `data-tool-row`, so the
|
||||
// `:has([data-tool-row][data-tool-open])` rule in styles.css lifts the cap
|
||||
// on its own. A diff row mounts open and frees the group immediately; a
|
||||
// collapsed row has no body in the DOM to clip. Exempting these in JS
|
||||
// instead vetoed grouping for the whole run — and since reads and edits are
|
||||
// most of a coding session, runs of 19 calls never collapsed at all.
|
||||
it('bounds the rows the CSS break-out already covers', () => {
|
||||
for (const toolName of ['read_file', 'execute_code', 'edit_file', 'patch', 'write_file']) {
|
||||
expect(isUnboundableTool(toolName)).toBe(false)
|
||||
describe('isCardTool', () => {
|
||||
it('keeps what the user has to look at out of a summary', () => {
|
||||
// A diff is the deliverable, a clarify is a question waiting on an answer,
|
||||
// an image is the thing that was asked for. None of them survives being
|
||||
// folded into "used 3 tools".
|
||||
for (const toolName of ['clarify', 'image_generate', 'edit_file', 'patch', 'write_file']) {
|
||||
expect(isCardTool(toolName)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('still bounds console output and other ordinary rows', () => {
|
||||
expect(isUnboundableTool('terminal')).toBe(false)
|
||||
expect(isUnboundableTool('web_search')).toBe(false)
|
||||
it('treats reads, searches and commands as ephemeral activity', () => {
|
||||
for (const toolName of ['read_file', 'search_files', 'terminal', 'execute_code', 'web_search']) {
|
||||
expect(isCardTool(toolName)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitRunItems', () => {
|
||||
it('collapses a stretch of activity into one run', () => {
|
||||
expect(splitRunItems(['read_file', 'search_files', 'terminal'])).toEqual([{ end: 2, kind: 'run', start: 0 }])
|
||||
})
|
||||
|
||||
it('keeps a card at the point in the turn where it happened', () => {
|
||||
// Read, edit, read has to stay in that order — a summary, the diff, then a
|
||||
// second summary — rather than sorting the diffs to one end.
|
||||
expect(splitRunItems(['read_file', 'patch', 'read_file', 'terminal'])).toEqual([
|
||||
{ end: 0, kind: 'run', start: 0 },
|
||||
{ index: 1, kind: 'card' },
|
||||
{ end: 3, kind: 'run', start: 2 }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not let adjacent cards merge into a run', () => {
|
||||
expect(splitRunItems(['patch', 'write_file'])).toEqual([
|
||||
{ index: 0, kind: 'card' },
|
||||
{ index: 1, kind: 'card' }
|
||||
])
|
||||
})
|
||||
|
||||
it('passes a part that is not a tool call through as its own card', () => {
|
||||
expect(splitRunItems(['read_file', '', 'read_file'])).toEqual([
|
||||
{ end: 0, kind: 'run', start: 0 },
|
||||
{ index: 1, kind: 'card' },
|
||||
{ end: 2, kind: 'run', start: 2 }
|
||||
])
|
||||
})
|
||||
|
||||
it('has nothing to split when the range is empty', () => {
|
||||
expect(splitRunItems([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ import { useStore } from '@nanostores/react'
|
|||
import {
|
||||
Children,
|
||||
createContext,
|
||||
type CSSProperties,
|
||||
type FC,
|
||||
Fragment,
|
||||
isValidElement,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
|
|
@ -23,6 +25,7 @@ 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 { SCAFFOLD_LABEL_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
|
|
@ -41,11 +44,12 @@ import { normalize } from '@/lib/text'
|
|||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { recordPreviewArtifact } from '@/store/preview-status'
|
||||
import { sessionApprovalRequest } from '@/store/prompts'
|
||||
import { $toolInlineDiff } from '@/store/tool-diffs'
|
||||
import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss'
|
||||
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
|
||||
|
||||
import { PendingToolApproval } from './approval'
|
||||
import { APPROVAL_TOOLS, PendingToolApproval } from './approval'
|
||||
import {
|
||||
buildToolView,
|
||||
clampForDisplay,
|
||||
|
|
@ -701,109 +705,83 @@ function TerminalTranscript({ command, exitCode }: TerminalTranscriptProps) {
|
|||
)
|
||||
}
|
||||
|
||||
// A back-to-back run of this many tool calls collapses into the bounded,
|
||||
// auto-scrolling window; fewer than this stays a plain inline stack.
|
||||
const TOOL_GROUP_SCROLL_THRESHOLD = 3
|
||||
// Tools that draw their own surface and must never be folded into a run's
|
||||
// summary. Two kinds, for the same reason — the thing on screen IS the point:
|
||||
//
|
||||
// - File edits are the deliverable, not scaffolding. The diff is what the
|
||||
// user reviews, so it stays visible at its place in the turn, live and
|
||||
// settled, the way a PR shows its changes.
|
||||
// - `clarify` and `image_generate` bypass ToolEntry to render their own
|
||||
// markup: a question the user has to answer, an image they asked for.
|
||||
//
|
||||
// Everything else is ephemeral activity — reads, searches, commands — which is
|
||||
// what a run summarizes and what the live ticker cycles through.
|
||||
const CARD_TOOLS = new Set(['clarify', 'image_generate'])
|
||||
|
||||
// Tools whose body must never be hidden — not behind the window's max-height +
|
||||
// fade mask, and not behind a settled run's summary chevron. A run holding any
|
||||
// of them stays a plain, fully-visible stack no matter how long it is.
|
||||
//
|
||||
// This list is deliberately tiny, because the two things it opts out of are
|
||||
// already safe for anything ToolEntry renders. For the window: a row carries
|
||||
// `data-tool-row`, and the `:has([data-tool-row][data-tool-open])` rule in
|
||||
// styles.css lifts the cap whenever one is open, so a code/diff row mounting
|
||||
// open (see `defaultOpen`) frees itself the moment it appears, and a collapsed
|
||||
// row is a one-line status with no body in the DOM to clip. For the chevron: a
|
||||
// run still holding a pending call always renders its rows, so an approval bar
|
||||
// can't end up behind one.
|
||||
//
|
||||
// Only components that bypass ToolEntry need this opt-out: `clarify` and
|
||||
// `image_generate` render their own markup, never emit `data-tool-row`, and so
|
||||
// neither the CSS escape hatch nor the pending-row rule can reach them.
|
||||
//
|
||||
// File edits are pointedly NOT here. A settled run collapses its diffs behind
|
||||
// the summary, which carries their +N/−M — one click to read, and the run stays
|
||||
// compact. Adding them back veto'd the window for nearly every coding session
|
||||
// last time (7f2971039, reverted in 058aa34d8); it would do the same here.
|
||||
const UNBOUNDABLE_TOOLS = new Set(['clarify', 'image_generate'])
|
||||
|
||||
export function isUnboundableTool(toolName: string): boolean {
|
||||
return UNBOUNDABLE_TOOLS.has(toolName)
|
||||
export function isCardTool(toolName: string): boolean {
|
||||
return CARD_TOOLS.has(toolName) || isFileEditTool(toolName)
|
||||
}
|
||||
|
||||
export function shouldBoundToolGroup(childCount: number, hasUnboundable: boolean) {
|
||||
return childCount >= TOOL_GROUP_SCROLL_THRESHOLD && !hasUnboundable
|
||||
}
|
||||
export type RunItem = { end: number; kind: 'run'; start: number } | { index: number; kind: 'card' }
|
||||
|
||||
// Pin-to-bottom + top-fade for the bounded tool window. Pins the newest row on
|
||||
// growth (a call lands or a row expands) unless the user scrolled up, and fades
|
||||
// the top edge once anything sits above it. Mirrors ThinkingDisclosure's live
|
||||
// preview. `enabled` is false for short runs, leaving the plain flat stack.
|
||||
function useToolWindow(enabled: boolean) {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement | null>(null)
|
||||
const stickRef = useRef(true)
|
||||
const [faded, setFaded] = useState(false)
|
||||
/**
|
||||
* Split a range of parts into cards and the runs of activity between them.
|
||||
*
|
||||
* Order is preserved rather than sorted into "all the runs, then all the
|
||||
* cards": a turn that reads, edits, then reads again shows a summary, the
|
||||
* diff, then a second summary, in the sequence it happened. Indices are
|
||||
* relative to the range. An empty name is a part that isn't a tool call at
|
||||
* all, which passes through as its own card.
|
||||
*/
|
||||
export function splitRunItems(toolNames: readonly string[]): RunItem[] {
|
||||
const items: RunItem[] = []
|
||||
let run: null | Extract<RunItem, { kind: 'run' }> = null
|
||||
|
||||
const syncFade = useCallback(() => setFaded((scrollRef.current?.scrollTop ?? 0) > 4), [])
|
||||
toolNames.forEach((name, index) => {
|
||||
if (!name || isCardTool(name)) {
|
||||
run = null
|
||||
items.push({ index, kind: 'card' })
|
||||
|
||||
const onScroll = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <= 8
|
||||
syncFade()
|
||||
}, [syncFade])
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
const content = contentRef.current
|
||||
|
||||
if (!enabled || !el || !content) {
|
||||
return
|
||||
if (run) {
|
||||
run.end = index
|
||||
} else {
|
||||
run = { end: index, kind: 'run', start: index }
|
||||
items.push(run)
|
||||
}
|
||||
})
|
||||
|
||||
// Track the content's HEIGHT and only pin when it grows. The observer also
|
||||
// fires for width changes — a sidebar sash drag resizes every tool window
|
||||
// once per frame — and pinning there is (a) pointless, the list didn't
|
||||
// grow, and (b) expensive: `pin` writes scrollTop then `syncFade` reads it
|
||||
// back, a write->read forced reflow per tool group per frame. Measured on
|
||||
// a real session while dragging the sash: 927ms of `pin` script plus
|
||||
// 2.7s of style recalc across one 60-frame drag. Reading the height off
|
||||
// the RO entry keeps the check reflow-free.
|
||||
let lastHeight = -1
|
||||
return items
|
||||
}
|
||||
|
||||
const pin = (entries: readonly ResizeObserverEntry[]) => {
|
||||
const height = entries[entries.length - 1]?.borderBoxSize?.[0]?.blockSize ?? -1
|
||||
const grew = height < 0 || height > lastHeight
|
||||
lastHeight = height
|
||||
/**
|
||||
* The live run, as one line.
|
||||
*
|
||||
* A run in progress shows only what it is doing right now; each new action
|
||||
* slides the one before it up and out of a single-line window, so a turn that
|
||||
* touches thirty files reads as one line ticking over in place instead of a
|
||||
* list growing down the page. When the run settles the ticker goes away and
|
||||
* the summary above it is all that's left.
|
||||
*
|
||||
* Rows are clipped to a uniform line box so the reel's offset stays exact
|
||||
* whatever a row happens to contain.
|
||||
*/
|
||||
function ToolRunTicker({ children }: { children: ReactNode }) {
|
||||
const rows = Children.toArray(children)
|
||||
|
||||
if (!grew) {
|
||||
return
|
||||
}
|
||||
|
||||
if (stickRef.current) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
||||
syncFade()
|
||||
}
|
||||
|
||||
// No sync pin() here: the observer's guaranteed initial delivery runs it
|
||||
// inside RO timing (layout already clean, still before paint). A sync call
|
||||
// at effect time reads scrollHeight while the commit's layout is dirty —
|
||||
// one forced reflow per tool group, which cascades on a session switch.
|
||||
const observer = new ResizeObserver(pin)
|
||||
observer.observe(content)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [enabled, syncFade])
|
||||
|
||||
return { contentRef, faded, onScroll, scrollRef }
|
||||
return (
|
||||
<div className="tool-ticker" data-tool-ticker="">
|
||||
<div className="tool-ticker__reel" style={{ '--tool-ticker-index': rows.length - 1 } as CSSProperties}>
|
||||
{rows.map((row, index) => (
|
||||
<div className="tool-ticker__row" key={isValidElement(row) ? (row.key ?? index) : index}>
|
||||
{row}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The one grey line that stands in for a run of tool calls once it has
|
||||
|
|
@ -823,21 +801,22 @@ function ToolRunHeader({
|
|||
}) {
|
||||
return (
|
||||
<div data-tool-summary="">
|
||||
<DisclosureRow onToggle={onToggle} open={open}>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<FadeText className={cn(TOOL_HEADER_TITLE_CLASS, 'truncate text-(--ui-text-tertiary)')}>
|
||||
{live ? <span className="shimmer">{summary.text}</span> : summary.text}
|
||||
</FadeText>
|
||||
<DiffCount added={summary.added} className="text-[0.625rem]" removed={summary.removed} />
|
||||
</span>
|
||||
</DisclosureRow>
|
||||
<ScaffoldRow onToggle={onToggle} open={open}>
|
||||
<FadeText className={cn(SCAFFOLD_LABEL_CLASS, 'truncate')}>
|
||||
{live ? <span className="shimmer">{summary.text}</span> : summary.text}
|
||||
</FadeText>
|
||||
<DiffCount added={summary.added} className="text-[0.625rem]" removed={summary.removed} />
|
||||
</ScaffoldRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ToolRunState {
|
||||
count: number
|
||||
key: string
|
||||
live: boolean
|
||||
/** A call still awaiting a result that could be the one blocking on approval. */
|
||||
pendingApprovalTool: boolean
|
||||
summary: RunSummary
|
||||
}
|
||||
|
||||
|
|
@ -868,8 +847,10 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState {
|
|||
cache.current = {
|
||||
signature,
|
||||
value: {
|
||||
count: tools.length,
|
||||
key: tools[0]?.toolCallId ?? '',
|
||||
live,
|
||||
pendingApprovalTool: tools.some(tool => tool.result === undefined && APPROVAL_TOOLS.has(tool.toolName)),
|
||||
summary: summarizeToolRun(tools, live)
|
||||
}
|
||||
}
|
||||
|
|
@ -880,7 +861,7 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState {
|
|||
}
|
||||
|
||||
/**
|
||||
* A run of consecutive tool calls, headed by the line that summarizes it.
|
||||
* One run of consecutive activity calls, headed by the line that summarizes it.
|
||||
*
|
||||
* The run is identified by its FIRST tool call, never by its position: a live
|
||||
* stream and the same turn rehydrated from history agree on which calls belong
|
||||
|
|
@ -889,69 +870,94 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState {
|
|||
* index is what made an earlier attempt at this reshuffle the moment a turn
|
||||
* settled. `lib/tool-run-continuity.test.ts` locks that agreement down.
|
||||
*
|
||||
* A settled run collapses to its header; a live one keeps its rows on screen
|
||||
* (and past `TOOL_GROUP_SCROLL_THRESHOLD` inside a fixed-height auto-scrolling
|
||||
* window, so a long run can't shove the reply off screen). `ToolEmbedContext`
|
||||
* is false so every row still owns its own chrome (timer / copy / approval).
|
||||
* Live, the run is a summary plus the one-line ticker. Settled, the summary is
|
||||
* the whole of it until the user opens it. `ToolEmbedContext` is false so each
|
||||
* row still owns its own chrome (timer / copy / approval) when shown.
|
||||
*/
|
||||
const ToolRun: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> = ({
|
||||
children,
|
||||
endIndex,
|
||||
startIndex
|
||||
}) => {
|
||||
const messageRunning = useAuiState(selectMessageRunning)
|
||||
const { count, key, live, pendingApprovalTool, summary } = useToolRun(startIndex, endIndex)
|
||||
const sessionId = useStore(useSessionView().$runtimeId)
|
||||
const approval = useStore(useMemo(() => sessionApprovalRequest(sessionId), [sessionId]))
|
||||
const disclosureId = `tool-run:${key}`
|
||||
const persistedOpen = useStore($toolDisclosureOpen(disclosureId))
|
||||
const enterRef = useEnterAnimation(messageRunning, `tool-run:${key}`)
|
||||
|
||||
// A lone call is already its own one-line summary; heading it with a second
|
||||
// line would say the same thing twice.
|
||||
if (count < 2) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// An approval is a question the user has to answer, and the ticker only ever
|
||||
// shows one line — the row carrying the approval bar could tick straight
|
||||
// past it. Show the whole run until it's answered.
|
||||
const blocked = Boolean(approval) && pendingApprovalTool
|
||||
const expanded = live ? blocked : (persistedOpen ?? false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid min-w-0 max-w-full gap-(--tool-row-gap) overflow-hidden"
|
||||
data-slot="tool-block"
|
||||
data-tool-group=""
|
||||
ref={enterRef}
|
||||
>
|
||||
<ToolRunHeader
|
||||
live={live}
|
||||
onToggle={live ? undefined : () => setToolDisclosureOpen(disclosureId, !expanded)}
|
||||
open={expanded}
|
||||
summary={summary}
|
||||
/>
|
||||
{live && !blocked && <ToolRunTicker>{children}</ToolRunTicker>}
|
||||
{expanded && <div className="grid min-w-0 max-w-full gap-(--tool-row-gap)">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A range of consecutive tool calls, split into the cards that must stay on
|
||||
* screen and the runs of activity between them.
|
||||
*
|
||||
* assistant-ui hands the whole adjacent range over as one group; what belongs
|
||||
* together is a narrower question than adjacency. A diff or a question for the
|
||||
* user is the point of the turn and renders in place, while the reads and
|
||||
* commands around it collapse into a line. Splitting here rather than asking
|
||||
* for different ranges keeps the decision next to the rendering that depends
|
||||
* on it.
|
||||
*/
|
||||
export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> = ({
|
||||
children,
|
||||
endIndex,
|
||||
startIndex
|
||||
}) => {
|
||||
const messageRunning = useAuiState(selectMessageRunning)
|
||||
const { key, live, summary } = useToolRun(startIndex, endIndex)
|
||||
|
||||
const hasUnboundable = useAuiState(s =>
|
||||
s.message.parts
|
||||
// Joined rather than returned as an array: assistant-ui compares selector
|
||||
// results with `Object.is` and re-runs them on every store update, so a
|
||||
// fresh array would re-render the whole group on every text delta.
|
||||
const toolNameKey = useAuiState(state =>
|
||||
state.message.parts
|
||||
.slice(Math.max(0, startIndex), endIndex + 1)
|
||||
.some(part => part.type === 'tool-call' && isUnboundableTool(part.toolName))
|
||||
.map(part => (part.type === 'tool-call' ? part.toolName : ''))
|
||||
.join('\u0000')
|
||||
)
|
||||
|
||||
const disclosureId = `tool-run:${key}`
|
||||
const persistedOpen = useStore($toolDisclosureOpen(disclosureId))
|
||||
// A lone call is already its own one-line summary, so it never earns a
|
||||
// header. Neither does a run holding an `UNBOUNDABLE_TOOLS` surface — a
|
||||
// `clarify` asking the user a question must not end up behind a chevron.
|
||||
const grouped = endIndex > startIndex && !hasUnboundable
|
||||
const open = !grouped || live || (persistedOpen ?? false)
|
||||
|
||||
const enterRef = useEnterAnimation(messageRunning, `tool-run:${key}`)
|
||||
|
||||
const bounded = open && shouldBoundToolGroup(Children.count(children), hasUnboundable)
|
||||
const { contentRef, faded, onScroll, scrollRef } = useToolWindow(bounded)
|
||||
const items = useMemo(() => splitRunItems(toolNameKey.split('\u0000')), [toolNameKey])
|
||||
const rows = Children.toArray(children)
|
||||
|
||||
return (
|
||||
<ToolEmbedContext.Provider value={false}>
|
||||
<div
|
||||
className="grid min-w-0 max-w-full gap-(--tool-row-gap) overflow-hidden"
|
||||
data-slot="tool-block"
|
||||
data-tool-group=""
|
||||
ref={enterRef}
|
||||
>
|
||||
{grouped && (
|
||||
<ToolRunHeader
|
||||
live={live}
|
||||
onToggle={live ? undefined : () => setToolDisclosureOpen(disclosureId, !open)}
|
||||
open={open}
|
||||
summary={summary}
|
||||
/>
|
||||
)}
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
bounded && 'tool-group-scroll max-h-(--tool-group-scroll-max-h) overflow-y-auto',
|
||||
bounded && faded && 'tool-group-scroll--faded'
|
||||
)}
|
||||
onScroll={bounded ? onScroll : undefined}
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div className="grid min-w-0 max-w-full gap-(--tool-row-gap)" ref={contentRef}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{items.map(item =>
|
||||
item.kind === 'card' ? (
|
||||
<Fragment key={`card:${item.index}`}>{rows[item.index]}</Fragment>
|
||||
) : (
|
||||
<ToolRun endIndex={startIndex + item.end} key={`run:${item.start}`} startIndex={startIndex + item.start}>
|
||||
{rows.slice(item.start, item.end + 1)}
|
||||
</ToolRun>
|
||||
)
|
||||
)}
|
||||
</ToolEmbedContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,15 @@ function isPending(tool: ToolCallLike): boolean {
|
|||
return tool.result === undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* How a tool reads while it is happening — "Editing", "Exploring". Shared with
|
||||
* the status line that covers the gap before a tool starts, so the same run is
|
||||
* described in the same words from the moment the model drafts it.
|
||||
*/
|
||||
export function toolPresentVerb(toolName: string): string {
|
||||
return CATEGORY_COPY[toolCategory(toolName)].present
|
||||
}
|
||||
|
||||
/** The thing a tool acted on, as the header should name it. */
|
||||
function toolTarget(tool: ToolCallLike): string {
|
||||
const args = parseMaybeObject(tool.args)
|
||||
|
|
|
|||
41
apps/desktop/src/components/chat/scaffold-row.tsx
Normal file
41
apps/desktop/src/components/chat/scaffold-row.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
|
||||
/**
|
||||
* Transcript scaffolding: the quiet lines around the reply that say what the
|
||||
* agent did rather than what it said — a thinking header, a settled tool run,
|
||||
* the live activity ticker.
|
||||
*
|
||||
* They all render through here so they cannot drift apart. They used to each
|
||||
* pick their own grey — a thinking header painted `--ui-text-secondary`, a tool
|
||||
* summary `--ui-text-tertiary`, both under the same opacity — which read as two
|
||||
* different kinds of line for what is one kind of thing.
|
||||
*/
|
||||
export const SCAFFOLD_LABEL_CLASS =
|
||||
'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--conversation-scaffold-text)'
|
||||
|
||||
/** Durations, counts and diff stats trailing a scaffold label. */
|
||||
export const SCAFFOLD_META_CLASS = 'shrink-0 text-[0.625rem] tabular-nums text-(--conversation-scaffold-meta)'
|
||||
|
||||
/**
|
||||
* One scaffold line. `children` is the label and whatever trails it in flow
|
||||
* (meta, diff counts); `trailing` overlays the right edge for a live timer.
|
||||
*/
|
||||
export function ScaffoldRow({
|
||||
children,
|
||||
onToggle,
|
||||
open = false,
|
||||
trailing
|
||||
}: {
|
||||
children: ReactNode
|
||||
onToggle?: () => void
|
||||
open?: boolean
|
||||
trailing?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<DisclosureRow onToggle={onToggle} open={open} trailing={trailing}>
|
||||
<span className="flex min-w-0 items-center gap-1.5">{children}</span>
|
||||
</DisclosureRow>
|
||||
)
|
||||
}
|
||||
|
|
@ -2273,6 +2273,7 @@ export const ar = defineLocale({
|
|||
resumeWhenBackgroundDone: count =>
|
||||
count === 1 ? 'سيُستأنف عند انتهاء المهمة الخلفية' : `سيُستأنف عند انتهاء ${count} مهام خلفية`,
|
||||
thinking: 'يفكر...',
|
||||
thought: 'فكّر',
|
||||
thoughtBriefly: 'فكّر قليلاً',
|
||||
thoughtFor: duration => `فكّر لمدة ${duration}`,
|
||||
today: time => `اليوم ${time}`,
|
||||
|
|
|
|||
|
|
@ -2668,6 +2668,7 @@ export const en: Translations = {
|
|||
? 'Will resume when the background task finishes'
|
||||
: `Will resume when ${count} background tasks finish`,
|
||||
thinking: 'Thinking',
|
||||
thought: 'Thought',
|
||||
thoughtBriefly: 'Thought briefly',
|
||||
thoughtFor: duration => `Thought for ${duration}`,
|
||||
today: time => `Today, ${time}`,
|
||||
|
|
|
|||
|
|
@ -2517,6 +2517,7 @@ export const ja = defineLocale({
|
|||
? 'バックグラウンドタスクの完了後に再開します'
|
||||
: `${count} 件のバックグラウンドタスクの完了後に再開します`,
|
||||
thinking: '考え中',
|
||||
thought: '思考済み',
|
||||
thoughtBriefly: '少し思考',
|
||||
thoughtFor: duration => `${duration} 思考`,
|
||||
today: time => `今日 ${time}`,
|
||||
|
|
|
|||
|
|
@ -2267,6 +2267,7 @@ export interface Translations {
|
|||
loadingResponse: string
|
||||
resumeWhenBackgroundDone: (count: number) => string
|
||||
thinking: string
|
||||
thought: string
|
||||
thoughtBriefly: string
|
||||
thoughtFor: (duration: string) => string
|
||||
today: (time: string) => string
|
||||
|
|
|
|||
|
|
@ -2438,6 +2438,7 @@ export const zhHant = defineLocale({
|
|||
resumeWhenBackgroundDone: count =>
|
||||
count === 1 ? '背景工作完成後將自動繼續' : `${count} 個背景工作完成後將自動繼續`,
|
||||
thinking: '思考中',
|
||||
thought: '已思考',
|
||||
thoughtBriefly: '思考了片刻',
|
||||
thoughtFor: duration => `思考了 ${duration}`,
|
||||
today: time => `今天,${time}`,
|
||||
|
|
|
|||
|
|
@ -2839,6 +2839,7 @@ export const zh: Translations = {
|
|||
resumeWhenBackgroundDone: count =>
|
||||
count === 1 ? '后台任务完成后将自动继续' : `${count} 个后台任务完成后将自动继续`,
|
||||
thinking: '思考中',
|
||||
thought: '已思考',
|
||||
thoughtBriefly: '思考了片刻',
|
||||
thoughtFor: duration => `思考了 ${duration}`,
|
||||
today: time => `今天,${time}`,
|
||||
|
|
|
|||
45
apps/desktop/src/store/tool-drafting.ts
Normal file
45
apps/desktop/src/store/tool-drafting.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { atom, computed } from 'nanostores'
|
||||
|
||||
// The tool whose arguments the model is streaming right now, and when it
|
||||
// started. Generating a large payload (a 45 KB write_file) takes seconds during
|
||||
// which the tool has not begun and the transcript has nothing to say.
|
||||
// Per-session because a transcript may be a tile, and a tile must never narrate
|
||||
// the primary chat's work.
|
||||
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
|
||||
|
||||
export interface DraftingTool {
|
||||
name: string
|
||||
since: number
|
||||
}
|
||||
|
||||
export const $draftingToolSessions = atom<Record<string, DraftingTool>>({})
|
||||
|
||||
/** What `sessionId` is drafting, if anything. */
|
||||
export function sessionDraftingTool(sessionId: null | string) {
|
||||
return computed($draftingToolSessions, sessions => sessions[keyFor(sessionId)] ?? null)
|
||||
}
|
||||
|
||||
export function setSessionDraftingTool(sessionId: string | null | undefined, name: string): void {
|
||||
const key = keyFor(sessionId)
|
||||
const sessions = $draftingToolSessions.get()
|
||||
|
||||
if (!name) {
|
||||
if (!(key in sessions)) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = { ...sessions }
|
||||
delete next[key]
|
||||
$draftingToolSessions.set(next)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Re-announcing the same tool keeps the original timestamp, so the elapsed
|
||||
// count reads as one continuous wait rather than restarting.
|
||||
if (sessions[key]?.name === name) {
|
||||
return
|
||||
}
|
||||
|
||||
$draftingToolSessions.set({ ...sessions, [key]: { name, since: Date.now() } })
|
||||
}
|
||||
|
|
@ -287,6 +287,13 @@
|
|||
--ui-text-secondary: color-mix(in srgb, var(--ui-base) 74%, transparent);
|
||||
--ui-text-tertiary: color-mix(in srgb, var(--ui-base) 54%, transparent);
|
||||
--ui-text-quaternary: color-mix(in srgb, var(--ui-base) 36%, transparent);
|
||||
/* Transcript scaffolding — thinking headers, settled tool runs, the live
|
||||
activity ticker. One colour for all of them (see `ScaffoldRow`), pitched
|
||||
between the secondary and tertiary greys those lines used to pick
|
||||
individually, and dimmed once more by the fade rule below so the prose
|
||||
reading column stays primary. */
|
||||
--conversation-scaffold-text: color-mix(in srgb, var(--ui-base) 64%, transparent);
|
||||
--conversation-scaffold-meta: color-mix(in srgb, var(--ui-base) 44%, transparent);
|
||||
--ui-stroke-primary: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) var(--theme-stroke-primary-accent-mix),
|
||||
|
|
@ -402,10 +409,6 @@
|
|||
/* Tight gap between tool rows inside a single action group, so a back-to-back
|
||||
run still reads as one cohesive sequence. */
|
||||
--tool-row-gap: 0.375rem;
|
||||
/* Height of the bounded, auto-scrolling window a long adjacent tool-call run
|
||||
collapses into (see `.tool-group-scroll`). Sized to show a few rows before
|
||||
the scroll + top fade kick in. */
|
||||
--tool-group-scroll-max-h: 6.75rem;
|
||||
/* Paragraph spacing — vertical gap between prose paragraphs, both inside a
|
||||
markdown block and between consecutive prose parts. Single knob; tweak
|
||||
freely. */
|
||||
|
|
@ -1567,32 +1570,33 @@ text-* variant utilities. */ .btn-arc {
|
|||
--thread-last-message-clearance: calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 2rem);
|
||||
}
|
||||
|
||||
/* Long adjacent tool-call run collapsed into a fixed, auto-scrolling window.
|
||||
ToolGroupSlot pins the newest call to the bottom (unless the user scrolls
|
||||
up), so a back-to-back run stays compact instead of pushing the reply off
|
||||
screen. A thin scrollbar keeps the affordance discoverable. */
|
||||
.tool-group-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--ui-stroke-tertiary) transparent;
|
||||
/* A live tool run, shown as one line. `ToolRunTicker` stacks the run's rows
|
||||
into a reel and offsets it by the newest row's index, so each new action
|
||||
slides the one before it up and out of a single-line window — the run reads
|
||||
as one line ticking over in place rather than a list growing down the page.
|
||||
Rows are clipped to a uniform line box so the offset stays exact whatever a
|
||||
row contains. */
|
||||
.tool-ticker {
|
||||
height: var(--conversation-line-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Break out of the fixed window the moment any row inside is expanded: the
|
||||
user is now reading a diff/output, so let it grow to full height instead of
|
||||
peering at it through a ~2-row viewport. Collapsing the row drops it back
|
||||
into the compact, auto-scrolling window. Beats a larger fixed cap since an
|
||||
open row already bounds its own payload with an inner scroll. */
|
||||
.tool-group-scroll:has([data-tool-row][data-tool-open]) {
|
||||
max-height: none;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
.tool-ticker__reel {
|
||||
transform: translateY(calc(var(--tool-ticker-index, 0) * var(--conversation-line-height) * -1));
|
||||
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
/* Top gradient — only applied once the user is scrolled down off the top, so
|
||||
the oldest visible call fades up under the fade while the first row stays
|
||||
fully legible when scrolled all the way up. */
|
||||
.tool-group-scroll--faded {
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 2rem, black 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0, black 2rem, black 100%);
|
||||
.tool-ticker__row {
|
||||
display: flex;
|
||||
height: var(--conversation-line-height);
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tool-ticker__reel {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes code-card-stream-enter {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue