Simplify object drawer headers

This commit is contained in:
rcourtman
2026-08-24 12:48:31 +01:00
parent 9e622c291d
commit 66fd37ee6f
13 changed files with 86 additions and 417 deletions
@@ -3607,6 +3607,11 @@ automatic-action policy, saved access configuration, and action audit belong
to a dedicated `Manage` tab and must not render inside Overview. Guest, node,
Docker-host, and unified-resource drawers must compose these shared primitives
instead of defining provider-local attention or technical disclosure shells.
Drawer headers reserve their limited space for the subject and a close control,
plus a direct object-specific lifecycle action only where the object contract
requires one. Generic `Ask Assistant` and `Copy context` actions do not belong
in object drawer headers: Assistant remains available through the global shell,
and raw context export must not compete with the operational reading path.
The shared interactive sparkline now follows that same split.
`frontend-modern/src/components/shared/InteractiveSparkline.tsx` stays the
render shell, `frontend-modern/src/components/shared/useInteractiveSparklineState.ts`
@@ -1366,6 +1366,10 @@ selected. Alert lookup may consume the already-loaded active-alert map through
the resource's bounded canonical/provider identity candidate list; it must not
add a per-drawer fetch, rescan the workload inventory, or eagerly mount hidden
technical sections.
Guest and resource drawer headers must not initialize clipboard-context or
Assistant-handoff state for generic header buttons. Removing those controls
also removes their drawer-local signal, API, and formatting wiring; the global
Assistant shell remains the single generic entry point.
The investigation enrichment path in `MaybeInvestigateFinding`
adds at most one operator-state projection lookup per investigation
@@ -4659,3 +4659,7 @@ details. Operator overrides, lifecycle policy, update actions, audit controls,
and saved web access belong to Manage. Provider pages may supply typed detail
models, but they must not create a provider-local drawer hierarchy or promote
metadata into Overview solely because it exists.
The drawer header contains no generic Assistant handoff or agent-context copy
action. Those shell-level capabilities must not consume object identity space;
only the close control and a capability-backed object lifecycle action may sit
beside the heading.
+15 -62
View File
@@ -1,65 +1,21 @@
{
"version": 1,
"base_sha": "a73abe909caceee084e647b959f9adae9a355bb3",
"verified_at": "2026-08-24T11:27:51Z",
"base_sha": "9e622c291d499cd88b8269ef98c878c702de801b",
"verified_at": "2026-08-24T11:47:38Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerSupportDisclosure.tsx",
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts",
"frontend-modern/src/components/Workloads/GuestDrawer.tsx",
"frontend-modern/src/components/Workloads/GuestDrawerManage.tsx",
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
"frontend-modern/src/components/Workloads/NodeDrawer.tsx",
"frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx",
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts",
"frontend-modern/src/components/shared/DetailSectionTable.tsx",
"frontend-modern/src/components/shared/DrawerAttentionSection.tsx",
"frontend-modern/src/components/shared/TechnicalDetailsDisclosure.tsx",
"frontend-modern/src/components/shared/detailSectionModel.ts",
"frontend-modern/src/features/alerts/alertOverridesModel.ts",
"frontend-modern/src/features/docker/DockerHostDrawer.tsx",
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx",
"frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
"frontend-modern/src/hooks/useWorkloads.ts",
"frontend-modern/src/types/workloads.ts",
"frontend-modern/src/utils/alerts.ts",
"frontend-modern/src/utils/workloadGuestPresentation.ts"
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "8d759c4043eca6ffc4353e478e8ecc9117eb5cac0ee0799afb186f669802bf09",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "7a69a40a65452affb25c5a136dcfb5a84bb07d3ee948c08eb77e0fe6707bee8f",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerSupportDisclosure.tsx": "040da0942faabe89455b2d478b2fac03697e50ee9c12426a399b2cb0004327dd",
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "0d40dbff2b59f63813f98419004fedf1269c99efa29308210e11c3d18ee8d904",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "10dc7c47e116f50f197908b52b88c9acca42ffd35e89106390a5d69f73a00837",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts": "850cd9dba3b91530068c8542121e4929bebc547b3abda1a10744fa886c0f9361",
"frontend-modern/src/components/Workloads/GuestDrawer.tsx": "543a17e2bcb03cc7e411aebe63330e575c069e03f78ceb31baa6ca727cb01f57",
"frontend-modern/src/components/Workloads/GuestDrawerManage.tsx": "8def7d128956c2e24e7f8c0913b7f79f7c9b3f21c54130ed51f4149915f417b5",
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "b9bddadb3c2386329a2caae6c8ae6d785e935af35c41c79be9f47f2163463252",
"frontend-modern/src/components/Workloads/NodeDrawer.tsx": "f6803503882e380e2d0d3df56d6676607717579da81ed7e24c06894961fb1258",
"frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx": "5d9f130c58cffe8f653730b961b297fb789df3a5bc79b83c3da874c2067bc31e",
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "e5ccf2149958fc5cfe3b0711b9d56557e0c86d525efdef69af229f325a0fe4df",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "faf23c024fd0c0395ef1fbcebb10f13063ed9a4b7dcb5800c2ab836c9d5c5e8b",
"frontend-modern/src/components/shared/DetailSectionTable.tsx": "871c66c39f727ae46fbfb6e3246c6ea0acd37d02e9abe6ba7143013913f19e22",
"frontend-modern/src/components/shared/DrawerAttentionSection.tsx": "b9035090571c09e791021f789e8e4a4bcaffbff33dd37458ffbebf9a223d319c",
"frontend-modern/src/components/shared/TechnicalDetailsDisclosure.tsx": "d6a8ed7f006cecbe1b2e7055d041e767ff8d200ecb2c8454827bfc397e0dbdeb",
"frontend-modern/src/components/shared/detailSectionModel.ts": "b2effcc4ff9ce98e9fdd73d4853cb38166b92487c553e8cd981bb41ff7c11d86",
"frontend-modern/src/features/alerts/alertOverridesModel.ts": "8e344f8d76f4038efad5fe2765ba92ef5ca8edc0afbcdbfbc3d4cda9696a1d0a",
"frontend-modern/src/features/docker/DockerHostDrawer.tsx": "8bfd9ea8ec7cd8be5a77568aa33774a2c30f6bcc33113044ef4bf1e34422bcc4",
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx": "4adb8252dcac84ed8523f798338091852869f891f1583e130053649affc987a7",
"frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx": "05bc58b83bb5ecce65243a6849d6ded08bcbb656fcb3eda23d13129463e94ccd",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "65c945d3ae01adeb6f41b2ba144b5b7b523c3e7c118721890c78bd6d19b42efb",
"frontend-modern/src/hooks/useWorkloads.ts": "f56021125846e931dd175ed8c4a0c46bd884edce434c61bf5a332a8c0a444f52",
"frontend-modern/src/types/workloads.ts": "e878e4d96359b4add4ea650ce5e3af361e6cb15e3fede51a8f7bc9d2c8ca8aa9",
"frontend-modern/src/utils/alerts.ts": "6b9556d0415294a8151d786489a4ecb9667b26ddc272c8fe33cf955a9ebcbbeb",
"frontend-modern/src/utils/workloadGuestPresentation.ts": "ef23c08bed5f507a163aca438ec8edd2e95e47e20d164a68e0e15b7697941886"
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "4bc28b3a2f26f1e011502a55deb6befe62e001db2d4020c14b016a7d2f1458e5",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts": "8e1f892548ec92d5e895013c8247b73e6119e909cb9d635dcab354c25d14fd04",
"frontend-modern/src/components/Workloads/GuestDrawer.tsx": "17291d2c1f574e8bb1a60ca3d933dc6fab6badbd80cd4097542d74db13050429",
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts": "ec24e7f9c32f9ffdf36eebc0391fbd7019af8ed096a01513e81ac54806e315f2"
},
"routes": ["/proxmox/overview", "/docker/overview", "/truenas/overview"],
"routes": ["/truenas/overview", "/proxmox/overview"],
"viewports": [
{
"width": 1440,
@@ -71,17 +27,14 @@
}
],
"states": [
"Proxmox guest with an active powered-off alert and condensed additive operator context",
"Proxmox node with an active offline alert and condensed node context",
"Docker host overview and Manage tab with update actions separated from context",
"TrueNAS system with multiple active health issues and both technical disclosure groups collapsed",
"generic resource Manage tab with operator overrides and saved web access"
"TrueNAS system drawer with active health issues and only the close control in its header",
"Proxmox guest drawer with only the close control in its header",
"global platform Assistant entry remaining available outside the object drawer"
],
"interactions": [
"expanded guest, node, Docker host, and TrueNAS resource drawers",
"confirmed exact active alert messages render immediately and wrap at phone width",
"expanded Technical details and Platform details independently",
"switched to Manage and confirmed overrides, actions, and access are absent from Overview",
"confirmed the hierarchy at desktop width and restored the temporary browser viewport"
"expanded a TrueNAS resource drawer at phone width and confirmed no object Assistant or context-copy controls",
"expanded a Proxmox guest drawer at phone width and confirmed no object Assistant or context-copy controls",
"confirmed the guest header rule at desktop width",
"restored the temporary browser viewport"
]
}
@@ -23,6 +23,26 @@
"path": "src/features/docker/DockerHostDrawerOverview.tsx"
}
],
"forbiddenPatterns": [
{
"path": "src/components/Infrastructure/ResourceDetailDrawer.tsx",
"patterns": [
"Ask Pulse Assistant about",
"Copy Pulse context for",
"openAssistantForResource",
"copyAgentContext"
]
},
{
"path": "src/components/Workloads/GuestDrawer.tsx",
"patterns": [
"Ask Pulse Assistant about",
"Copy Pulse context for",
"openAssistantForGuest",
"copyAgentContext"
]
}
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/Workloads/GuestDrawer.test.tsx",
@@ -1,14 +1,8 @@
import { Show, For, Suspense } from 'solid-js';
import type { Component } from 'solid-js';
import CopyIcon from 'lucide-solid/icons/copy';
import MessageSquareIcon from 'lucide-solid/icons/message-square';
import XIcon from 'lucide-solid/icons/x';
import type { Resource } from '@/types/resource';
import {
DrawerHeaderActionButton,
DrawerHeaderActionGroup,
DrawerHeaderIconButton,
} from '@/components/shared/Button';
import { DrawerHeaderActionGroup, DrawerHeaderIconButton } from '@/components/shared/Button';
import { DiscoveryLoadingFallback } from '@/components/shared/DiscoveryLoadingFallback';
import { StatusDot } from '@/components/shared/StatusDot';
import { Subtabs } from '@/components/shared/Subtabs';
@@ -129,25 +123,6 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
onActionSettled={props.onResourceActionSettled}
/>
</Show>
<Show when={drawer.assistantAvailable()}>
<DrawerHeaderActionButton
onClick={() => drawer.openAssistantForResource()}
title={`Ask Pulse Assistant about ${drawer.displayName()}`}
aria-label={`Ask Pulse Assistant about ${drawer.displayName()}`}
>
<MessageSquareIcon class="h-4 w-4" />
<span class="hidden sm:inline">Ask</span>
</DrawerHeaderActionButton>
</Show>
<DrawerHeaderActionButton
onClick={() => void drawer.copyAgentContext()}
disabled={drawer.copyingAgentContext()}
title={`Copy Pulse context for ${drawer.displayName()}`}
aria-label={`Copy Pulse context for ${drawer.displayName()}`}
>
<CopyIcon class="h-4 w-4" />
<span class="hidden sm:inline">{drawer.agentContextCopied() ? 'Copied' : 'Copy'}</span>
</DrawerHeaderActionButton>
<Show when={props.onClose}>
<DrawerHeaderIconButton
onClick={() => props.onClose?.()}
@@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render as solidRender, waitFor, within } from '@solidjs/testing-library';
import type { Resource } from '@/types/resource';
import { aiChatStore } from '@/stores/aiChat';
import {
ResourceDetailDrawer,
getSpecializedTabAvailabilityMessage,
@@ -29,10 +28,6 @@ const render = (...args: Parameters<typeof solidRender>): ReturnType<typeof soli
const wsState = vi.hoisted(() => ({ pmg: [] as any[] }));
const reconnectSpy = vi.hoisted(() => vi.fn());
const getResourceContextMock = vi.hoisted(() => vi.fn());
const copyToClipboardMock = vi.hoisted(() => vi.fn());
const notificationSuccessMock = vi.hoisted(() => vi.fn());
const notificationErrorMock = vi.hoisted(() => vi.fn());
vi.mock('@/contexts/appRuntime', () => ({
useWebSocket: () => ({
@@ -104,23 +99,6 @@ vi.mock('@/api/ai', () => ({
},
}));
vi.mock('@/api/agentContext', () => ({
AgentContextAPI: {
getResourceContext: getResourceContextMock,
},
}));
vi.mock('@/utils/clipboard', () => ({
copyToClipboard: copyToClipboardMock,
}));
vi.mock('@/stores/notifications', () => ({
notificationStore: {
success: notificationSuccessMock,
error: notificationErrorMock,
},
}));
class ResizeObserverMock {
constructor(_callback: ResizeObserverCallback) {}
observe() {}
@@ -146,84 +124,16 @@ const baseResource = (overrides: Partial<Resource>): Resource => ({
...overrides,
});
afterEach(() => {
aiChatStore.setEnabled(false);
getResourceContextMock.mockReset();
copyToClipboardMock.mockReset();
notificationSuccessMock.mockReset();
notificationErrorMock.mockReset();
});
afterEach(() => vi.clearAllMocks());
describe('ResourceDetailDrawer runtime and identity cards', () => {
it('opens Pulse Assistant with a resource-scoped handoff from the drawer header', () => {
aiChatStore.setEnabled(true);
const openSpy = vi.spyOn(aiChatStore, 'open');
const resource = baseResource({
type: 'app-container',
displayName: 'Home Assistant',
technology: 'docker',
parentName: 'ha-lxc',
discoveryTarget: {
resourceType: 'app-container',
agentId: 'agent:pve-1',
resourceId: 'homeassistant',
},
});
it('keeps generic Assistant and context-copy actions out of the drawer header', () => {
const { queryByRole } = render(() => (
<ResourceDetailDrawer resource={baseResource({ displayName: 'Home Assistant' })} />
));
const { getByRole } = render(() => <ResourceDetailDrawer resource={resource} />);
fireEvent.click(getByRole('button', { name: 'Ask Pulse Assistant about Home Assistant' }));
expect(openSpy).toHaveBeenCalledWith(
expect.objectContaining({
targetType: 'resource',
targetId: 'resource-1',
autonomousMode: false,
handoffResources: [
{
id: 'resource-1',
name: 'Home Assistant',
type: 'app-container',
node: 'ha-lxc',
},
],
handoffMetadata: { kind: 'resource_context' },
}),
);
openSpy.mockRestore();
});
it('copies the canonical agent resource context from the drawer header', async () => {
getResourceContextMock.mockResolvedValue({
canonicalId: 'resource-1',
resourceType: 'agent',
resourceName: 'host-1',
activeFindings: [],
pendingApprovals: [],
recentActions: [],
generatedAt: '2026-05-06T14:00:00Z',
contextSections: [
{
id: 'identity',
title: 'Identity',
source: 'unified-resource',
trustTier: 'pulse-authored',
generatedAt: '2026-05-06T14:00:00Z',
facts: [{ label: 'Display name', value: 'host-1' }],
},
],
});
copyToClipboardMock.mockResolvedValue(true);
const { getByRole } = render(() => <ResourceDetailDrawer resource={baseResource({})} />);
fireEvent.click(getByRole('button', { name: 'Copy Pulse context for host-1' }));
await waitFor(() => {
expect(getResourceContextMock).toHaveBeenCalledWith('resource-1');
expect(copyToClipboardMock).toHaveBeenCalledWith(expect.stringContaining('host-1'));
expect(notificationSuccessMock).toHaveBeenCalledWith('Resource context copied.');
});
expect(queryByRole('button', { name: 'Ask Pulse Assistant about Home Assistant' })).toBeNull();
expect(queryByRole('button', { name: 'Copy Pulse context for Home Assistant' })).toBeNull();
});
it('omits empty operator context without repeating healthy source status', () => {
@@ -1,17 +1,11 @@
import { createEffect, createSignal } from 'solid-js';
import { AgentContextAPI } from '@/api/agentContext';
import type { Resource } from '@/types/resource';
import type { HistoryTimeRange } from '@/api/charts';
import { GUEST_DRAWER_HISTORY_DEFAULT_RANGE } from '@/components/Workloads/guestDrawerModel';
import { aiChatStore } from '@/stores/aiChat';
import { notificationStore } from '@/stores/notifications';
import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage';
import { copyToClipboard } from '@/utils/clipboard';
import { formatAgentResourceContextForClipboard } from '@/utils/agentContextPresentation';
import { useResourceDetailDrawerDockerActionsState } from './useResourceDetailDrawerDockerActionsState';
import { useResourceDetailDrawerHistoryState } from './useResourceDetailDrawerHistoryState';
import { useResourceDetailDrawerDerivedState } from './useResourceDetailDrawerDerivedState';
import { buildResourceAssistantContext } from '@/utils/resourceAssistantContextModel';
import type { ResourceDetailDrawerPresentation } from './resourceDetailDrawerPresentation';
type DrawerTab =
@@ -42,8 +36,6 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
);
const [debugEnabled] = createLocalStorageBooleanSignal(STORAGE_KEYS.DEBUG_MODE, false);
const [copied, setCopied] = createSignal(false);
const [copyingAgentContext, setCopyingAgentContext] = createSignal(false);
const [agentContextCopied, setAgentContextCopied] = createSignal(false);
const [showReportModal, setShowReportModal] = createSignal(false);
const [showHistoryFilters, setShowHistoryFilters] = createSignal(false);
const [showAccessContext, setShowAccessContext] = createSignal(
@@ -123,35 +115,6 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
}
};
const assistantAvailable = () => aiChatStore.enabled === true;
const openAssistantForResource = () => {
if (!assistantAvailable()) return;
aiChatStore.open(buildResourceAssistantContext(resource));
};
const copyAgentContext = async () => {
if (copyingAgentContext()) return;
setCopyingAgentContext(true);
setAgentContextCopied(false);
try {
const context = await AgentContextAPI.getResourceContext(resource.id);
const copiedContext = await copyToClipboard(formatAgentResourceContextForClipboard(context));
if (!copiedContext) {
throw new Error('Clipboard unavailable');
}
setAgentContextCopied(true);
notificationStore.success('Resource context copied.');
setTimeout(() => setAgentContextCopied(false), 2000);
} catch {
notificationStore.error('Unable to copy resource context.');
setAgentContextCopied(false);
} finally {
setCopyingAgentContext(false);
}
};
return {
activeTab,
setActiveTab,
@@ -159,8 +122,6 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
setMetricsHistoryRange,
debugEnabled,
copied,
copyingAgentContext,
agentContextCopied,
showReportModal,
setShowReportModal,
showHistoryFilters,
@@ -190,9 +151,6 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
...history,
...derived,
...dockerActions,
assistantAvailable,
openAssistantForResource,
copyAgentContext,
handleCopyJson,
};
};
@@ -11,7 +11,6 @@ import { Suspense } from 'solid-js';
import type { WorkloadGuest } from '@/types/workloads';
import type { Memory, Disk, GuestNetworkInterface } from '@/types/api';
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
import { aiChatStore } from '@/stores/aiChat';
import { getCanonicalWorkloadId, getWorkloadMetadataId } from '@/utils/workloads';
import { getDiscoveryProvenanceTitle } from '@/utils/discoveryPresentation';
import guestDrawerSource from './GuestDrawer.tsx?raw';
@@ -69,10 +68,6 @@ const discoveryApiMocks = vi.hoisted(() => ({
null,
),
}));
const getResourceContextMock = vi.hoisted(() => vi.fn());
const copyToClipboardMock = vi.hoisted(() => vi.fn());
const notificationSuccessMock = vi.hoisted(() => vi.fn());
const notificationErrorMock = vi.hoisted(() => vi.fn());
vi.mock('@/api/discovery', async () => {
const actual = await vi.importActual<typeof import('@/api/discovery')>('@/api/discovery');
@@ -82,23 +77,6 @@ vi.mock('@/api/discovery', async () => {
};
});
vi.mock('@/api/agentContext', () => ({
AgentContextAPI: {
getResourceContext: getResourceContextMock,
},
}));
vi.mock('@/utils/clipboard', () => ({
copyToClipboard: copyToClipboardMock,
}));
vi.mock('@/stores/notifications', () => ({
notificationStore: {
success: notificationSuccessMock,
error: notificationErrorMock,
},
}));
vi.mock('@/components/shared/WebInterfaceUrlField', () => ({
WebInterfaceUrlField: (props: {
metadataKind: string;
@@ -217,7 +195,6 @@ beforeEach(() => {
afterEach(() => {
cleanup();
aiChatStore.setEnabled(false);
vi.clearAllMocks();
vi.useRealTimers();
});
@@ -277,99 +254,15 @@ describe('GuestDrawer', () => {
expect(screen.queryByTestId('guest-manage-tab')).not.toBeInTheDocument();
});
describe('Assistant context actions', () => {
it('opens Pulse Assistant with a canonical Proxmox LXC resource handoff', () => {
aiChatStore.setEnabled(true);
const openSpy = vi.spyOn(aiChatStore, 'open');
const guest = makeGuest({
id: 'legacy-row-id',
instance: 'pve-main',
node: 'pve-a',
vmid: 101,
name: 'homeassistant',
type: 'lxc',
workloadType: 'system-container',
platformType: 'proxmox',
status: 'running',
discoveryTarget: {
resourceType: 'system-container',
agentId: 'agent-pve-a',
resourceId: '101',
},
});
it('keeps generic Assistant and context-copy actions out of the drawer header', () => {
render(() => <GuestDrawer guest={makeGuest({ name: 'homeassistant' })} onClose={vi.fn()} />);
render(() => <GuestDrawer guest={guest} onClose={vi.fn()} />);
fireEvent.click(
screen.getByRole('button', { name: 'Ask Pulse Assistant about homeassistant' }),
);
expect(openSpy).toHaveBeenCalledWith(
expect.objectContaining({
targetType: 'resource',
targetId: 'pve-main:pve-a:101',
autonomousMode: false,
handoffResources: [
{
id: 'pve-main:pve-a:101',
name: 'homeassistant',
type: 'system-container',
node: 'pve-a',
},
],
handoffMetadata: { kind: 'resource_context' },
}),
);
expect(openSpy.mock.calls[0]?.[0]?.handoffContext).toBeUndefined();
expect(openSpy.mock.calls[0]?.[0]?.briefing?.detailLines).toContain(
'Discovery: system-container:101',
);
openSpy.mockRestore();
});
it('copies the safe agent context bundle using canonical guest identity', async () => {
getResourceContextMock.mockResolvedValue({
canonicalId: 'pve-main:pve-a:101',
resourceType: 'system-container',
resourceName: 'homeassistant',
activeFindings: [],
pendingApprovals: [],
recentActions: [],
generatedAt: '2026-05-06T14:00:00Z',
contextSections: [
{
id: 'runtime',
title: 'Runtime',
source: 'discovery',
trustTier: 'pulse-observed',
generatedAt: '2026-05-06T14:00:00Z',
facts: [{ label: 'Service', value: 'Home Assistant' }],
},
],
});
copyToClipboardMock.mockResolvedValue(true);
const guest = makeGuest({
id: 'legacy-row-id',
instance: 'pve-main',
node: 'pve-a',
vmid: 101,
name: 'homeassistant',
type: 'lxc',
workloadType: 'system-container',
});
render(() => <GuestDrawer guest={guest} onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Copy Pulse context for homeassistant' }));
await waitFor(() => {
expect(getResourceContextMock).toHaveBeenCalledWith('pve-main:pve-a:101');
expect(copyToClipboardMock).toHaveBeenCalledWith(
expect.stringContaining('Pulse resource context: homeassistant'),
);
expect(notificationSuccessMock).toHaveBeenCalledWith('Resource context copied.');
});
});
expect(
screen.queryByRole('button', { name: 'Ask Pulse Assistant about homeassistant' }),
).toBeNull();
expect(
screen.queryByRole('button', { name: 'Copy Pulse context for homeassistant' }),
).toBeNull();
});
// ── Tabs ──
@@ -1,13 +1,7 @@
import { Component, Show, Suspense, createMemo } from 'solid-js';
import CopyIcon from 'lucide-solid/icons/copy';
import MessageSquareIcon from 'lucide-solid/icons/message-square';
import XIcon from 'lucide-solid/icons/x';
import { DiscoveryTab } from '../Discovery/DiscoveryTab';
import {
DrawerHeaderActionButton,
DrawerHeaderActionGroup,
DrawerHeaderIconButton,
} from '@/components/shared/Button';
import { DrawerHeaderActionGroup, DrawerHeaderIconButton } from '@/components/shared/Button';
import { DiscoveryLoadingFallback } from '@/components/shared/DiscoveryLoadingFallback';
import { DrawerSubjectHeading } from '@/components/shared/DrawerSubjectHeading';
import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessBadge';
@@ -25,9 +19,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
agentHeading,
agentLabel,
agentTitle,
agentContextCopied,
backupPresentation,
copyingAgentContext,
discoveryAgentId,
discoveryIdentifiedSummary,
discoveryLoadingState,
@@ -49,9 +41,6 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
guestOsSummary,
networkInterfaces,
normalizedTags,
assistantAvailable,
copyAgentContext,
openAssistantForGuest,
setHistoryRange,
showInGuestAgentInstallCue,
switchTab,
@@ -74,25 +63,6 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
statusLabel={headerIndicator().label}
/>
<DrawerHeaderActionGroup>
<Show when={assistantAvailable()}>
<DrawerHeaderActionButton
onClick={() => openAssistantForGuest()}
title={`Ask Pulse Assistant about ${props.guest.name}`}
aria-label={`Ask Pulse Assistant about ${props.guest.name}`}
>
<MessageSquareIcon class="h-4 w-4" aria-hidden="true" />
<span class="hidden sm:inline">Ask</span>
</DrawerHeaderActionButton>
</Show>
<DrawerHeaderActionButton
onClick={() => void copyAgentContext()}
disabled={copyingAgentContext()}
title={`Copy Pulse context for ${props.guest.name}`}
aria-label={`Copy Pulse context for ${props.guest.name}`}
>
<CopyIcon class="h-4 w-4" aria-hidden="true" />
<span class="hidden sm:inline">{agentContextCopied() ? 'Copied' : 'Copy'}</span>
</DrawerHeaderActionButton>
<DrawerHeaderIconButton onClick={() => props.onClose()} aria-label="Close guest drawer">
<XIcon class="h-4 w-4" aria-hidden="true" />
</DrawerHeaderIconButton>
@@ -1570,7 +1570,7 @@ describe('Workloads performance contract', () => {
expect(guestDrawerSource).toContain('GuestDrawerOverview');
expect(guestDrawerSource).toContain('GuestDrawerHistoryRangeSelect');
expect(guestDrawerSource).toContain('DrawerHeaderActionGroup');
expect(guestDrawerSource).toContain('DrawerHeaderActionButton');
expect(guestDrawerSource).not.toContain('DrawerHeaderActionButton');
expect(guestDrawerSource).toContain('DrawerHeaderIconButton');
expect(guestDrawerSource).not.toContain('Open related infrastructure');
expect(guestDrawerHistorySource).toContain('data-testid="guest-history-hover-time"');
@@ -1,15 +1,10 @@
import { createEffect, createMemo, createSignal } from 'solid-js';
import { AgentContextAPI } from '@/api/agentContext';
import { getDiscovery } from '@/api/discovery';
import type { HistoryTimeRange } from '@/api/charts';
import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery';
import { aiChatStore } from '@/stores/aiChat';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { notificationStore } from '@/stores/notifications';
import type { ResourceDiscovery, ResourceType as DiscoveryResourceType } from '@/types/discovery';
import { formatAgentResourceContextForClipboard } from '@/utils/agentContextPresentation';
import { copyToClipboard } from '@/utils/clipboard';
import {
getDiscoveryIdentifiedSummary,
getDiscoveryLoadingState,
@@ -44,7 +39,6 @@ import {
type GuestDrawerProps,
type GuestDrawerTab,
} from './guestDrawerModel';
import { buildGuestAssistantContext } from './guestAssistantContextModel';
import {
getWorkloadActionAgentTitle,
hasExplicitWorkloadActionAgent,
@@ -63,8 +57,6 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
const [historyRange, setHistoryRange] = createSignal<HistoryTimeRange>(
GUEST_DRAWER_HISTORY_DEFAULT_RANGE,
);
const [copyingAgentContext, setCopyingAgentContext] = createSignal(false);
const [agentContextCopied, setAgentContextCopied] = createSignal(false);
const guestId = createMemo(() => getCanonicalWorkloadId(props.guest));
const alertThresholdScope = createMemo(() => getWorkloadAlertThresholdScope(props.guest));
@@ -161,35 +153,6 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
setActiveTab(tab);
};
const assistantAvailable = () => aiChatStore.enabled === true;
const openAssistantForGuest = () => {
if (!assistantAvailable()) return;
aiChatStore.open(buildGuestAssistantContext(props.guest));
};
const copyAgentContext = async () => {
if (copyingAgentContext()) return;
setCopyingAgentContext(true);
setAgentContextCopied(false);
try {
const context = await AgentContextAPI.getResourceContext(guestId());
const copiedContext = await copyToClipboard(formatAgentResourceContextForClipboard(context));
if (!copiedContext) {
throw new Error('Clipboard unavailable');
}
setAgentContextCopied(true);
notificationStore.success('Resource context copied.');
setTimeout(() => setAgentContextCopied(false), 2000);
} catch {
notificationStore.error('Unable to copy resource context.');
setAgentContextCopied(false);
} finally {
setCopyingAgentContext(false);
}
};
createEffect(() => {
if (activeTab() === 'discovery' && !hasDiscoverySupport()) {
setActiveTab('overview');
@@ -205,8 +168,6 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
agentLabel,
agentTitle,
backupPresentation,
copyingAgentContext,
agentContextCopied,
discoveryAgentId,
discoveryIdentifiedSummary,
discoveryReadinessPresentation,
@@ -232,9 +193,6 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
osVersion,
showInGuestAgentInstallCue,
workloadActionAgentTitle,
assistantAvailable,
openAssistantForGuest,
copyAgentContext,
switchTab,
setHistoryRange,
webInterfaceMetadataId,
@@ -4184,7 +4184,7 @@ describe('shared primitive guardrails', () => {
for (const drawerSource of [guestDrawerSource, resourceDetailDrawerSource]) {
expect(drawerSource).toContain('@/components/shared/Button');
expect(drawerSource).toContain('DrawerHeaderActionGroup');
expect(drawerSource).toContain('DrawerHeaderActionButton');
expect(drawerSource).not.toContain('DrawerHeaderActionButton');
expect(drawerSource).toContain('DrawerHeaderIconButton');
expect(drawerSource).not.toContain(
'inline-flex h-8 min-h-11 min-w-11 items-center gap-1.5 rounded border border-border bg-surface px-2 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500',
@@ -8509,6 +8509,25 @@ describe('shared primitive guardrails', () => {
expect(source).toContain('ResourceOperatorStateSection');
}
for (const headerPath of [
'src/components/Infrastructure/ResourceDetailDrawer.tsx',
'src/components/Workloads/GuestDrawer.tsx',
]) {
const source = readFrontendSource(headerPath);
expect(source).not.toContain('Ask Pulse Assistant about');
expect(source).not.toContain('Copy Pulse context for');
expect(source).not.toContain('copyAgentContext');
}
for (const statePath of [
'src/components/Infrastructure/useResourceDetailDrawerState.ts',
'src/components/Workloads/useGuestDrawerState.ts',
]) {
const source = readFrontendSource(statePath);
expect(source).not.toContain('AgentContextAPI');
expect(source).not.toContain('formatAgentResourceContextForClipboard');
}
const disclosureSource = readFrontendSource(
'src/components/shared/TechnicalDetailsDisclosure.tsx',
);