feat(web): add PWA manifest and wire mobile accordion on dashboard (#1476)

* feat(web): add PWA manifest and wire mobile accordion on dashboard

- Add manifest.json with standalone display mode and theme colors
- Link manifest in layout.tsx generateMetadata
- Pass compactMobile, collapsed, onToggle to AttentionZone from Dashboard
  so the mobile accordion (already implemented) is now activated

Closes #175

* fix(web): add missing PWA icons and split icon purposes

- Add icon-192.png and icon-512.png to public/ (were 404ing on PWA install)
- Split combined 'any maskable' into separate icon entries per W3C
  best practice to avoid safe-zone cropping in non-maskable contexts
- Apply Prettier formatting to Dashboard.tsx (long SVG attribute lines)

* fix(web): resolve merge conflict, fix types, use dynamic icon routes

- Resolve Dashboard.tsx merge conflict with upstream refactored useSessionEvents
- Type handleZoneToggle as (level: AttentionLevel) per reviewer request
- Start all zones collapsed on mobile for better UX (collapsedZones init)
- Add scope to manifest.json per PWA best practice
- Switch manifest icons from static PNGs to dynamic /icon-192 and /icon-512
  routes that use the existing renderIconElement system (branded icons)
- Remove static icon-192.png and icon-512.png black square placeholders

* fix(web): wire BottomSheet preview, fix mobile tests, update manifest

- Add onPreview to AttentionZone, opens BottomSheet on mobile tap
- Add previewSession state and bottom sheet handlers to Dashboard
- Default collapsedZones to done+working only, not all zones
- Fix isMergeReady to use server attentionLevels instead of client recompute
- Restore isMerged guard on DoneCard restore button
- Update manifest.ts with scope, orientation, maskable icon entries
- Update manifest.test.ts to expect new fields
- Update Dashboard.mobile.test.tsx for MobileSessionRow compact row structure
- Revert inverted Dashboard.doneBar.test.tsx assertion

* fix(web): add display:flex to kanban-board mobile override
This commit is contained in:
Syed Laraib Ahmed 2026-05-11 14:04:43 +05:30 committed by GitHub
parent 23ac3a23ae
commit 334611b375
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 85 additions and 22 deletions

View File

@ -5817,7 +5817,8 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
/* -- Kanban board: stack columns vertically -- */
.kanban-board {
grid-template-columns: minmax(0, 1fr);
display: flex;
flex-direction: column;
height: auto;
min-height: auto;
overflow-x: visible;

View File

@ -12,13 +12,17 @@ describe("app manifest", () => {
name: "ao | Agent Orchestrator",
short_name: "ao",
start_url: "/",
scope: "/",
display: "standalone",
orientation: "portrait-primary",
background_color: "#121110",
theme_color: "#121110",
icons: [
{ src: "/apple-icon", sizes: "180x180", type: "image/png" },
{ src: "/icon-192", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icon-192", sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "/icon-512", sizes: "512x512", type: "image/png", purpose: "any" },
{ src: "/icon-512", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
});
});

View File

@ -8,13 +8,17 @@ export default function manifest(): MetadataRoute.Manifest {
short_name: "ao",
description: "Dashboard for managing parallel AI coding agents",
start_url: "/",
scope: "/",
display: "standalone",
orientation: "portrait-primary",
background_color: "#121110",
theme_color: "#121110",
icons: [
{ src: "/apple-icon", sizes: "180x180", type: "image/png" },
{ src: "/icon-192", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icon-192", sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "/icon-512", sizes: "512x512", type: "image/png", purpose: "any" },
{ src: "/icon-512", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}

View File

@ -25,6 +25,7 @@ import { ConnectionBar } from "./ConnectionBar";
import { CopyDebugBundleButton } from "./CopyDebugBundleButton";
import { SidebarContext } from "./workspace/SidebarContext";
import { projectDashboardPath, projectSessionPath } from "@/lib/routes";
import { BottomSheet } from "./BottomSheet";
interface DashboardProps {
initialSessions: DashboardSession[];
@ -121,7 +122,7 @@ function DoneCard({
</a>
) : null}
<span className="done-card__age">{formatRelativeTimeCompact(session.lastActivityAt)}</span>
{canRestore ? (
{canRestore && !isMerged ? (
<button
type="button"
className="done-card__restore"
@ -173,9 +174,11 @@ function DashboardInner({
return sessions.filter((s) => s.projectId === projectId);
}, [sessions, projectId]);
const connectionStatus: "connected" | "reconnecting" | "disconnected" =
mux?.status === "disconnected" ? "disconnected"
: mux?.status === "connected" ? "connected"
: "reconnecting";
mux?.status === "disconnected"
? "disconnected"
: mux?.status === "connected"
? "connected"
: "reconnecting";
const recoveredFromLoadError = Boolean(dashboardLoadError) && liveSessionsResolved;
const ssrLoadError = recoveredFromLoadError ? undefined : dashboardLoadError;
// Live WS error takes precedence; fall back to SSR load error when live data hasn't resolved it.
@ -193,6 +196,11 @@ function DashboardInner({
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const isMobile = useMediaQuery(MOBILE_BREAKPOINT);
const [collapsedZones, setCollapsedZones] = useState<Set<AttentionLevel>>(
() => new Set<AttentionLevel>(["done", "working"]),
);
const [previewSession, setPreviewSession] = useState<DashboardSession | null>(null);
const [bottomSheetMode, setBottomSheetMode] = useState<"preview" | "confirm-kill">("preview");
const debugParam = searchParams.get("debug");
const showDebugBundleButton =
!isMobile &&
@ -364,6 +372,25 @@ function DashboardInner({
},
[killSession],
);
const handlePreview = useCallback((session: DashboardSession) => {
setPreviewSession(session);
setBottomSheetMode("preview");
}, []);
const handleRequestKill = useCallback(() => {
setBottomSheetMode("confirm-kill");
}, []);
const handleBottomSheetClose = useCallback(() => {
setPreviewSession(null);
setBottomSheetMode("preview");
}, []);
const handleBottomSheetConfirmKill = useCallback(() => {
if (previewSession) void killSession(previewSession.id);
setPreviewSession(null);
setBottomSheetMode("preview");
}, [previewSession, killSession]);
const handleMerge = useCallback(
async (prNumber: number) => {
@ -457,9 +484,7 @@ function DashboardInner({
<span className="font-semibold text-[var(--color-status-error)]">
Orchestrator failed to load
</span>
<span className="break-words text-[var(--color-text-secondary)]">
{visibleLoadError}
</span>
<span className="break-words text-[var(--color-text-secondary)]">{visibleLoadError}</span>
<span className="text-[var(--color-text-secondary)]">
Confirm <span className="font-mono text-[10px]">agent-orchestrator.yaml</span> exists and is
valid, then run <span className="font-mono text-[10px]">ao doctor</span> for diagnostics.
@ -478,6 +503,17 @@ function DashboardInner({
: (projectName ?? (allProjectsView ? "All projects" : "Dashboard"));
const showHeaderProjectLabel = !allProjectsView && headerProjectLabel.trim().length > 0;
const handleZoneToggle = (level: AttentionLevel) => {
setCollapsedZones((prev) => {
const next = new Set(prev);
if (next.has(level)) {
next.delete(level);
} else {
next.add(level);
}
return next;
});
};
const handleToggleSidebar = () => {
if (typeof window !== "undefined" && window.innerWidth < 768) {
setMobileMenuOpen((current) => !current);
@ -688,6 +724,10 @@ function DashboardInner({
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
compactMobile={isMobile}
collapsed={isMobile && collapsedZones.has(level)}
onToggle={isMobile ? handleZoneToggle : undefined}
onPreview={isMobile ? handlePreview : undefined}
/>
))}
</div>
@ -749,6 +789,15 @@ function DashboardInner({
</main>
</div>
</div>
<BottomSheet
session={previewSession}
mode={bottomSheetMode}
onCancel={handleBottomSheetClose}
onConfirm={handleBottomSheetConfirmKill}
onRequestKill={handleRequestKill}
onMerge={handleMerge}
isMergeReady={previewSession ? attentionLevels[previewSession.id] === "merge" : false}
/>
</>
</SidebarContext.Provider>
);

View File

@ -60,10 +60,10 @@ describe("Dashboard done bar", () => {
expect(screen.queryByText(/No active sessions/i)).not.toBeInTheDocument();
});
it("renders a restore action for merged sessions", () => {
it("does not render a restore action for merged sessions", () => {
render(<Dashboard initialSessions={[DONE_SESSION]} />);
const toggle = screen.getByText("Done / Terminated").closest("button")!;
fireEvent.click(toggle);
expect(screen.queryByRole("button", { name: /restore/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /restore/i })).toBeNull();
});
});

View File

@ -66,8 +66,12 @@ describe("Dashboard unified layout (mobile viewport)", () => {
render(<Dashboard initialSessions={sessions} />);
// Compact mobile rows cap at 5 by default; sessions 15 are immediately visible
expect(screen.getByText("Session 1")).toBeInTheDocument();
expect(screen.getByText("Session 5")).toBeInTheDocument();
// Expand to reveal session 6
fireEvent.click(screen.getByRole("button", { name: /View all/i }));
expect(screen.getByText("Session 6")).toBeInTheDocument();
});
@ -150,7 +154,8 @@ describe("Dashboard unified layout (mobile viewport)", () => {
/>,
);
expect(screen.getByText("feat/dashboard-polish")).toBeInTheDocument();
// Branch appears in the compact row's meta, combined with the PR number
expect(screen.getByText(/feat\/dashboard-polish/)).toBeInTheDocument();
});
it("shows and dismisses the rate limit banner", () => {
@ -241,17 +246,17 @@ describe("Dashboard unified layout (mobile viewport)", () => {
/>,
);
const killButtons = screen.getAllByRole("button", { name: "Terminate session" });
expect(killButtons.length).toBeGreaterThan(0);
// On mobile, kill is via the BottomSheet. Open it by tapping the compact row.
// getSessionTitle falls back to humanized branch: "feat/live" → "Live"
fireEvent.click(screen.getByRole("button", { name: /Open Live/i }));
// First click → enters confirming state; does not fire the kill request
fireEvent.click(killButtons[0]);
// BottomSheet preview mode: Terminate enters confirm-kill mode, does not fire yet
fireEvent.click(screen.getByRole("button", { name: "Terminate" }));
expect(fetchSpy).not.toHaveBeenCalledWith(expect.stringContaining("/kill"), expect.anything());
// Button now advertises the confirm affordance
const confirm = screen.getByRole("button", { name: "Confirm terminate session" });
// BottomSheet confirm-kill mode: second Terminate fires the kill
await act(async () => {
fireEvent.click(confirm);
fireEvent.click(screen.getByRole("button", { name: "Terminate" }));
});
expect(fetchSpy).toHaveBeenCalledWith(
@ -260,7 +265,7 @@ describe("Dashboard unified layout (mobile viewport)", () => {
);
});
it("shows CI check chips on cards with enriched PRs", () => {
it("shows enriched PR sessions with branch and PR number in compact row", () => {
render(
<Dashboard
initialSessions={[
@ -284,9 +289,9 @@ describe("Dashboard unified layout (mobile viewport)", () => {
/>,
);
// Passing CI checks render as chips by name
expect(screen.getByText("build")).toBeInTheDocument();
expect(screen.getByText("lint")).toBeInTheDocument();
// Compact row meta line shows branch and PR number
expect(screen.getByText(/feat\/green/)).toBeInTheDocument();
expect(screen.getByText(/PR #301/)).toBeInTheDocument();
});
it("preserves sessions across live updates", () => {