mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 08:02:33 +00:00
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:
@@ -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>
|
||||
)
|
||||
|
||||
+159
@@ -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>{' '}
|
||||
|
||||
Reference in New Issue
Block a user