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:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 {");
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user