Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ agent-browser screenshot /tmp/check.png # then Read the image to inspect it

Add `--full` for the whole page. Check both light and dark mode, and a narrow viewport for anything layout-sensitive. If `agent-browser` isn't installed: `npm install -g agent-browser && agent-browser install`.

### Debug trace (UX jank)

For jank (grid jumping, dialogs remounting, layout shifts), use the admin debug trace instead of guessing: open any page with `?debug=1` (admins; anyone in local dev) or toggle "Debug trace" in the admin account menu. A panel records a timeline of URL writes (with call stacks), React Query fetches/invalidations, masonry reflows (which frames moved, flashed blue), layout shifts (flashed red), slow frames, and dialog/grid mount/unmount/prop changes; "Copy" exports it as JSON. Code lives in `src/lib/debug/` + `src/components/debug/`. Instrument new suspect components with `useDebugLifecycle` / `debugTrace` (no-ops when tracing is off). When driving the app with `agent-browser`, read the trace via `window.__abodeDebugTrace.export().events` (and `.clear()` before reproducing) instead of the clipboard.

For automated tests, run from the `./app` directory:

```bash
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetTrace } from "@/lib/debug/test-utils";
import { getTraceEvents } from "@/lib/debug/trace";
import type { Item } from "@/lib/types/item";
import { CentralItemDialog } from "./central-item-dialog";

Expand Down Expand Up @@ -273,3 +275,29 @@ describe("CentralItemDialog", () => {
expect(screen.queryByTestId("frame")).not.toBeInTheDocument();
});
});

describe("CentralItemDialog debug trace", () => {
beforeEach(() => resetTrace());
afterEach(() => resetTrace({ enabled: false }));

it("records the resolution source flipping when the open item leaves the list", () => {
dialogState = { openItemId: "a" };
const { rerender } = renderDialog();
// e.g. a refetch/search swap drops the open item from the loaded list
rerender(
<CentralItemDialog
onItemRenamed={() => {}}
canEdit
items={items.filter((item) => item.id !== "a")}
/>,
);
const change = getTraceEvents().find(
(event) => event.event === "CentralItemDialog:change",
);
expect(change?.data).toMatchObject({
source: "list → none",
hasContent: "true → false",
needsFetch: "false → true",
});
});
});
30 changes: 30 additions & 0 deletions app/src/app/(app)/dashboard/_components/central-item-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { AnimatePresence } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { debugTrace } from "@/lib/debug/trace";
import { useDebugLifecycle } from "@/lib/debug/use-debug-lifecycle";
import { getProxyImageUrl } from "@/lib/image-url";
import { getItemDisplayName } from "@/lib/items/item-display-name";
import { useItem } from "@/lib/items/use-item";
Expand Down Expand Up @@ -108,12 +110,35 @@ export function CentralItemDialog({
const showSeed = !resolved && !!seed && seed.id === openItemId;
const hasContent = resolved !== null || showSeed;

// Debug trace: which source resolved the open item, so a dialog that exits
// and re-enters shows the input that flipped (e.g. list → none mid-refetch)
useDebugLifecycle({
name: "CentralItemDialog",
channel: "dialog",
watch: {
openItemId,
source: inList
? "list"
: fromInitial
? "initial"
: resolved
? "fetched"
: showSeed
? "seed"
: "none",
hasContent,
needsFetch,
fetchError: isError,
},
});

return (
<AnimatePresence>
{open && hasContent && (
<ItemDialogFrame
open
onOpenChange={(next) => {
debugTrace("dialog", "frame:onOpenChange", { next });
if (!next) closeItem?.();
}}
>
Expand Down Expand Up @@ -155,6 +180,11 @@ function CentralItemBody({
onItemRenamed: (itemId: string, title: string) => void;
onItemDeleted?: (itemId: string) => void;
}) {
useDebugLifecycle({
name: "CentralItemBody",
channel: "dialog",
watch: { itemId: item.id, animateEntrance },
});
const displayName = getItemDisplayName(item);
// Local mirror so a rename shows in the dialog immediately; onItemRenamed
// keeps the grid card in sync, and displayName re-derives on the next render.
Expand Down
101 changes: 60 additions & 41 deletions app/src/app/(app)/dashboard/item-card.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import type { ProcessingStatus } from "@prisma/client";
import type { ItemKind, ProcessingStatus } from "@prisma/client";
import {
AlertCircle,
BookOpen,
Expand Down Expand Up @@ -30,7 +30,6 @@ import {
useMotionValue,
useTransform,
} from "motion/react";
import dynamic from "next/dynamic";
import Link from "next/link";
import posthog from "posthog-js";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
Expand Down Expand Up @@ -81,6 +80,8 @@ import {
import { branchTitlePrefix } from "@/lib/branch-title";
import { copyToClipboard } from "@/lib/copy";
import { getCurrencySymbol } from "@/lib/currency";
import { debugTrace } from "@/lib/debug/trace";
import { useDebugLifecycle } from "@/lib/debug/use-debug-lifecycle";
import { gridCardStyle } from "@/lib/grid-styles";
import { decodeHtmlEntities } from "@/lib/html-metadata";
import { getProxyImageUrl } from "@/lib/image-url";
Expand All @@ -101,6 +102,7 @@ import {
shouldCompleteSeeAiAnalysis,
} from "@/lib/milestones/conditions";
import { getPlatformName } from "@/lib/platforms";
import { preloadableView } from "@/lib/preloadable-view";
import { useSearch } from "@/lib/search";
import {
type ChipSearch,
Expand Down Expand Up @@ -139,89 +141,100 @@ export const DETAIL_IMAGE_CLASSNAME =

// Detail views render only inside the click-to-expand modal, never in the
// collapsed grid card. Load them lazily so they stay out of the dashboard
// grid's initial JS. ssr:false is safe because the modal is client-only.
// grid's initial JS; client-only is safe because the modal is client-only.
const detailViewLoading = () => (
<div className="flex h-full w-full items-center justify-center">
<IsLoading label="Loading" />
</div>
);

const ArticleDetailView = dynamic(
const ArticleDetailView = preloadableView(
() =>
import("@/components/article/article-detail-view").then(
(m) => m.ArticleDetailView,
),
{ ssr: false, loading: detailViewLoading },
{ loading: detailViewLoading },
);

const HighlightsPanel = dynamic(
const HighlightsPanel = preloadableView(
() =>
import("@/components/article/highlights-panel").then(
(m) => m.HighlightsPanel,
),
{ ssr: false, loading: detailViewLoading },
{ loading: detailViewLoading },
);

const BookDetailView = dynamic(
() =>
import("@/components/book/book-detail-view").then((m) => m.BookDetailView),
{ ssr: false, loading: detailViewLoading },
const ArticleReadingControls = preloadableView(() =>
import("@/components/article/article-reading-controls").then(
(m) => m.ArticleReadingControls,
),
);

const ArticleReadingControls = dynamic(
const BookDetailView = preloadableView(
() =>
import("@/components/article/article-reading-controls").then(
(m) => m.ArticleReadingControls,
),
{ ssr: false },
import("@/components/book/book-detail-view").then((m) => m.BookDetailView),
{ loading: detailViewLoading },
);

const BookReadingControls = dynamic(
() =>
import("@/components/book/book-reading-controls").then(
(m) => m.BookReadingControls,
),
{ ssr: false },
const BookReadingControls = preloadableView(() =>
import("@/components/book/book-reading-controls").then(
(m) => m.BookReadingControls,
),
);

const NoteDetailView = dynamic(
const NoteDetailView = preloadableView(
() =>
import("@/components/note/note-detail-view").then((m) => m.NoteDetailView),
{ ssr: false, loading: detailViewLoading },
{ loading: detailViewLoading },
);

const ProductDetailView = dynamic(
const ProductDetailView = preloadableView(
() =>
import("@/components/product/product-detail-view").then(
(m) => m.ProductDetailView,
),
{ ssr: false, loading: detailViewLoading },
{ loading: detailViewLoading },
);

const InstagramDetailView = dynamic(
const InstagramDetailView = preloadableView(
() =>
import("@/components/instagram/instagram-detail-view").then(
(m) => m.InstagramDetailView,
),
{ ssr: false, loading: detailViewLoading },
{ loading: detailViewLoading },
);

const TwitterDetailView = dynamic(
const TwitterDetailView = preloadableView(
() =>
import("@/components/twitter/twitter-detail-view").then(
(m) => m.TwitterDetailView,
),
{ ssr: false, loading: detailViewLoading },
{ loading: detailViewLoading },
);

const VideoDetailView = dynamic(
const VideoDetailView = preloadableView(
() =>
import("@/components/video/video-detail-view").then(
(m) => m.VideoDetailView,
),
{ ssr: false, loading: detailViewLoading },
{ loading: detailViewLoading },
);

const DETAIL_VIEWS: Partial<
Record<ItemKind, ReadonlyArray<{ preload: () => Promise<unknown> }>>
> = {
article: [ArticleDetailView, HighlightsPanel, ArticleReadingControls],
book: [BookDetailView, BookReadingControls],
note: [NoteDetailView],
product: [ProductDetailView],
instagram: [InstagramDetailView],
twitter: [TwitterDetailView],
video: [VideoDetailView],
};

/**
* Start loading a kind's lazy detail view on intent (pointer over a card), so
* it's usually loaded by the click and the dialog opens straight into the real
* view instead of flashing a "Loading" placeholder first.
*/
export function preloadDetailView(kind: ItemKind | null): void {
for (const view of (kind && DETAIL_VIEWS[kind]) || []) {
// A failed preload just means the dialog loads it on open as before
view.preload().catch(() => {});
}
}

type ItemCardProps = {
item: Item;
name: string;
Expand Down Expand Up @@ -371,6 +384,7 @@ export function ItemCard({
};

const handleOpenDetail = () => {
debugTrace("dialog", "card:click", { itemId: item.id });
setIsAnimating(true);
setShowDetailDialog(true);

Expand Down Expand Up @@ -1261,6 +1275,11 @@ export function ItemDialogFrame({
defaultValue: false,
initializeWithValue: false,
});
useDebugLifecycle({
name: "ItemDialogFrame",
channel: "dialog",
watch: { open, isTouchDevice },
});
const dragY = useMotionValue(0);
const dragOpacity = useTransform(dragY, [0, 200], [1, 0.5]);
const closingOpacity = useMotionValue(1);
Expand Down
6 changes: 6 additions & 0 deletions app/src/app/(app)/dashboard/item-dialog-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
useRef,
useState,
} from "react";
import { debugTrace } from "@/lib/debug/trace";
import {
readItemParam,
withOpenItem,
Expand Down Expand Up @@ -106,11 +107,16 @@ export function ItemDialogProvider({ children }: { children: ReactNode }) {
seeds.delete(seeds.keys().next().value ?? "");
}
}
debugTrace("dialog", "openItem", { itemId, seeded: seed !== undefined });
const query = withOpenItem(window.location.search, itemId);
window.history.pushState(null, "", `?${query}`);
}, []);

const closeItem = useCallback(() => {
debugTrace("dialog", "closeItem", {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
itemId: readItemParam(window.location.search),
via: openedViaPushRef.current ? "history.back" : "strip-param",
});
if (openedViaPushRef.current) {
openedViaPushRef.current = false;
window.history.back();
Expand Down
10 changes: 9 additions & 1 deletion app/src/app/(app)/dashboard/item-frame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ type ItemFrameProps = {
frameTransition?: string;
/** True when this is a freshly-added item that should grow into the grid. */
animateIn: boolean;
/** Item id, exposed as `data-grid-item` for the debug grid observer */
itemId?: string;
children: ReactNode;
};

Expand All @@ -35,6 +37,7 @@ export function ItemFrame({
columnWidth,
frameTransition,
animateIn,
itemId,
children,
}: ItemFrameProps) {
const targetPx =
Expand Down Expand Up @@ -77,7 +80,12 @@ export function ItemFrame({
}

return (
<Frame width={width} height={height} style={frameStyle}>
<Frame
width={width}
height={height}
style={frameStyle}
data-grid-item={itemId}
>
<div
className="h-full"
// Pin the content to its full height while the frame clips it, so it
Expand Down
Loading
Loading