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:
@@ -427,6 +427,7 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou
|
|||||||
- The priority chip in task metadata is an inline picker: you can change priority directly without entering full edit mode.
|
- The priority chip in task metadata is an inline picker: you can change priority directly without entering full edit mode.
|
||||||
- Execution mode has a read-mode inline lightning-bolt toggle for Fast mode on/off without opening the full edit form.
|
- Execution mode has a read-mode inline lightning-bolt toggle for Fast mode on/off without opening the full edit form.
|
||||||
- These two metadata controls share matched sizing/alignment in read mode (including mobile wrapping) so they behave like a single polished control group.
|
- These two metadata controls share matched sizing/alignment in read mode (including mobile wrapping) so they behave like a single polished control group.
|
||||||
|
- Task metadata also shows compact `Created` / `Updated` timestamps: recent values render as relative time (`just now`, `Xm`, `Xh`, `Xd`) and older values switch to short month/day dates, with a mobile-stacked layout for readability.
|
||||||
- Eligible existing tasks (triage, todo, in-progress, in-review) expose a **GitHub tracking** section directly in Task Detail, even when tracking is currently disabled.
|
- Eligible existing tasks (triage, todo, in-progress, in-review) expose a **GitHub tracking** section directly in Task Detail, even when tracking is currently disabled.
|
||||||
- The GitHub tracking section now defaults to a compact summary row; use the disclosure arrow to expand linked-issue details plus tracking edit controls.
|
- The GitHub tracking section now defaults to a compact summary row; use the disclosure arrow to expand linked-issue details plus tracking edit controls.
|
||||||
- In shared task edit/create forms, GitHub Tracking appears at the bottom of **More options**, after **Workflow Steps**.
|
- In shared task edit/create forms, GitHub Tracking appears at the bottom of **More options**, after **Workflow Steps**.
|
||||||
|
|||||||
@@ -629,18 +629,18 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
|||||||
<Pause size={14} />
|
<Pause size={14} />
|
||||||
<span className="agent-detail-control-label">Pause</span>
|
<span className="agent-detail-control-label">Pause</span>
|
||||||
</button>
|
</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} />
|
<Square size={14} />
|
||||||
Stop
|
<span className="agent-detail-control-label">Stop</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-task-create btn--compact"
|
className="btn btn-task-create btn--compact agent-detail-mobile-icon-control"
|
||||||
onClick={() => void handleRunHeartbeat()}
|
onClick={() => void handleRunHeartbeat()}
|
||||||
aria-label={`Run now for ${agent.name}`}
|
aria-label={`Run now for ${agent.name}`}
|
||||||
disabled={isStartingRun || isTransitioning}
|
disabled={isStartingRun || isTransitioning}
|
||||||
>
|
>
|
||||||
<Activity size={14} />
|
<Activity size={14} />
|
||||||
Run Now
|
<span className="agent-detail-control-label">Run Now</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -662,9 +662,9 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
|||||||
<Pause size={14} />
|
<Pause size={14} />
|
||||||
<span className="agent-detail-control-label">Pause</span>
|
<span className="agent-detail-control-label">Pause</span>
|
||||||
</button>
|
</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} />
|
<Square size={14} />
|
||||||
Stop
|
<span className="agent-detail-control-label">Stop</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -674,9 +674,9 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
|||||||
<Play size={14} />
|
<Play size={14} />
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</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} />
|
<Square size={14} />
|
||||||
Stop
|
<span className="agent-detail-control-label">Stop</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -157,14 +157,47 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-timestamps {
|
.detail-timestamps {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
margin-top: var(--space-xs);
|
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) {
|
@media (max-width: 768px) {
|
||||||
.detail-provenance {
|
.detail-provenance {
|
||||||
align-items: flex-start;
|
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 {
|
.detail-meta-inline-controls {
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ function formatTimestamp(iso: string): string {
|
|||||||
if (diffMin < 60) return `${diffMin}m ago`;
|
if (diffMin < 60) return `${diffMin}m ago`;
|
||||||
if (diffHr < 24) return `${diffHr}h ago`;
|
if (diffHr < 24) return `${diffHr}h ago`;
|
||||||
if (diffDay < 7) return `${diffDay}d ago`;
|
if (diffDay < 7) return `${diffDay}d ago`;
|
||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
@@ -2069,9 +2069,22 @@ export function TaskDetailContent({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="detail-timestamps">
|
<div className="detail-timestamps" aria-label="Task timestamps">
|
||||||
Created {new Date(task.createdAt).toLocaleDateString()} · Updated{" "}
|
<span className="detail-timestamp-item">
|
||||||
{new Date(task.updatedAt).toLocaleDateString()}
|
<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>
|
||||||
</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 () => {
|
it("transitions running agent to paused when Stop is clicked", async () => {
|
||||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running" }));
|
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running" }));
|
||||||
|
|
||||||
@@ -1312,6 +1328,7 @@ describe("AgentDetailView", () => {
|
|||||||
expect(stylesContent).toContain("grid-column: 1;");
|
expect(stylesContent).toContain("grid-column: 1;");
|
||||||
expect(stylesContent).toContain(".agent-detail-header-actions {");
|
expect(stylesContent).toContain(".agent-detail-header-actions {");
|
||||||
expect(stylesContent).toContain("grid-column: 2;");
|
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 {");
|
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 { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import {
|
import {
|
||||||
@@ -162,6 +162,65 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(timestamps).toBeTruthy();
|
expect(timestamps).toBeTruthy();
|
||||||
expect(provenance?.compareDocumentPosition(timestamps as Node) & Node.DOCUMENT_POSITION_FOLLOWING).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", () => {
|
it("renders modal wrapper structure and default close control", () => {
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ describe("TaskDetailModal", () => {
|
|||||||
expectBaseRule(css, ".detail-meta-inline-controls", "flex-wrap: nowrap;");
|
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;/);
|
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)", () => {
|
it("renders responsive structural classes (modal-lg, overlay, spacer, tabs, detail-body)", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
@@ -43,6 +54,8 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(container.querySelector(".modal-overlay.open")).toBeTruthy();
|
expect(container.querySelector(".modal-overlay.open")).toBeTruthy();
|
||||||
expect(container.querySelector(".modal-actions .modal-actions-spacer")).toBeTruthy();
|
expect(container.querySelector(".modal-actions .modal-actions-spacer")).toBeTruthy();
|
||||||
expect(container.querySelector(".detail-body")).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");
|
const tabs = container.querySelectorAll(".detail-tab");
|
||||||
expect(tabs.length).toBe(10);
|
expect(tabs.length).toBe(10);
|
||||||
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true);
|
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user