fix(native-chat): dock the task strip on the goal tab (#22530)

* fix(native-chat): dock the task strip on the goal tab

The background-task strip was the full width of the message box, so it
did not share an edge with the narrower goal tab underneath it. When a
goal is showing, the strip now uses that tab's width and keeps a square
bottom, and the goal tab's top stays square so the two sit on each other.

* fix(native-chat): derive the task strip and goal tab seam from adjacency

The strip and goal tab were each told by the chat session whether the
other was showing, through two flags that had to match what actually
rendered. The session also re-derived the goal tab's own visibility rule
to compute one of them.

Now each bar styles its side of the seam from the DOM: the strip takes the
goal tab's width and drops its bottom corners and shadow when the goal tab
is its next sibling, and the goal tab drops its top border and corners
when the strip comes right before it. The flags and the duplicated goal
visibility check are gone, so the seam cannot disagree with what renders,
and anything placed between the two bars falls back to the separate look.
This commit is contained in:
Brennan Benson
2026-09-23 16:49:31 -07:00
committed by GitHub
parent 63866c1e27
commit 4bab736f90
4 changed files with 276 additions and 109 deletions
@@ -175,117 +175,120 @@ export function NativeChatBackgroundTasksStatus(props: {
return (
<div
data-native-chat-background-tasks="true"
className="shrink-0 bg-background px-3 pt-2 sm:px-4"
className="group/tasks shrink-0 bg-background px-3 pt-2 sm:px-4"
>
<div
ref={stripRef}
className="mx-auto w-full max-w-4xl overflow-hidden rounded-lg border border-border bg-muted/50 text-xs text-muted-foreground shadow-xs"
>
<div className="flex h-8 items-center px-1.5">
<button
type="button"
className="flex h-6 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-1.5 text-left outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
aria-expanded={expanded}
aria-controls={taskListId}
aria-label={headerText}
onClick={() => props.onExpandedChange(!expanded)}
>
<span className="min-w-0 truncate">
{header.segments.map((segment, index) => {
// A collapsed total spans kinds, so no single icon can stand for it.
const kind = segment.kind
const Icon = kind ? KIND_ICONS[kind] : null
return (
<span key={segment.kind ?? 'total'}>
{/* A text token, not `--border`: that one is a divider line
{/* When the goal tab is the next sibling, take its width and share its top edge. */}
<div className="mx-auto w-full max-w-4xl group-has-[+[data-native-chat-thread-goal]]/tasks:px-2">
<div
ref={stripRef}
className="overflow-hidden rounded-lg border border-border bg-muted/50 text-xs text-muted-foreground shadow-xs group-has-[+[data-native-chat-thread-goal]]/tasks:rounded-b-none group-has-[+[data-native-chat-thread-goal]]/tasks:shadow-none"
>
<div className="flex h-8 items-center px-1.5">
<button
type="button"
className="flex h-6 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-1.5 text-left outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
aria-expanded={expanded}
aria-controls={taskListId}
aria-label={headerText}
onClick={() => props.onExpandedChange(!expanded)}
>
<span className="min-w-0 truncate">
{header.segments.map((segment, index) => {
// A collapsed total spans kinds, so no single icon can stand for it.
const kind = segment.kind
const Icon = kind ? KIND_ICONS[kind] : null
return (
<span key={segment.kind ?? 'total'}>
{/* A text token, not `--border`: that one is a divider line
(7% white in dark) and reads as invisible at this size. */}
{index > 0 ? <span className="text-muted-foreground"> · </span> : null}
{Icon && kind ? (
<Icon
aria-hidden="true"
// The turn owns the voice: same icons, dimmed until it ends.
className={`mr-1 inline size-3 align-[-0.125em] ${kindIconTone(
kind,
!props.indicatorActive
)}`}
/>
) : null}
<span className="font-medium text-foreground">{segment.text}</span>
{index > 0 ? <span className="text-muted-foreground"> · </span> : null}
{Icon && kind ? (
<Icon
aria-hidden="true"
// The turn owns the voice: same icons, dimmed until it ends.
className={`mr-1 inline size-3 align-[-0.125em] ${kindIconTone(
kind,
!props.indicatorActive
)}`}
/>
) : null}
<span className="font-medium text-foreground">{segment.text}</span>
</span>
)
})}
{header.detail ? (
<span>
{header.segments.length > 0 ? ' — ' : null}
{header.detail}
</span>
)
})}
{header.detail ? (
<span>
{header.segments.length > 0 ? ' — ' : null}
{header.detail}
</span>
) : null}
</span>
<ChevronDown
aria-hidden="true"
className={`size-3 transition-transform ${expanded ? 'rotate-180' : ''}`}
/>
</button>
</div>
{expanded ? (
<div
id={taskListId}
className="scrollbar-sleek max-h-40 overflow-y-auto border-t border-border px-3 py-2"
>
{groups.length > 0 ? (
groups.map((group, index) => (
<div
key={group.kind}
className={index > 0 ? 'mt-1.5 border-t border-border/60 pt-1.5' : ''}
>
<p className="px-0.5 pb-1 font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
{backgroundTaskGroupLabel(group.kind)}
</p>
<ul
role="list"
aria-label={backgroundTaskGroupLabel(group.kind)}
className="space-y-0.5"
>
{group.tasks.map((entry) => (
<BackgroundTaskRow
key={entry.task.id}
entry={entry}
now={now}
supportsTaskStop={props.supportsTaskStop}
stopping={props.stoppingTaskIds.has(entry.task.id)}
onStop={props.onStop}
/>
))}
</ul>
</div>
))
) : (
<p>
{translate(
'components.native-chat.backgroundTasks.detailsUnavailable',
'Task details are unavailable for this session.'
)}
</p>
)}
{!props.supportsTaskStop && props.supportsStopAll ? (
<div className={groups.length > 0 ? 'mt-2 border-t border-border pt-2' : 'mt-2'}>
<Button
type="button"
variant="ghost"
size="xs"
aria-label={translate(
'components.native-chat.backgroundTasks.stopAll',
'Stop background tasks'
)}
disabled={props.stoppingAll}
onClick={() => props.onStop()}
>
{translate('components.native-chat.backgroundTasks.stop', 'Stop')}
</Button>
</div>
) : null}
) : null}
</span>
<ChevronDown
aria-hidden="true"
className={`size-3 transition-transform ${expanded ? 'rotate-180' : ''}`}
/>
</button>
</div>
) : null}
{expanded ? (
<div
id={taskListId}
className="scrollbar-sleek max-h-40 overflow-y-auto border-t border-border px-3 py-2"
>
{groups.length > 0 ? (
groups.map((group, index) => (
<div
key={group.kind}
className={index > 0 ? 'mt-1.5 border-t border-border/60 pt-1.5' : ''}
>
<p className="px-0.5 pb-1 font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
{backgroundTaskGroupLabel(group.kind)}
</p>
<ul
role="list"
aria-label={backgroundTaskGroupLabel(group.kind)}
className="space-y-0.5"
>
{group.tasks.map((entry) => (
<BackgroundTaskRow
key={entry.task.id}
entry={entry}
now={now}
supportsTaskStop={props.supportsTaskStop}
stopping={props.stoppingTaskIds.has(entry.task.id)}
onStop={props.onStop}
/>
))}
</ul>
</div>
))
) : (
<p>
{translate(
'components.native-chat.backgroundTasks.detailsUnavailable',
'Task details are unavailable for this session.'
)}
</p>
)}
{!props.supportsTaskStop && props.supportsStopAll ? (
<div className={groups.length > 0 ? 'mt-2 border-t border-border pt-2' : 'mt-2'}>
<Button
type="button"
variant="ghost"
size="xs"
aria-label={translate(
'components.native-chat.backgroundTasks.stopAll',
'Stop background tasks'
)}
disabled={props.stoppingAll}
onClick={() => props.onStop()}
>
{translate('components.native-chat.backgroundTasks.stop', 'Stop')}
</Button>
</div>
) : null}
</div>
) : null}
</div>
</div>
</div>
)
@@ -0,0 +1,159 @@
// @vitest-environment happy-dom
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type {
AgentJournalRenderItem,
AgentJournalThreadGoal
} from '../../../../shared/agent-session-journal-types'
const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () =>
(await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks()
)
vi.mock('@/lib/structured-agent-session-launch', () =>
moduleFactories.structuredAgentSessionLaunch()
)
vi.mock('@/runtime/structured-agent-session-client', () =>
moduleFactories.structuredAgentSessionClient()
)
vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession())
vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale())
vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext())
vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick())
vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList())
vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer())
vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState())
vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard())
vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard())
import { NativeChatStructuredSession } from './NativeChatStructuredSession'
const STRIP = '[data-native-chat-background-tasks]'
const GOAL = '[data-native-chat-thread-goal]'
// The seam is CSS on DOM adjacency: the strip styles itself when a goal tab follows
// it, and the goal tab styles itself when the strip precedes it.
const STRIP_DOCK_RULE = /^group-has-\[\+\[([a-z-]+)\]\]\/tasks:(.+)$/
const GOAL_DOCK_RULE = /^group-\[\[([a-z-]+)\]\+&\]\/goal:(.+)$/
function dockRules(root: Element, rule: RegExp): { attribute: string; utility: string }[] {
return [root, ...root.querySelectorAll('*')].flatMap((element) =>
[...element.classList].flatMap((token) => {
const match = rule.exec(token)
return match ? [{ attribute: match[1], utility: match[2] }] : []
})
)
}
function goal(status: AgentJournalThreadGoal['status']): AgentJournalThreadGoal {
return {
objective: 'Ship the parser',
status,
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 60,
createdAt: 1,
updatedAt: 1
}
}
function sessionView(): React.JSX.Element {
return (
<TooltipProvider>
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-goal"
sessionId="session-goal"
target={{ kind: 'local' }}
agent="codex"
/>
</TooltipProvider>
)
}
function showStripAndGoal(status: AgentJournalThreadGoal['status'] = 'active'): void {
mocks.monitoringBackgroundTasks = true
mocks.backgroundTasks = [{ id: 'task-agent', kind: 'agent' }]
mocks.threadGoal = { goal: goal(status), pending: false, change: vi.fn() }
}
describe('NativeChatStructuredSession task strip on the goal tab', () => {
afterEach(() => {
cleanup()
localStorage.clear()
resetStructuredSessionMocks()
})
it('stacks the strip directly on the goal tab at the tab width, sharing one edge', () => {
showStripAndGoal()
render(sessionView())
const strip = document.querySelector(STRIP)
const goalTab = document.querySelector(GOAL)
if (!strip || !goalTab) {
throw new Error('expected both the task strip and the goal tab')
}
expect(strip.nextElementSibling).toBe(goalTab)
// Group variants are inert without their group marker on the adjacent element.
expect(strip.classList).toContain('group/tasks')
expect(goalTab.classList).toContain('group/goal')
const stripRules = dockRules(strip, STRIP_DOCK_RULE)
const goalRules = dockRules(goalTab, GOAL_DOCK_RULE)
for (const { attribute } of stripRules) {
expect(goalTab.hasAttribute(attribute)).toBe(true)
}
for (const { attribute } of goalRules) {
expect(strip.hasAttribute(attribute)).toBe(true)
}
const stripUtilities = stripRules.map((rule) => rule.utility)
expect(stripUtilities).toEqual(expect.arrayContaining(['rounded-b-none', 'shadow-none']))
expect(goalRules.map((rule) => rule.utility)).toEqual(['rounded-t-none', 'border-t-0'])
// Same width: the strip takes the goal tab's inset inside the shared column.
const goalInset = goalTab.firstElementChild
expect(goalInset?.classList).toContain('px-2')
expect(stripUtilities).toContain('px-2')
})
it('leaves the strip undocked when the goal tab does not render', () => {
showStripAndGoal('complete')
const { rerender } = render(sessionView())
expect(document.querySelector(GOAL)).toBeNull()
expect(document.querySelector(STRIP)?.nextElementSibling?.matches(GOAL) ?? false).toBe(false)
const approval: AgentJournalRenderItem = {
itemId: 'approval-item',
revision: 1,
sequence: 1,
observedAt: 1,
body: {
kind: 'approval',
title: 'Allow command?',
detail: 'pnpm test',
options: [{ id: 'allow', label: 'Allow' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
}
}
showStripAndGoal('active')
mocks.promptItems = [approval]
rerender(sessionView())
expect(document.querySelector(GOAL)).toBeNull()
expect(document.querySelector(STRIP)).not.toBeNull()
})
it('restores the goal tab top edge when the strip goes away', () => {
showStripAndGoal()
const { rerender } = render(sessionView())
expect(document.querySelector(GOAL)?.previousElementSibling?.matches(STRIP)).toBe(true)
mocks.monitoringBackgroundTasks = false
rerender(sessionView())
expect(document.querySelector(STRIP)).toBeNull()
expect(document.querySelector(GOAL)?.previousElementSibling?.matches(STRIP) ?? false).toBe(
false
)
})
})
@@ -5,6 +5,7 @@ import type { AgentSessionBackgroundTask } from '../../../../shared/agent-sessio
import type { NativeChatApprovalCardProps } from './NativeChatApprovalCard'
import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard'
import type { NativeChatLaunchSeed } from './native-chat-composer-types'
import type { StructuredAgentSessionThreadGoal } from './use-structured-agent-session-thread-goal'
import type { StructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch'
import type {
SessionOptionSetResult,
@@ -67,6 +68,7 @@ export function createStructuredSessionMocks() {
supportsBackgroundTaskStopAll: true,
backgroundTasks: [] as AgentSessionBackgroundTask[],
settledBackgroundTasks: [] as AgentSessionBackgroundTask[],
threadGoal: nullable<StructuredAgentSessionThreadGoal>(),
stopBackgroundTask: vi.fn<StopBackgroundTaskSpy>()
}
@@ -129,6 +131,7 @@ export function createStructuredSessionMocks() {
supportsStopAll: mocks.supportsBackgroundTaskStopAll
},
turnId: mocks.turnId,
threadGoal: mocks.threadGoal,
cancel: mocks.cancel,
stopBackgroundTask: (taskId?: string) =>
mocks.stopBackgroundTask(props.sessionId, taskId),
@@ -242,6 +245,7 @@ export function createStructuredSessionMocks() {
mocks.stopBackgroundTask.mockReset()
mocks.backgroundTasks = []
mocks.settledBackgroundTasks = []
mocks.threadGoal = null
}
return { mocks, moduleFactories, resetStructuredSessionMocks }
@@ -70,11 +70,12 @@ export function NativeChatThreadGoalBanner(props: {
return (
// Pulled over the composer's top padding so the strip sits on the input box.
<div
className="relative -mb-2 shrink-0 px-3 sm:px-4"
className="group/goal relative -mb-2 shrink-0 px-3 sm:px-4"
data-native-chat-thread-goal={goal.status}
>
<div className="mx-auto w-full max-w-4xl px-2">
<div className="flex items-start gap-2 rounded-t-md border border-b-0 border-border bg-muted/30 py-1 pr-1 pl-3 text-xs text-muted-foreground">
{/* Right after the task strip, that strip's bottom border is this tab's top edge. */}
<div className="flex items-start gap-2 rounded-t-md border border-b-0 border-border bg-muted/30 py-1 pr-1 pl-3 text-xs text-muted-foreground group-[[data-native-chat-background-tasks]+&]/goal:rounded-t-none group-[[data-native-chat-background-tasks]+&]/goal:border-t-0">
<Goal aria-hidden className="mt-1 size-3.5 shrink-0" />
<p className={cn('min-w-0 flex-1 py-0.5', expanded ? 'break-words' : 'truncate')}>
<span className="font-semibold text-foreground">{label}</span>{' '}