Keep Actions target names whole on narrow cards (#2539)

## Problem

Each Actions card's second line shows the target name, its type and the
time. The name and type were sibling flex items that shrank together. So
on a phone the line read "analytics-ba… · Virtual ma…" and "backup-coor…
· App cont…", which hid which resource the action touches. That's what
the operator needs to see before opening the review. From 360 to 430px
every target name was cut; from 480px up everything fit.

## Change

In `src/pages/Actions.tsx`, the name and the type now sit in their own
inline flex row:

- **Name:** never shrinks and is capped at the row's width (`shrink-0
max-w-full truncate`).
- **Type:** wrapped together with its separator, takes whatever room is
left (`min-w-0 flex-1 truncate`).

The time keeps its place. A first attempt with a large shrink factor
still gave the name about 0.08px of the shortfall, which was enough to
draw an ellipsis at every pixel density. The two-level row hands the
name no shortfall at all.

## Verification

Mock-mode private stack on main 1dcf9418aa, three open demo actions,
before against after at 360, 390, 430, 480, 640, 768, 1024 and 1440px:

| Viewport | `analytics-batch-01` (main → after) | `backup-coordinator`
(main → after) |
|---|---|---|
| 360 | 61 → 107 of 107px | 67 → 112 of 112px |
| 390 | 77 → 107 | 84 → 112 |
| 430 | 98 → 107 | 107 → 112 |

- **Exact fit:** the name's box equals its text width at 1×, 2× and 3×
pixel ratio. The type with its separator gets the rest ("Virtual
machine" 21, 51 and 91px at 360, 390 and 430px; whole from 480px).
Nothing changes from 480px up.
- **Long names:** a name longer than the line, injected in the browser,
truncates inside it at 360 and 390px with the time fully visible.
- **Interactions:** the review opens from a card and closes with Escape
at 390 and 1024px, and the History tab works. No console errors.
- **Tests:** the new
`features/actions/__tests__/Actions.metaLine.test.tsx` pins the
structure and fails against main's source. 25 test files across actions,
pages, routing and the resource-actions API pass.

The browser receipt is
`frontend-modern/browser-verification/2294fd5a84fdc31a.json`.
This commit is contained in:
rcourtman
2026-10-05 23:12:54 +01:00
committed by GitHub
4 changed files with 170 additions and 9 deletions
@@ -3568,8 +3568,12 @@ not part of `ActionRequest`, durable plan identity, or `planHash`; a resource
rename therefore cannot change action authority. Opaque canonical resource IDs
remain in the row's accessible name and title, while the visual row prefers the
API-supplied name and type and falls back to the bounded ID-derived type plus
short suffix only when the resource projection is unavailable. Read-only demo
posture is quiet supporting context rather than a page-level callout. The
short suffix only when the resource projection is unavailable. When the row is
too narrow for both, the resource name keeps its width and only the type, with
its separator, gives way: on a phone they used to shrink together, so the row
read "analytics-ba… · Virtual ma…" and hid which resource the action touches.
Read-only demo posture is quiet supporting context rather than a page-level
callout. The
empty Open queue must explain producer state rather than promising content:
when the effective Patrol mode is Watch only (read via the canonical
`/api/ai/patrol/autonomy` settings read, failing closed to the generic calm
@@ -0,0 +1,62 @@
{
"version": 1,
"base_sha": "1dcf9418aae87f9427dbfc6bcae67dbde114bf8a",
"verified_at": "2026-10-05T21:08:04Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/pages/Actions.tsx"
],
"content_sha256": {
"frontend-modern/src/pages/Actions.tsx": "17ded57aa6d0476a01c4d0cfec5dee8c9fed2be0fbf50c3d3f5cd2e40c278de6"
},
"routes": [
"/actions",
"/actions/history"
],
"viewports": [
{
"width": 360,
"height": 844
},
{
"width": 390,
"height": 844
},
{
"width": 430,
"height": 844
},
{
"width": 480,
"height": 844
},
{
"width": 640,
"height": 844
},
{
"width": 768,
"height": 844
},
{
"width": 1024,
"height": 844
},
{
"width": 1440,
"height": 844
}
],
"states": [
"Private mock stack (PULSE_MOCK_MODE) serving this tree on main 1dcf9418aa; before = main, after = main plus this change, measured in the same session on the three open demo actions",
"Main: the target name and its type shrank together, so from 360 to 430 every name was cut (analytics-batch-01 61-98 of 107px, backup-coordinator 67-107 of 112px) beside a cut type (Virtual ma\u2026, App conta\u2026); from 480 everything fit",
"Patched: the name keeps its full width at every width (box equals text width, 106.61px for analytics-batch-01, at 1x, 2x and 3x device pixel ratio), and the type with its separator takes the rest (Virtual machine 21px at 360, 51px at 390, 91px at 430, whole from 480); nothing changes from 480 up",
"A name longer than the line (very-long-resource-name-on-a-production-cluster-node-07, injected) truncates inside the line at 360 and 390 with the relative time still fully visible, and fits whole at 768",
"Before and after element screenshots of the three cards at 360, 390 and 1024 at 3x density"
],
"interactions": [
"Loaded /actions fresh at 390 (mobile emulation) and resized through each viewport before measuring",
"Opened the second card's review at 390 and 1024 (Governed action review, analytics-batch-01 \u00b7 Virtual machine) and closed it with Escape",
"Switched to the History tab (/actions/history, 3 rows); no console errors"
]
}
@@ -0,0 +1,89 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { Route, Router } from '@solidjs/router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ResourceActionsAPI } from '@/api/resourceActions';
import { SecurityAPI } from '@/api/security';
import type { ActionAuditRecord } from '@/types/actionAudit';
import { getActionResourcePresentation } from '../actionPresentation';
import Actions from '../../../pages/Actions';
vi.mock('@/api/resourceActions', () => ({
ResourceActionsAPI: { listActions: vi.fn(), getAction: vi.fn() },
}));
vi.mock('@/api/security', () => ({ SecurityAPI: { getStatus: vi.fn() } }));
vi.mock('@/api/patrol', () => ({ getPatrolAutonomySettings: vi.fn().mockResolvedValue({}) }));
const resourceId = 'docker:container:analytics-batch-01';
const audit: ActionAuditRecord = {
id: 'restart-analytics',
createdAt: '2026-09-29T18:00:00Z',
updatedAt: '2026-09-29T18:01:00Z',
state: 'executing',
decisionRevision: 1,
request: {
requestId: 'request-restart-analytics',
resourceId,
capabilityName: 'restart',
reason: 'Recover checkout after three failed health checks',
requestedBy: 'operator',
},
plan: {
actionId: 'restart-analytics',
requestId: 'request-restart-analytics',
allowed: true,
requiresApproval: false,
approvalPolicy: 'none',
rollbackAvailable: false,
expiresAt: '2026-09-29T21:00:00Z',
planHash: 'sha256:plan-restart-analytics',
},
verificationOutcome: { status: 'unknown' },
};
beforeEach(() => {
window.history.replaceState(null, '', '/');
vi.mocked(ResourceActionsAPI.listActions).mockResolvedValue({
actions: [audit],
count: 1,
view: 'pending',
readOnly: false,
});
vi.mocked(SecurityAPI.getStatus).mockResolvedValue({
hasAuthentication: true,
requiresAuth: true,
settingsCapabilities: { authenticationWrite: false },
} as Awaited<ReturnType<typeof SecurityAPI.getStatus>>);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
window.history.replaceState(null, '', '/');
});
describe('Actions card resource line', () => {
it('keeps the target name whole and lets its type give way on narrow cards', async () => {
const { label, detail } = getActionResourcePresentation(resourceId);
expect(label).toBe('analytics-batch-01');
expect(detail).not.toBe('');
render(() => (
<Router>
<Route path="*" component={Actions} />
</Router>
));
// On a phone the name and its type used to shrink together, so the line
// read "analytics-ba… · Virtual ma…" and hid which resource the action
// touches. The name never shrinks below its own width; the type and its
// separator take whatever room is left.
const name = await screen.findByText(label);
expect(name).toHaveClass('shrink-0', 'max-w-full', 'truncate');
const type = screen.getByText(detail).parentElement!;
expect(type).toHaveClass('min-w-0', 'flex-1', 'truncate');
expect(type.querySelector('[aria-hidden="true"]')).toHaveTextContent('·');
expect(type.parentElement).toBe(name.parentElement);
expect(name.parentElement?.closest(`[title="${resourceId}"]`)).not.toBeNull();
});
});
+13 -7
View File
@@ -334,13 +334,19 @@ export function Actions() {
class="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-muted"
title={action.request.resourceId}
>
<span class="truncate">{resource().label}</span>
<Show when={resource().detail}>
<span aria-hidden="true">·</span>
<span class="truncate font-mono text-[11px]">
{resource().detail}
</span>
</Show>
{/* The name says what the action touches, so it keeps its
width and only the type, with its separator, gives way. */}
<span class="flex min-w-0 items-center gap-1.5">
<span class="max-w-full shrink-0 truncate">{resource().label}</span>
<Show when={resource().detail}>
<span class="min-w-0 flex-1 truncate">
<span class="mr-1.5" aria-hidden="true">
·
</span>
<span class="font-mono text-[11px]">{resource().detail}</span>
</span>
</Show>
</span>
<span aria-hidden="true">·</span>
<span class="shrink-0">{formatRelativeTime(action.updatedAt)}</span>
</div>