Keep Actions target names whole on narrow cards

Each Actions card's second line shows the target name, its type and
the time. Name and type were sibling flex items that shrank together,
so on a phone the line read "analytics-ba... · Virtual ma..." and hid
which resource the action touches, which is what the operator needs
before opening the review. From 360 to 430px every target name was
cut.

The name and the type now sit in their own row where the name keeps
its width (capped at the line) and the type, with its separator,
takes whatever is left. Names read in full at every width from 360px
(the box now equals the text width at 1x, 2x and 3x pixel ratio), a
name longer than the line still truncates with the time visible, and
nothing changes from 480px up.
This commit is contained in:
rcourtman
2026-10-05 22:08:34 +01:00
parent 1dcf9418aa
commit 4819afb082
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>