Skip to content
Merged
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
18 changes: 18 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,24 @@ such detail into a token. New layout spacing must use the scale.
Do not add motion for decoration. New animation must honor
`prefers-reduced-motion`; feedback must still work without animation.

### Loading

- Keep the real shell, Repos navigation and theme actions available while review
data loads. Reserve the shared navigation panel width and split/unified layout
with neutral skeletons for repo rows, file rows, the toolbar and diff frames.
Keep Diff disabled until a review is ready; switching to Repos replaces the file
placeholders within the same panel.
Narrow screens hide the placeholder file sidebar and follow the narrow layout.
- Skeletons use `--border-muted`, existing spacing and radius tokens, and a gentle
shared opacity pulse. Disable animation under `prefers-reduced-motion`. Keep
placeholders out of the accessibility tree and tab order; label the busy region
with a concise status at the bottom of the review surface. Do not invent file
names, counts, progress percentages or addition/deletion colors.
- Replace skeletons as data arrives, without a minimum display delay. Stored
manifest loading uses the same diff-frame skeleton without unmounting Pierre.
Background Sync preserves the existing review and usable controls. Errors and
empty results replace placeholders with their existing explanation and actions.

## Responsive design

- Use 1012px for drawer navigation/tighter gutters and 768px for compact mobile
Expand Down
47 changes: 30 additions & 17 deletions apps/web/src/DiffWorkspace.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DiffSkeleton } from "./WorkspaceLoading.tsx";
import type {
CodeViewItem,
DiffLineAnnotation,
Expand Down Expand Up @@ -627,10 +628,9 @@ export function DiffWorkspace() {
],
);

