From 4bab736f9097317ad6c4bcb90f99d00a4bc4e31c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:49:31 -0700 Subject: [PATCH] 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. --- .../NativeChatBackgroundTasksStatus.tsx | 217 +++++++++--------- ...veChatStructuredSession.goal-dock.test.tsx | 159 +++++++++++++ ...tiveChatStructuredSession.test-harness.tsx | 4 + .../NativeChatThreadGoalBanner.tsx | 5 +- 4 files changed, 276 insertions(+), 109 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx index 1b5225efed8..2e8cf51f18f 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx @@ -175,117 +175,120 @@ export function NativeChatBackgroundTasksStatus(props: { return (
-
-
- -
- {expanded ? ( -
- {groups.length > 0 ? ( - groups.map((group, index) => ( -
0 ? 'mt-1.5 border-t border-border/60 pt-1.5' : ''} - > -

- {backgroundTaskGroupLabel(group.kind)} -

-
    - {group.tasks.map((entry) => ( - - ))} -
-
- )) - ) : ( -

- {translate( - 'components.native-chat.backgroundTasks.detailsUnavailable', - 'Task details are unavailable for this session.' - )} -

- )} - {!props.supportsTaskStop && props.supportsStopAll ? ( -
0 ? 'mt-2 border-t border-border pt-2' : 'mt-2'}> - -
- ) : null} + ) : null} + +
- ) : null} + {expanded ? ( +
+ {groups.length > 0 ? ( + groups.map((group, index) => ( +
0 ? 'mt-1.5 border-t border-border/60 pt-1.5' : ''} + > +

+ {backgroundTaskGroupLabel(group.kind)} +

+
    + {group.tasks.map((entry) => ( + + ))} +
+
+ )) + ) : ( +

+ {translate( + 'components.native-chat.backgroundTasks.detailsUnavailable', + 'Task details are unavailable for this session.' + )} +

+ )} + {!props.supportsTaskStop && props.supportsStopAll ? ( +
0 ? 'mt-2 border-t border-border pt-2' : 'mt-2'}> + +
+ ) : null} +
+ ) : null} +
) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx new file mode 100644 index 00000000000..8a7752f49d1 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx @@ -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 ( + + + + ) +} + +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 + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index c214479b84d..82460205807 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -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(), stopBackgroundTask: vi.fn() } @@ -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 } diff --git a/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx b/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx index 20cf93af809..1c2b15b9044 100644 --- a/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx +++ b/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx @@ -70,11 +70,12 @@ export function NativeChatThreadGoalBanner(props: { return ( // Pulled over the composer's top padding so the strip sits on the input box.
-
+ {/* Right after the task strip, that strip's bottom border is this tab's top edge. */} +

{label}{' '}