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:
parent
23ac3a23ae
commit
334611b375
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -66,8 +66,12 @@ describe("Dashboard unified layout (mobile viewport)", () => {
|
|||
|
||||
render(<Dashboard initialSessions={sessions} />);
|
||||
|
||||
// Compact mobile rows cap at 5 by default; sessions 1–5 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", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue