diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx
index 508ead3ec9d..63f2c9fb2a1 100644
--- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx
@@ -136,6 +136,7 @@ const ThinkingDisclosure: FC<{
return (
diff --git a/apps/desktop/src/components/assistant-ui/thread/status.test.tsx b/apps/desktop/src/components/assistant-ui/thread/status.test.tsx
index f6edaee0d7e..e51f39313a7 100644
--- a/apps/desktop/src/components/assistant-ui/thread/status.test.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/status.test.tsx
@@ -54,3 +54,18 @@ describe('ResponseLoadingIndicator timer', () => {
expect(screen.getAllByText((_, node) => node?.textContent === '8s').length).toBeGreaterThan(0)
})
})
+
+// The status line sits between tool rows and thinking headers, which the
+// transcript rests at a fade. Without the mark it reads a shade brighter than
+// both — the one line in the column claiming emphasis it hasn't earned.
+describe('status line', () => {
+ afterEach(cleanup)
+
+ it('is marked as transcript scaffolding', () => {
+ $activeSessionId.set('session-a')
+ $turnStartedAt.set(Date.now())
+ const { container } = renderIndicator()
+
+ expect(container.querySelector('[role="status"]')?.hasAttribute('data-conversation-scaffold')).toBe(true)
+ })
+})
diff --git a/apps/desktop/src/components/assistant-ui/thread/status.tsx b/apps/desktop/src/components/assistant-ui/thread/status.tsx
index 62ad0940f75..83cd107a292 100644
--- a/apps/desktop/src/components/assistant-ui/thread/status.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/status.tsx
@@ -34,6 +34,7 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
'text-(--conversation-scaffold-text)',
className
)}
+ data-conversation-scaffold=""
role="status"
{...rest}
>
diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx
index 3cb3445c089..966e977b177 100644
--- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx
@@ -510,6 +510,7 @@ function ToolEntry({ part }: ToolEntryProps) {
'group/tool-block min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
open && TOOL_EXPANDED_SHELL_CLASS
)}
+ data-conversation-scaffold=""
data-file-edit={isFileEdit && open ? '' : undefined}
data-slot="tool-block"
data-tool-open={open ? '' : undefined}
@@ -795,7 +796,7 @@ function ToolRunHeader({
summary: string
}) {
return (
-
+
{live ? {summary} : summary}
diff --git a/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx b/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx
index 9ed31b76ae4..65e9ab34d07 100644
--- a/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx
@@ -476,6 +476,23 @@ describe('a file edit among ordinary activity', () => {
})
})
+// The transcript rests its scaffolding at a fade, keyed off one attribute. A
+// surface that renders without it is brighter than everything around it, which
+// is how two adjacent, identical rows came to sit at two opacities.
+describe('transcript fade', () => {
+ it('marks every row and summary as scaffolding', async () => {
+ const { container } = render()
+
+ await screen.findByText('Explored 2 files')
+
+ const unmarked = [...container.querySelectorAll('[data-tool-summary],[data-tool-row]')].filter(
+ node => !node.hasAttribute('data-conversation-scaffold')
+ )
+
+ expect(unmarked).toHaveLength(0)
+ })
+})
+
describe('live tool run', () => {
it('keeps its rows on screen instead of hiding them behind the summary', async () => {
const { container } = render()
diff --git a/apps/desktop/src/components/chat/scaffold-row.tsx b/apps/desktop/src/components/chat/scaffold-row.tsx
index dd30ff1025c..23e0c737a78 100644
--- a/apps/desktop/src/components/chat/scaffold-row.tsx
+++ b/apps/desktop/src/components/chat/scaffold-row.tsx
@@ -11,6 +11,10 @@ import { DisclosureRow } from '@/components/chat/disclosure-row'
* 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.
+ *
+ * The resting fade is the other half and lives in CSS, on the *block* that
+ * holds the row rather than on the row: mark it `data-conversation-scaffold`.
+ * A surface that skips the mark reads a shade brighter than its neighbours.
*/
export const SCAFFOLD_LABEL_CLASS =
'text-[length:var(--conversation-tool-font-size)] leading-(--conversation-line-height) text-(--conversation-scaffold-text)'
diff --git a/apps/desktop/src/lib/use-enter-animation.test.tsx b/apps/desktop/src/lib/use-enter-animation.test.tsx
new file mode 100644
index 00000000000..936aeab2668
--- /dev/null
+++ b/apps/desktop/src/lib/use-enter-animation.test.tsx
@@ -0,0 +1,82 @@
+import { cleanup, render } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+
+import { useEnterAnimation } from './use-enter-animation'
+
+interface PlayedAnimation {
+ keyframes: Keyframe[]
+ options: KeyframeAnimationOptions
+}
+
+/**
+ * Mounts one element through the hook and reports the animation it played, if
+ * any. jsdom has no Web Animations API, so `animate` is the seam.
+ */
+function mountAnimated(enabled: boolean, animationKey?: string): PlayedAnimation | undefined {
+ let played: PlayedAnimation | undefined
+
+ function Probe() {
+ const ref = useEnterAnimation(enabled, animationKey)
+
+ return
+ }
+
+ // Defined rather than spied on: jsdom ships no Web Animations API at all, so
+ // there is no `animate` to wrap.
+ Object.defineProperty(HTMLElement.prototype, 'animate', {
+ configurable: true,
+ value: (keyframes: Keyframe[], options: KeyframeAnimationOptions) => {
+ played = { keyframes, options }
+
+ return {} as Animation
+ },
+ writable: true
+ })
+ render()
+
+ return played
+}
+
+afterEach(() => {
+ cleanup()
+ Reflect.deleteProperty(HTMLElement.prototype, 'animate')
+})
+
+describe('useEnterAnimation', () => {
+ it('plays once on mount', () => {
+ const played = mountAnimated(true, 'plays-once')
+
+ expect(played).toBeDefined()
+ expect(played?.keyframes[0]).toMatchObject({ opacity: 0 })
+ })
+
+ it('stays out of the way when disabled', () => {
+ expect(mountAnimated(false, 'disabled')).toBeUndefined()
+ })
+
+ // A key is only banked once the node survives a microtask, so that a mount
+ // React immediately tears down doesn't burn it.
+ it('does not replay for a key that already animated', async () => {
+ expect(mountAnimated(true, 'replay')).toBeDefined()
+ await Promise.resolve()
+
+ expect(mountAnimated(true, 'replay')).toBeUndefined()
+ })
+
+ /**
+ * The animation fills forwards, so any value in its last keyframe is held in
+ * the animation origin of the cascade for the life of the element — above
+ * the stylesheet. Naming an end opacity therefore doesn't just finish the
+ * fade, it permanently overrules whatever opacity CSS wants the element to
+ * rest at, and transcript scaffolding rests dimmed. Rows that animated in
+ * during the turn stayed bright while their rehydrated neighbours faded, and
+ * no hover could lift the bright ones because the sheet had lost the
+ * argument. Opacity has to be left to CSS at the end.
+ */
+ it('leaves the resting opacity to the stylesheet', () => {
+ const played = mountAnimated(true, 'resting-opacity')
+
+ expect(played?.options.fill).toBe('both')
+ expect(played?.keyframes.at(-1)).not.toHaveProperty('opacity')
+ })
+})
diff --git a/apps/desktop/src/lib/use-enter-animation.ts b/apps/desktop/src/lib/use-enter-animation.ts
index 259cf7a388d..267eaa4cac5 100644
--- a/apps/desktop/src/lib/use-enter-animation.ts
+++ b/apps/desktop/src/lib/use-enter-animation.ts
@@ -82,7 +82,17 @@ export function useEnterAnimation(enabled: boolean, animationKey?: string): (el:
el.animate(
[
{ opacity: 0, transform: 'translateY(0.375rem)' },
- { opacity: 1, transform: 'translateY(0)' }
+ // No `opacity` on the way out, deliberately. A filled animation holds
+ // its final value in the animation origin of the cascade, which
+ // outranks the stylesheet for as long as the element lives — naming 1
+ // here permanently pinned full opacity onto everything the sheet dims.
+ // Transcript scaffolding is dimmed that way, so a tool row or thinking
+ // header kept whichever opacity it happened to mount with: full if it
+ // animated in during the turn, faded if it was rehydrated or remounted
+ // past its one-shot key. Adjacent identical rows disagreed, and hover
+ // couldn't lift the pinned ones. Left neutral, opacity rises to
+ // whatever CSS says it should be and answers hover afterwards.
+ { transform: 'translateY(0)' }
],
{ duration: 180, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'both' }
)
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index 106b196b1af..541da35b6bc 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -1416,16 +1416,18 @@ text-* variant utilities. */ .btn-arc {
background: transparent !important;
}
-/* Fade scaffolding so the prose reading column stays primary. Two targets:
- a thinking disclosure fades as one block, and each *individual* tool row
- (`[data-tool-row]`) fades on its own. We deliberately do NOT fade the tool
- group wrapper (`[data-tool-group]`): opacity on a parent opens a stacking
- context, so a child row can never be more opaque than the group — that made
- it impossible to keep one row lit (an open diff) while its siblings faded.
- With the fade per-row, each row hovers/focuses independently. */
-[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure'],
-[data-slot='aui_assistant-message-content'] [data-tool-summary],
-[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row] {
+/* Fade scaffolding so the prose reading column stays primary. Each surface
+ opts in with `data-conversation-scaffold` — thinking header, tool row, run
+ summary, live status line — instead of being named by its own selector here.
+ Spelling them out individually is how the status line came to sit a shade
+ brighter than the rows either side of it.
+
+ Marked per surface and never on a container: opacity opens a stacking
+ context, so nothing inside a faded parent can be more opaque than it, and
+ one row (an open diff) could not stay lit while its siblings dimmed. Hence
+ the tool group wrapper carries no mark and each row inside it carries its
+ own — no two marked elements nest, so the fade never compounds. */
+[data-slot='aui_assistant-message-content'] [data-conversation-scaffold] {
opacity: 0.67;
transition: opacity 120ms ease-out;
}
@@ -1433,9 +1435,7 @@ text-* variant utilities. */ .btn-arc {
/* Lift on hover or *keyboard* focus only. `:focus-within` also matches the
focus a mouse click leaves on the disclosure toggle, which kept a row lit
after you clicked to collapse it; `:has(:focus-visible)` excludes that. */
-[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure']:is(:hover, :has(:focus-visible)),
-[data-slot='aui_assistant-message-content'] [data-tool-summary]:is(:hover, :has(:focus-visible)),
-[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row]:is(:hover, :has(:focus-visible)) {
+[data-slot='aui_assistant-message-content'] [data-conversation-scaffold]:is(:hover, :has(:focus-visible)) {
opacity: 1;
}