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}{' '}