feat(FN-4047): add responsive compact timestamps and mobile icon-only contr

Restored compact timestamp rendering in AgentDetailView and TaskDetailModal, added responsive timestamp layout for mobile and desktop, and added mobile icon-only stop/run controls to AgentDetailView, with new tests covering both components and a documentation note for the compact timestamp behavior.

Fusion-Task-Id: FN-4047
This commit is contained in:
Fusion
2026-05-11 18:50:27 -07:00
committed by gsxdsm
parent 5c3a1df977
commit 1eb6e87914
7 changed files with 149 additions and 13 deletions

View File

@@ -629,18 +629,18 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
<Pause size={14} />
<span className="agent-detail-control-label">Pause</span>
</button>
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("paused")} disabled={isTransitioning}>
<button className="btn btn--danger btn--compact agent-detail-mobile-icon-control" onClick={() => void handleStateChange("paused")} disabled={isTransitioning} aria-label="Stop">
<Square size={14} />
Stop
<span className="agent-detail-control-label">Stop</span>
</button>
<button
className="btn btn-task-create btn--compact"
className="btn btn-task-create btn--compact agent-detail-mobile-icon-control"
onClick={() => void handleRunHeartbeat()}
aria-label={`Run now for ${agent.name}`}
disabled={isStartingRun || isTransitioning}
>
<Activity size={14} />
Run Now
<span className="agent-detail-control-label">Run Now</span>
</button>
</>
)}
@@ -662,9 +662,9 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
<Pause size={14} />
<span className="agent-detail-control-label">Pause</span>
</button>
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("paused")} disabled={isTransitioning}>
<button className="btn btn--danger btn--compact agent-detail-mobile-icon-control" onClick={() => void handleStateChange("paused")} disabled={isTransitioning} aria-label="Stop">
<Square size={14} />
Stop
<span className="agent-detail-control-label">Stop</span>
</button>
</>
)}
@@ -674,9 +674,9 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
<Play size={14} />
Retry
</button>
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("paused")} disabled={isTransitioning}>
<button className="btn btn--danger btn--compact agent-detail-mobile-icon-control" onClick={() => void handleStateChange("paused")} disabled={isTransitioning} aria-label="Stop">
<Square size={14} />
Stop
<span className="agent-detail-control-label">Stop</span>
</button>
</>
)}

View File