const loadingChanges = !repository && !diff.notice;
const emptyTitle = !repository
? diff.notice
? "Cannot load changes"
: "Loading changes"
? "Cannot load changes"
: filter
? "No matching files"
: piped
Expand All @@ -639,9 +639,7 @@ export function DiffWorkspace() {
? "Nothing staged"
: "No changes in this snapshot";
const emptyDescription = !repository
? diff.notice
? "Check that the server is running, then refresh."
: "Reading stored changes…"
? "Check that the server is running, then refresh."
: filter
? "Try a different filename or clear the filter."
: piped
Expand Down Expand Up @@ -795,17 +793,32 @@ export function DiffWorkspace() {
}
/>
</section>
<div id="empty" role="status" hidden={files.length > 0}>
<h2>{emptyTitle}</h2>
<p>{emptyDescription}</p>
{!!filter && (
<Button
type="button"
variant="outline"
onClick={() => setFilter("")}
>
Clear filter
</Button>
<div
id="empty"
role="status"
hidden={files.length > 0}
className={loadingChanges ? "is-loading" : undefined}
aria-busy={loadingChanges}
>
{loadingChanges ? (
<>
<DiffSkeleton />
<p className="visually-hidden">Loading changes</p>
</>
) : (
<>
<h2>{emptyTitle}</h2>
<p>{emptyDescription}</p>
{!!filter && (
<Button
type="button"
variant="outline"
onClick={() => setFilter("")}
>
Clear filter
</Button>
)}
</>
)}
</div>
</div>
Expand Down
13 changes: 11 additions & 2 deletions apps/web/src/Navigation.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import { useRef, type ReactNode } from "react";
import { FileTreeSkeleton } from "./WorkspaceLoading.tsx";
import { ActivityBar } from "./ActivityBar.tsx";
import { ProjectsNav } from "./ProjectsNav.tsx";
import { useSidebarState } from "./sidebar-context.tsx";

/** The shared navigation frame owns panel width and its resize boundary. */
export function Navigation({ children }: { children?: ReactNode }) {
export function Navigation({
children,
loading = false,
}: {
children?: ReactNode;
loading?: boolean;
}) {
const sidebar = useSidebarState();
const diffReady = Boolean(children);
const expanded = sidebar.expanded && (sidebar.view !== "diff" || diffReady);
const expanded =
sidebar.expanded && (sidebar.view !== "diff" || diffReady || loading);
return (
<div className="navigation-island">
<ActivityBar diffReady={diffReady} />
<div className="navigation-panel" hidden={!expanded}>
<ProjectsNav />
{loading && <FileTreeSkeleton />}
{children}
</div>
<NavigationResizer hidden={!expanded || sidebar.mobile} />
Expand Down
30 changes: 14 additions & 16 deletions apps/web/src/ProjectsNav.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ProjectsSkeleton } from "./WorkspaceLoading.tsx";
import type { ApiContext } from "@diffx/api";
import {
ChevronRightIcon,
Expand Down Expand Up @@ -294,24 +295,21 @@ function ProjectsEmpty({
</Button>
</div>
);
if (collecting || !loaded)
return (
<div className="projects-loading" role="status" aria-busy="true">
<ProjectsSkeleton />
<p className="visually-hidden">
{collecting ? "Collecting repos…" : "Loading repos…"}
</p>
</div>
);
return (
<div
className="projects-empty"
role="status"
aria-busy={collecting || !loaded}
>
<p>
{collecting
? "Collecting repos…"
: loaded
? "No current changes"
: "Loading repos…"}
<div className="projects-empty" role="status" aria-busy={false}>
<p>No current changes</p>
<p className="projects-empty-hint">
Agent hooks and diffx sync publish changed checkouts here.
</p>
{loaded && !collecting && (
<p className="projects-empty-hint">
Agent hooks and diffx sync publish changed checkouts here.
</p>
)}
</div>
);
}
Expand Down
124 changes: 124 additions & 0 deletions apps/web/src/WorkspaceLoading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Skeleton } from "@/components/ui/skeleton";
import { useAppearance } from "./appearance-context.tsx";
import { useSidebarState } from "./sidebar-context.tsx";

const lines = [
"medium",
"long",
"short",
"long",
"medium",
"short",
"medium",
"long",
"short",
"medium",
"long",
"short",
];

/** Decorative placeholders never claim file identities, counts or change status. */
export function DiffSkeleton() {
const { layout } = useAppearance();
return (
<div className="diff-skeleton" data-layout={layout} aria-hidden="true">
{["primary", "secondary"].map((file) => (
<div className="loading-file" key={file}>
<div className="loading-file-header">
<Skeleton className="skeleton-icon" />
<Skeleton className="skeleton-title" />
<Skeleton className="skeleton-action" />
</div>
<div className="loading-code">
{["before", "after"].map((side) => (
<div className="loading-code-pane" key={side}>
{lines
.slice(0, file === "primary" ? 12 : 8)
.map((length, index) => (
<div className="loading-code-line" key={index}>
<Skeleton className="skeleton-line-number" />
<Skeleton
className={`skeleton-code skeleton-code-${length}`}
/>
</div>
))}
</div>
))}
</div>
</div>
))}
</div>
);
}

export function FileTreeSkeleton() {
const sidebar = useSidebarState();
return (
<div
className="sidebar loading-sidebar"
hidden={sidebar.view !== "diff"}
aria-hidden="true"
>
<div className="loading-sidebar-tabs">
<Skeleton className="skeleton-title" />
<Skeleton className="skeleton-title" />
</div>
<div className="loading-tree">
<Skeleton className="skeleton-search" />
{["folder", "file", "file", "folder", "file", "file", "file"].map(
(kind, index) => (
<div
className={`loading-tree-row loading-tree-${kind}`}
key={index}
>
<Skeleton className="skeleton-icon" />
<Skeleton className="skeleton-name" />
</div>
),
)}
</div>
<div className="loading-sidebar-summary">
<Skeleton className="skeleton-title" />
<Skeleton className="skeleton-name" />
<Skeleton className="skeleton-name" />
</div>
</div>
);
}

export function ProjectsSkeleton() {
return (
<div className="projects-skeleton" aria-hidden="true">
{["first", "second", "third"].map((group) => (
<div className="loading-project" key={group}>
<div className="loading-project-name">
<Skeleton className="skeleton-icon" />
<Skeleton className="skeleton-name" />
</div>
<div className="loading-project-stream">
<Skeleton className="skeleton-name" />
<Skeleton className="skeleton-title" />
</div>
</div>
))}
</div>
);
}

export function WorkspaceLoading({ collecting }: { collecting: boolean }) {
return (
<>
<div className="loading-toolbar" aria-hidden="true">
<Skeleton className="skeleton-action" />
<Skeleton className="skeleton-title" />
<Skeleton className="skeleton-title" />
<Skeleton className="skeleton-action" />
</div>
<DiffSkeleton />
<div className="loading-status">
<h1>{collecting ? "Collecting latest changes" : "Loading review"}</h1>
<p>{collecting ? "Reading checkouts…" : "Reading stored changes…"}</p>
</div>
</>
);
}
13 changes: 13 additions & 0 deletions apps/web/src/components/ui/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";

export function Skeleton({ className, ...props }: ComponentProps<"span">) {
return (
<span
data-slot="skeleton"
aria-hidden="true"
className={cn("skeleton", className)}
{...props}
/>
);
}
Loading
Loading