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 1772572191
commit 2484591ae2
7 changed files with 149 additions and 13 deletions

View File

@@ -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**.

View File

@@ -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>
</> </>
)} )}

View File

@@ -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 {

View File

@@ -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>
</> </>

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 () => { 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 {");
}); });

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 { 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", () => {

View File

@@ -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);