@@ -157,14 +157,47 @@
}
.detail-timestamps {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs);
color: var(--text-dim);
margin-top: var(--space-xs);
}
.detail-timestamp-item {
display: inline-flex;
align-items: baseline;
gap: var(--space-xs);
}
.detail-timestamp-label {
color: var(--text-muted);
}
.detail-timestamp-separator {
color: var(--text-dim);
}
@media (max-width: 768px) {
.detail-provenance {
align-items: flex-start;
}
.detail-timestamps {
display: flex;
align-items: flex-start;
flex-direction: column;
}
.detail-timestamp-item {
align-items: flex-start;
flex-wrap: wrap;
}
.detail-timestamp-separator {
display: none;
}
}
.detail-meta-inline-controls {

View File

@@ -223,7 +223,7 @@ function formatTimestamp(iso: string): string {
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHr < 24) return `${diffHr}h ago`;
if (diffDay < 7) return `${diffDay}d ago`;
return date.toLocaleDateString();
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function formatBytes(bytes: number): string {
@@ -2069,9 +2069,22 @@ export function TaskDetailContent({
</span>
</div>
)}
<div className="detail-timestamps">
Created {new Date(task.createdAt).toLocaleDateString()} · Updated{" "}
{new Date(task.updatedAt).toLocaleDateString()}
<div className="detail-timestamps" aria-label="Task timestamps">
<span className="detail-timestamp-item">
<span className="detail-timestamp-label">Created</span>{" "}
<time dateTime={task.createdAt} title={new Date(task.createdAt).toLocaleString()}>
{formatTimestamp(task.createdAt)}
</time>
</span>
<span className="detail-timestamp-separator" aria-hidden="true">
·
</span>
<span className="detail-timestamp-item">
<span className="detail-timestamp-label">Updated</span>{" "}
<time dateTime={task.updatedAt} title={new Date(task.updatedAt).toLocaleString()}>
{formatTimestamp(task.updatedAt)}
</time>
</span>
</div>
</div>
</>

View File

@@ -1221,6 +1221,22 @@ describe("AgentDetailView", () => {
});
});
it("keeps active header Stop and Run Now buttons accessible by name while using mobile icon-control class", async () => {
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
const stopButton = await screen.findByRole("button", { name: "Stop" });
const runNowButton = await screen.findByRole("button", { name: "Run now for Test Agent" });
expect(stopButton.className).toContain("agent-detail-mobile-icon-control");
expect(runNowButton.className).toContain("agent-detail-mobile-icon-control");
});
it("transitions running agent to paused when Stop is clicked", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running" }));
@@ -1312,6 +1328,7 @@ describe("AgentDetailView", () => {
expect(stylesContent).toContain("grid-column: 1;");
expect(stylesContent).toContain(".agent-detail-header-actions {");
expect(stylesContent).toContain("grid-column: 2;");
expect(stylesContent).toContain(".agent-detail-controls .agent-detail-mobile-icon-control {");
expect(stylesContent).toContain(".agent-detail-mobile-icon-control .agent-detail-control-label {");
});

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
@@ -162,6 +162,65 @@ describe("TaskDetailModal", () => {
expect(timestamps).toBeTruthy();
expect(provenance?.compareDocumentPosition(timestamps as Node) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
describe("compact timestamp metadata", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-11T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("renders compact relative timestamps for recent tasks", () => {
render(
<TaskDetailModal
task={makeTask({
sourceType: "dashboard_ui",
createdAt: "2026-05-09T12:00:00.000Z",
updatedAt: "2026-05-11T09:00:00.000Z",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const timestamps = screen.getByLabelText("Task timestamps");
expect(timestamps).toHaveTextContent("Created 2d ago");
expect(timestamps).toHaveTextContent("Updated 3h ago");
const times = timestamps.querySelectorAll("time");
expect(times[0]?.getAttribute("dateTime")).toBe("2026-05-09T12:00:00.000Z");
expect(times[1]?.getAttribute("dateTime")).toBe("2026-05-11T09:00:00.000Z");
});
it("renders short calendar date for older timestamps", () => {
render(
<TaskDetailModal
task={makeTask({
sourceType: "dashboard_ui",
createdAt: "2026-05-01T12:00:00.000Z",
updatedAt: "2026-05-02T12:00:00.000Z",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const timestamps = screen.getByLabelText("Task timestamps");
expect(timestamps).toHaveTextContent("Created May 1");
expect(timestamps).toHaveTextContent("Updated May 2");
});
});
});
it("renders modal wrapper structure and default close control", () => {

View File

@@ -27,6 +27,17 @@ describe("TaskDetailModal", () => {
expectBaseRule(css, ".detail-meta-inline-controls", "flex-wrap: nowrap;");
expect(css).not.toMatch(/@media \(max-width: 640px\)\s*\{[^}]*\.detail-meta-inline-controls\s*\{[^}]*flex-direction:\s*column;/);
});
it("uses grouped timestamp metadata on desktop with mobile stacked overrides", () => {
const css = readDashboardStylesSource();
expectBaseRule(css, ".detail-timestamps", "display: inline-flex;");
expectBaseRule(css, ".detail-timestamps", "flex-wrap: wrap;");
expectBaseRule(css, ".detail-timestamp-item", "display: inline-flex;");
expect(css).toMatch(/@media \(max-width: 768px\)\s*\{\s*\.detail-provenance\s*\{[^}]*\}\s*\.detail-timestamps\s*\{[^}]*flex-direction:\s*column;/);
expect(css).toMatch(/@media \(max-width: 768px\)\s*\{[\s\S]*?\.detail-timestamp-separator\s*\{[^}]*display:\s*none;/);
});
it("renders responsive structural classes (modal-lg, overlay, spacer, tabs, detail-body)", () => {
const { container } = render(
<TaskDetailModal
@@ -43,6 +54,8 @@ describe("TaskDetailModal", () => {
expect(container.querySelector(".modal-overlay.open")).toBeTruthy();
expect(container.querySelector(".modal-actions .modal-actions-spacer")).toBeTruthy();
expect(container.querySelector(".detail-body")).toBeTruthy();
expect(container.querySelector(".detail-timestamps")).toBeTruthy();
expect(container.querySelectorAll(".detail-timestamp-item").length).toBe(2);
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(10);
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true);