FN-5845: fix mobile agent log scroll and add collapsible branch group card
Improve dashboard mobile log usability and make branch group cards collapsible for cleaner review workflows. - preserve/restore AgentLogViewer scroll position when toggling mobile drawer and lock body scroll while open - add collapse/expand state to BranchGroupCard with a summary header and hidden details when collapsed - add regression tests for AgentLogViewer mobile scroll behavior and BranchGroupCard collapse interactions - add a changeset and dashboard guide note for the new branch group card behavior Files changed: .changeset/fn-5845-branch-group-collapse.md | 5 ++ docs/dashboard-guide.md | 1 + .../dashboard/app/components/AgentLogViewer.tsx | 21 ++++++++ .../dashboard/app/components/BranchGroupCard.css | 23 ++++++++ .../dashboard/app/components/BranchGroupCard.tsx | 63 +++++++++++++++++----- .../components/__tests__/AgentLogViewer.test.tsx | 48 +++++++++++++++++ .../components/__tests__/BranchGroupCard.test.tsx | 18 +++++++ 7 files changed, 166 insertions(+), 13 deletions(-) Fusion-Task-Id: FN-5845 Fusion-Task-Lineage: 6a0f2967-5df8-4761-ae6c-50991f01ab78
This commit is contained in:
5
.changeset/fn-5845-branch-group-collapse.md
Normal file
5
.changeset/fn-5845-branch-group-collapse.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix mobile Task Detail Logs scrolling for branch-group tasks by making the branch-group card collapsible and re-pinning the agent log viewer when its container height changes.
|
||||||
@@ -1135,6 +1135,7 @@ UI surfaces:
|
|||||||
- Task cards show grouped/shared branch metadata for grouped tasks.
|
- Task cards show grouped/shared branch metadata for grouped tasks.
|
||||||
- Clicking either grouped badge opens the dedicated **Group Task Modal** for that branch group.
|
- Clicking either grouped badge opens the dedicated **Group Task Modal** for that branch group.
|
||||||
- Task detail renders a branch-group card with member landed progress.
|
- Task detail renders a branch-group card with member landed progress.
|
||||||
|
- In Task Detail Logs on mobile, the branch-group card includes a collapse/expand toggle so logs can reclaim vertical space while keeping group summary progress visible.
|
||||||
|
|
||||||
The Group Task Modal shows shared branch name/status, member list (`taskId`, title, column, landed state), quick links to open each member task detail, completion progress (`X of Y members finished`), and tracked PR state when present. It live-refreshes from the same dashboard task-update stream and ignores stale cross-project events.
|
The Group Task Modal shows shared branch name/status, member list (`taskId`, title, column, landed state), quick links to open each member task detail, completion progress (`X of Y members finished`), and tracked PR state when present. It live-refreshes from the same dashboard task-update stream and ignores stale cross-project events.
|
||||||
|
|
||||||
|
|||||||
@@ -393,6 +393,27 @@ export function AgentLogViewer({
|
|||||||
setIsFollowing(true);
|
setIsFollowing(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof ResizeObserver === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (!isFollowing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [isFollowing]);
|
||||||
|
|
||||||
// Escape key handler to exit fullscreen mode
|
// Escape key handler to exit fullscreen mode
|
||||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && isFullscreen) {
|
if (e.key === "Escape" && isFullscreen) {
|
||||||
|
|||||||
@@ -21,10 +21,28 @@
|
|||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.branch-group-card-header-meta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.branch-group-card-badge {
|
.branch-group-card-badge {
|
||||||
background: var(--surface-elevated);
|
background: var(--surface-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.branch-group-card-header-meta .btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-group-card-header-meta .btn-icon {
|
||||||
|
padding: calc(var(--space-xs) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-group-card-header-meta .btn-icon svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.branch-group-card-progress-text {
|
.branch-group-card-progress-text {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
@@ -80,4 +98,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.branch-group-card-header-meta {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import "./BranchGroupCard.css";
|
import "./BranchGroupCard.css";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { CheckCircle2, CircleDashed, ExternalLink, GitBranch, GitPullRequest, Loader2 } from "lucide-react";
|
import { CheckCircle2, ChevronDown, ChevronRight, CircleDashed, ExternalLink, GitBranch, GitPullRequest, Loader2 } from "lucide-react";
|
||||||
import type { BranchGroupSummary } from "../api";
|
import type { BranchGroupSummary } from "../api";
|
||||||
import { apiGetBranchGroup, apiPromoteBranchGroup } from "../api";
|
import { apiGetBranchGroup, apiPromoteBranchGroup } from "../api";
|
||||||
import { subscribeSse } from "../sse-bus";
|
import { subscribeSse } from "../sse-bus";
|
||||||
@@ -15,6 +15,7 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [promoting, setPromoting] = useState(false);
|
const [promoting, setPromoting] = useState(false);
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
const loadGroup = useCallback(async () => {
|
const loadGroup = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -34,6 +35,29 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
|
|||||||
void loadGroup();
|
void loadGroup();
|
||||||
}, [loadGroup]);
|
}, [loadGroup]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mediaQuery = window.matchMedia("(max-width: 768px)");
|
||||||
|
const syncCollapsed = (matches: boolean) => {
|
||||||
|
setCollapsed(matches);
|
||||||
|
};
|
||||||
|
|
||||||
|
syncCollapsed(mediaQuery.matches);
|
||||||
|
const onMediaChange = (event: MediaQueryListEvent) => {
|
||||||
|
syncCollapsed(event.matches);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof mediaQuery.addEventListener === "function") {
|
||||||
|
mediaQuery.addEventListener("change", onMediaChange);
|
||||||
|
return () => mediaQuery.removeEventListener("change", onMediaChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaQuery.addListener(onMediaChange);
|
||||||
|
return () => mediaQuery.removeListener(onMediaChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||||
return subscribeSse(`/api/events${query}`, {
|
return subscribeSse(`/api/events${query}`, {
|
||||||
@@ -83,24 +107,37 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
|
|||||||
<GitBranch size={14} />
|
<GitBranch size={14} />
|
||||||
<strong>{group.branchName}</strong>
|
<strong>{group.branchName}</strong>
|
||||||
</div>
|
</div>
|
||||||
<span className="badge branch-group-card-badge">Group {group.id}</span>
|
<div className="branch-group-card-header-meta">
|
||||||
|
<span className="badge branch-group-card-badge">Group {group.id}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-icon"
|
||||||
|
onClick={() => setCollapsed((value) => !value)}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
aria-label={collapsed ? "Expand branch group" : "Collapse branch group"}
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="branch-group-card-progress-text">{completionText}</div>
|
<div className="branch-group-card-progress-text">{completionText}</div>
|
||||||
<div className="branch-group-card-progress" role="progressbar" aria-valuenow={group.completion.landed} aria-valuemin={0} aria-valuemax={group.completion.total}>
|
<div className="branch-group-card-progress" role="progressbar" aria-valuenow={group.completion.landed} aria-valuemin={0} aria-valuemax={group.completion.total}>
|
||||||
<span className="branch-group-card-progress-fill" style={{ width: `${completionPercent}%` }} />
|
<span className="branch-group-card-progress-fill" style={{ width: `${completionPercent}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="branch-group-card-members">
|
{!collapsed && (
|
||||||
{group.members.map((member) => (
|
<ul className="branch-group-card-members">
|
||||||
<li key={member.taskId} className="branch-group-card-member">
|
{group.members.map((member) => (
|
||||||
<span className={`status-dot ${member.landed ? "status-dot--online" : "status-dot--pending"}`} />
|
<li key={member.taskId} className="branch-group-card-member">
|
||||||
<span className="branch-group-card-member-title">{member.taskId} · {member.title}</span>
|
<span className={`status-dot ${member.landed ? "status-dot--online" : "status-dot--pending"}`} />
|
||||||
<span className="branch-group-card-member-status">{member.landed ? <CheckCircle2 size={14} /> : <CircleDashed size={14} />}</span>
|
<span className="branch-group-card-member-title">{member.taskId} · {member.title}</span>
|
||||||
</li>
|
<span className="branch-group-card-member-status">{member.landed ? <CheckCircle2 size={14} /> : <CircleDashed size={14} />}</span>
|
||||||
))}
|
</li>
|
||||||
</ul>
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
{complete && (
|
{!collapsed && complete && (
|
||||||
<div className="branch-group-card-actions">
|
<div className="branch-group-card-actions">
|
||||||
{group.prUrl && (
|
{group.prUrl && (
|
||||||
<a className="btn" href={group.prUrl} target="_blank" rel="noreferrer">
|
<a className="btn" href={group.prUrl} target="_blank" rel="noreferrer">
|
||||||
|
|||||||
@@ -1137,6 +1137,54 @@ describe("AgentLogViewer", () => {
|
|||||||
expect(viewer.scrollTop).toBe(1000);
|
expect(viewer.scrollTop).toBe(1000);
|
||||||
expect(screen.queryByTestId("agent-log-return-to-live")).toBeNull();
|
expect(screen.queryByTestId("agent-log-return-to-live")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("re-pins to bottom on resize while following", () => {
|
||||||
|
const resizeCallbacks: Array<() => void> = [];
|
||||||
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
|
|
||||||
|
class ResizeObserverMock {
|
||||||
|
constructor(callback: ResizeObserverCallback) {
|
||||||
|
resizeCallbacks.push(() => callback([], this as unknown as ResizeObserver));
|
||||||
|
}
|
||||||
|
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, "ResizeObserver", {
|
||||||
|
configurable: true,
|
||||||
|
value: ResizeObserverMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = [
|
||||||
|
makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }),
|
||||||
|
makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }),
|
||||||
|
];
|
||||||
|
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||||
|
const viewer = getScrollContainer(container);
|
||||||
|
|
||||||
|
Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1200 });
|
||||||
|
Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 });
|
||||||
|
viewer.scrollTop = 980;
|
||||||
|
fireEvent.scroll(viewer);
|
||||||
|
|
||||||
|
viewer.scrollTop = 640;
|
||||||
|
resizeCallbacks.forEach((callback) => callback());
|
||||||
|
|
||||||
|
expect(viewer.scrollTop).toBe(1200);
|
||||||
|
} finally {
|
||||||
|
if (originalResizeObserver) {
|
||||||
|
Object.defineProperty(globalThis, "ResizeObserver", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalResizeObserver,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
delete (globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("pagination placement", () => {
|
describe("pagination placement", () => {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ vi.mock("../../sse-bus", () => ({
|
|||||||
|
|
||||||
vi.mock("lucide-react", () => ({
|
vi.mock("lucide-react", () => ({
|
||||||
CheckCircle2: () => null,
|
CheckCircle2: () => null,
|
||||||
|
ChevronDown: () => null,
|
||||||
|
ChevronRight: () => null,
|
||||||
CircleDashed: () => null,
|
CircleDashed: () => null,
|
||||||
ExternalLink: () => null,
|
ExternalLink: () => null,
|
||||||
GitBranch: () => null,
|
GitBranch: () => null,
|
||||||
@@ -93,4 +95,20 @@ describe("BranchGroupCard", () => {
|
|||||||
render(<BranchGroupCard groupId="BG-1" />);
|
render(<BranchGroupCard groupId="BG-1" />);
|
||||||
expect(await screen.findByRole("link", { name: /pr #9/i })).toBeInTheDocument();
|
expect(await screen.findByRole("link", { name: /pr #9/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows members by default and collapses via toggle", async () => {
|
||||||
|
apiGetBranchGroup.mockResolvedValue({ group: makeGroup() });
|
||||||
|
render(<BranchGroupCard groupId="BG-1" />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("FN-1 · one")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const toggle = screen.getByRole("button", { name: /collapse branch group/i });
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||||
|
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /expand branch group/i })).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(screen.queryByText("FN-1 · one")).toBeNull();
|
||||||
|
expect(screen.getByText("1 of 2 members finished")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user