diff --git a/DESIGN.md b/DESIGN.md
index 00c96746..e53fceca 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -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
diff --git a/apps/web/src/DiffWorkspace.tsx b/apps/web/src/DiffWorkspace.tsx
index 44fb1d29..fa9b7df8 100644
--- a/apps/web/src/DiffWorkspace.tsx
+++ b/apps/web/src/DiffWorkspace.tsx
@@ -1,3 +1,4 @@
+import { DiffSkeleton } from "./WorkspaceLoading.tsx";
import type {
CodeViewItem,
DiffLineAnnotation,
@@ -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
@@ -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
@@ -795,17 +793,32 @@ export function DiffWorkspace() {
}
/>
-
-
- {collecting
- ? "Collecting repos…"
- : loaded
- ? "No current changes"
- : "Loading repos…"}
+
+
No current changes
+
+ Agent hooks and diffx sync publish changed checkouts here.
- {loaded && !collecting && (
-
- Agent hooks and diffx sync publish changed checkouts here.
-
- )}
);
}
diff --git a/apps/web/src/WorkspaceLoading.tsx b/apps/web/src/WorkspaceLoading.tsx
new file mode 100644
index 00000000..72909e8f
--- /dev/null
+++ b/apps/web/src/WorkspaceLoading.tsx
@@ -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 (
+
+ {["primary", "secondary"].map((file) => (
+
+
+
+
+
+
+
+ {["before", "after"].map((side) => (
+
+ {lines
+ .slice(0, file === "primary" ? 12 : 8)
+ .map((length, index) => (
+
+
+
+
+ ))}
+
+ ))}
+
+
+ ))}
+
+ );
+}
+
+export function FileTreeSkeleton() {
+ const sidebar = useSidebarState();
+ return (
+
+
+
+
+
+
+
+ {["folder", "file", "file", "folder", "file", "file", "file"].map(
+ (kind, index) => (
+
+
+
+
+ ),
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+export function ProjectsSkeleton() {
+ return (
+
+ {["first", "second", "third"].map((group) => (
+
+ ))}
+
+ );
+}
+
+export function WorkspaceLoading({ collecting }: { collecting: boolean }) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
{collecting ? "Collecting latest changes" : "Loading review"}
+
{collecting ? "Reading checkouts…" : "Reading stored changes…"}
+
+ >
+ );
+}
diff --git a/apps/web/src/components/ui/skeleton.tsx b/apps/web/src/components/ui/skeleton.tsx
new file mode 100644
index 00000000..c9c593bf
--- /dev/null
+++ b/apps/web/src/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import type { ComponentProps } from "react";
+import { cn } from "@/lib/utils";
+
+export function Skeleton({ className, ...props }: ComponentProps<"span">) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/loading.css b/apps/web/src/loading.css
new file mode 100644
index 00000000..6bc20f68
--- /dev/null
+++ b/apps/web/src/loading.css
@@ -0,0 +1,238 @@
+/* Loading reserves the same navigation and review surfaces as the workspace. */
+.skeleton {
+ display: block;
+ flex-shrink: 0;
+ height: var(--space-2);
+ border-radius: var(--radius-xs);
+ background: var(--border-muted);
+}
+.skeleton-icon {
+ width: var(--icon-sm);
+ height: var(--icon-sm);
+}
+.skeleton-title {
+ width: calc(2 * var(--space-8));
+}
+.skeleton-name {
+ width: 64%;
+}
+.skeleton-action {
+ width: calc(2 * var(--space-8));
+ height: var(--control-compact);
+ border-radius: var(--radius-control);
+}
+.skeleton-search {
+ width: 100%;
+ height: var(--control-small);
+ margin-bottom: var(--space-3);
+ border-radius: var(--radius-control);
+}
+.loading-identity {
+ width: calc(3 * var(--space-12));
+ margin-left: var(--space-3);
+}
+.session-status.is-loading {
+ display: flex;
+ place-content: normal;
+ padding: 0;
+ text-align: start;
+}
+.loading-toolbar {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ height: calc(var(--control-small) + 2 * var(--space-1));
+ gap: var(--space-4);
+ padding-inline: var(--diff-gutter);
+ border-bottom: 1px solid var(--border);
+ background: var(--panel);
+}
+.loading-toolbar > :last-child {
+ margin-left: auto;
+}
+.diff-skeleton {
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ padding: var(--diff-gutter);
+ background: var(--diff-canvas);
+}
+.loading-file {
+ margin-bottom: var(--diff-gutter);
+ overflow: clip;
+ border-radius: var(--radius-md);
+ outline: 1px solid var(--border-muted);
+ background: var(--bg);
+}
+.loading-file-header {
+ height: calc(2 * var(--space-4) + var(--space-1));
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding-inline: var(--space-4);
+ background: var(--panel);
+ border-bottom: 1px solid var(--border-muted);
+}
+.loading-file-header .skeleton-title {
+ width: 30%;
+}
+.loading-file-header .skeleton-action {
+ height: var(--space-3);
+ margin-left: auto;
+}
+.loading-code {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+}
+.diff-skeleton[data-layout="split"] .loading-code {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+.diff-skeleton[data-layout="unified"] .loading-code-pane + .loading-code-pane {
+ display: none;
+}
+.loading-code-pane {
+ padding-block: var(--space-4);
+}
+.loading-code-pane + .loading-code-pane {
+ border-inline-start: 1px solid var(--border-muted);
+}
+.loading-code-line {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+ height: calc(var(--text-code) * var(--leading-code));
+ padding-inline: var(--space-4);
+}
+.skeleton-line-number {
+ width: var(--space-2);
+ flex-shrink: 0;
+}
+.skeleton-code {
+ height: var(--space-1-5);
+}
+.skeleton-code-long {
+ width: 68%;
+}
+.skeleton-code-medium {
+ width: 48%;
+}
+.skeleton-code-short {
+ width: 28%;
+}
+.loading-code-line:nth-child(3n + 2) .skeleton-code {
+ margin-inline-start: var(--space-3);
+}
+.loading-code-line:nth-child(3n) .skeleton-code {
+ margin-inline-start: var(--space-6);
+}
+.loading-status {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-1) var(--space-3);
+ min-height: var(--statusbar-height);
+ padding: var(--space-2) var(--diff-gutter);
+ border-top: 1px solid var(--border-muted);
+ background: var(--panel);
+}
+.loading-status h1,
+.loading-status p {
+ margin: 0;
+ font-size: var(--text-xs);
+ line-height: var(--leading-ui);
+ font-weight: var(--weight-normal);
+ letter-spacing: inherit;
+}
+.loading-status h1 {
+ color: var(--text-secondary);
+}
+.loading-sidebar {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+.loading-sidebar-tabs {
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+ height: calc(var(--control-small) + 2 * var(--space-1));
+ border-bottom: 1px solid var(--border-muted);
+}
+.loading-tree {
+ flex: 1;
+ padding: var(--space-2) var(--space-4);
+}
+.loading-tree-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ height: var(--control-small);
+}
+.loading-tree-file {
+ padding-inline-start: var(--space-6);
+}
+.loading-tree-row:nth-child(3n) .skeleton-name {
+ width: 42%;
+}
+.loading-sidebar-summary {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ padding: var(--space-4);
+ border-top: 1px solid var(--border-muted);
+}
+.projects-skeleton {
+ padding: var(--space-4);
+}
+.loading-project + .loading-project {
+ margin-top: var(--space-6);
+}
+.loading-project-name {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ height: var(--control-small);
+}
+.loading-project-stream {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ padding: var(--space-2) 0 var(--space-2) var(--space-6);
+}
+.loading-project:nth-child(2) .loading-project-name .skeleton-name {
+ width: 44%;
+}
+#empty.is-loading {
+ display: flex;
+ align-items: stretch;
+ justify-content: start;
+ padding: 0;
+}
+@media (prefers-reduced-motion: no-preference) {
+ .skeleton {
+ animation: skeleton-pulse 1800ms ease-in-out infinite;
+ }
+}
+@keyframes skeleton-pulse {
+ 50% {
+ opacity: 0.45;
+ }
+}
+@media (max-width: 1011px) {
+ .loading-sidebar {
+ display: none;
+ }
+}
+@media (max-width: 767px) {
+ .loading-toolbar {
+ height: var(--toolbar-height);
+ gap: var(--space-3);
+ }
+ .loading-toolbar > :nth-child(3),
+ .loading-identity {
+ display: none;
+ }
+ .loading-status p {
+ display: none;
+ }
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index ec745c48..52e94ce2 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -11,6 +11,7 @@ import "@fontsource-variable/jetbrains-mono/wght.css";
import "./typography.css";
import "./tokens.css";
import "./style.css";
+import "./loading.css";
import "./review.css";
import "./account.css";
import {
diff --git a/apps/web/src/session-context.tsx b/apps/web/src/session-context.tsx
index 9f0aa403..ea099ca4 100644
--- a/apps/web/src/session-context.tsx
+++ b/apps/web/src/session-context.tsx
@@ -16,6 +16,8 @@ import {
} from "react";
import { AccountButton } from "./account.tsx";
import { ThemeButton } from "./ThemeButton.tsx";
+import { Skeleton } from "@/components/ui/skeleton";
+import { WorkspaceLoading } from "./WorkspaceLoading.tsx";
import { useIdentity, navigate } from "./identity-context.tsx";
import { Button } from "@/components/ui/button";
import { ContextIdentity } from "./ContextIdentity.tsx";
@@ -429,6 +431,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
heads.loaded && !selectedId && route.kind === "root" && !collectOnLoad;
const statusError =
state.status === "error" ? state.detail : heads.error || collectError;
+ const loading = !statusError && !empty && !missing;
useEffect(() => {
if (!restorePickerFocus.current || (!empty && state.status === "loading"))
return;
@@ -456,6 +459,9 @@ export function SessionProvider({ children }: { children: ReactNode }) {
{compact &&
}
+ {loading && !current && (
+
+ )}
{compact &&
}
@@ -463,67 +469,69 @@ export function SessionProvider({ children }: { children: ReactNode }) {
-
+
-
- {statusError
- ? collectError
- ? "Cannot sync changes"
- : "Cannot load diffx"
- : missing
- ? "No current snapshot"
- : empty
- ? heads.contexts.length
- ? "No changes to review"
- : "No review contexts yet"
- : collectOnLoad
- ? "Collecting latest changes"
- : "Loading diffx"}
-
-
- {statusError ||
- (missing
- ? "This stream's snapshots expired or were deleted. Pick current work from Repos."
- : empty
- ? remote
- ? "Create a collector token in settings, then run diffx sync to publish your changes."
- : "Run diffx to review a checkout, or diffx sync to publish remotely."
- : collectOnLoad
- ? "Reading checkouts for new changes…"
- : "Reading review context…")}
-
- {empty && remote && (
- navigate("/settings")}
- >
- Create a collector token
-
- )}
- {statusError && (
- {
- setState({ status: "loading" });
- if (collectError) {
- collected.current = null;
- setCollectError("");
- setMissing(false);
- setUncollected(null);
- }
- reloadHeads();
- setAttempt((current) => current + 1);
- }}
- >
- Retry
-
+ {loading ? (
+
+ ) : (
+ <>
+
+ {statusError
+ ? collectError
+ ? "Cannot sync changes"
+ : "Cannot load diffx"
+ : missing
+ ? "No current snapshot"
+ : empty
+ ? heads.contexts.length
+ ? "No changes to review"
+ : "No review contexts yet"
+ : "Loading diffx"}
+
+
+ {statusError ||
+ (missing
+ ? "This stream's snapshots expired or were deleted. Pick current work from Repos."
+ : empty
+ ? remote
+ ? "Create a collector token in settings, then run diffx sync to publish your changes."
+ : "Run diffx to review a checkout, or diffx sync to publish remotely."
+ : "Reading review context…")}
+
+ {empty && remote && (
+ navigate("/settings")}
+ >
+ Create a collector token
+
+ )}
+ {statusError && (
+ {
+ setState({ status: "loading" });
+ if (collectError) {
+ collected.current = null;
+ setCollectError("");
+ setMissing(false);
+ setUncollected(null);
+ }
+ reloadHeads();
+ setAttempt((current) => current + 1);
+ }}
+ >
+ Retry
+
+ )}
+ >
)}
diff --git a/apps/web/test/browser/local-workspace.spec.ts b/apps/web/test/browser/local-workspace.spec.ts
index 4ec2384b..e3c8b42a 100644
--- a/apps/web/test/browser/local-workspace.spec.ts
+++ b/apps/web/test/browser/local-workspace.spec.ts
@@ -1761,12 +1761,32 @@ test("local first launch waits for collection with an empty catalog", async ({
const status = page.locator("main.session-status");
await expect(status).toContainText("Collecting latest changes");
await expect(status).toHaveAttribute("aria-busy", "true");
+ await expect(status.locator(".diff-skeleton")).toBeVisible();
+ const loadingBounds = await status.boundingBox();
+ if (!loadingBounds) throw new Error("Missing loading workspace");
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ expect(
+ await status
+ .locator('[data-slot="skeleton"]')
+ .first()
+ .evaluate((element) => getComputedStyle(element).animationName),
+ ).toBe("none");
+ await page.setViewportSize({ width: 390, height: 844 });
+ await expect(page.locator(".loading-sidebar")).toBeHidden();
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth,
+ ),
+ ).toBe(true);
+ await page.setViewportSize({ width: 1280, height: 720 });
const diffView = page.getByRole("button", { name: "Diff", exact: true });
await expect(diffView).toBeDisabled();
await expect(diffView).not.toHaveAttribute("aria-controls");
await page.getByRole("button", { name: "Repos", exact: true }).click();
const projects = page.getByRole("navigation", { name: "Repos" });
await expect(projects).toContainText("Collecting repos…");
+ await expect(projects.locator(".projects-skeleton")).toBeVisible();
+ await expect(page.locator(".loading-sidebar")).toBeHidden();
await expect(
page.getByRole("separator", { name: "Resize navigation panel" }),
).toBeVisible();
@@ -1783,6 +1803,29 @@ test("local first launch waits for collection with an empty catalog", async ({
await ready(page);
await expect(page).toHaveURL(/\/streams\/local-stream$/);
expect(collections).toBe(1);
+ await expect(page.locator(".session-status")).toHaveCount(0);
+ const reviewBounds = await page.locator(".workspace").boundingBox();
+ if (!reviewBounds) throw new Error("Missing loaded workspace");
+ expect(reviewBounds.x).toBeCloseTo(loadingBounds.x, 0);
+ expect(reviewBounds.width).toBeCloseTo(loadingBounds.width, 0);
+});
+
+test("stored manifest loading keeps the diff surface mounted until content arrives", async ({
+ page,
+}) => {
+ const state = await localWorkspace(page);
+ state.holdScopes.add("all");
+ await page.goto("/contexts/local-review");
+ const loading = page.locator('#empty[aria-busy="true"]');
+ await expect(loading.locator(".diff-skeleton")).toBeVisible();
+ await expect(page.locator("#viewer")).toHaveCount(1);
+ await expect.poll(() => state.pendingScopes.length).toBe(1);
+ const held = state.pendingScopes.shift();
+ if (!held) throw new Error("Missing held manifest request");
+ await held.route.fulfill({ json: held.repository });
+ await ready(page);
+ await expect(page.locator("#empty")).toBeHidden();
+ await expect(page.locator("#viewer")).toHaveCount(1);
});
for (const path of ["/", "/streams/local-stream"]) {
@@ -1821,6 +1864,7 @@ for (const path of ["/", "/streams/local-stream"]) {
await expect(status).toContainText("Cannot sync changes");
await expect(status).toContainText("git failed");
await expect(status).toHaveAttribute("aria-busy", "false");
+ await expect(status.locator(".diff-skeleton")).toHaveCount(0);
await status.getByRole("button", { name: "Retry", exact: true }).click();
await expect(status).toContainText("Collecting latest changes");
await expect(status).toHaveAttribute("aria-busy", "true");
diff --git a/docs/development.md b/docs/development.md
index 928c90c4..fc23e9d8 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -270,8 +270,11 @@ review's identity. The selected context scopes every request. The session owner
local checkout sync when bootstrap reports `collect`; loading `/` or a live
`/streams/{streamId}` URL, the header's “Sync all checkouts” icon and Alt+R call
`POST /api/v2/collections`. Load-time sync opens the stream's synced head, or the
-most recently active changed one for `/`, and shows “Collecting latest changes” until it
-resolves. The listener opens before background startup collection. Load-time
+most recently active changed one for `/`, and shows a workspace skeleton with
+“Collecting latest changes” in its status bar until it resolves. Skeletons retain
+shell geometry and display preferences, hide decorative placeholders from assistive
+technology, and respect reduced motion. Stored manifest loading uses the same diff
+skeleton while keeping Pierre mounted. The listener opens before background startup collection. Load-time
requests send `initial=true` to join startup once without collecting twice;
explicit Sync requests always collect fresh snapshots. While discovery is pending,
the process scope matches no stored contexts. Startup failures keep the server
diff --git a/test/conformance/foreground_test.go b/test/conformance/foreground_test.go
index 9454650b..70b0fae5 100644
--- a/test/conformance/foreground_test.go
+++ b/test/conformance/foreground_test.go
@@ -392,7 +392,9 @@ func TestForegroundSnapshotReplacementAndPersistentHistory(t *testing.T) {
if !found[entry.ID] || !found[refreshed.ID] || !found[linkedEntry.ID] {
t.Fatalf("reopened checkout lost history: %+v", history)
}
- ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
+ // The server may use its five-second shutdown grace before forcing a
+ // connection closed; leave time for descriptor and ownership cleanup too.
+ ctx, cancel := context.WithTimeout(t.Context(), 8*time.Second)
defer cancel()
if err := client.Stop(ctx); err != nil {
t.Fatal(err)
diff --git a/test/conformance/hooks_test.go b/test/conformance/hooks_test.go
index fa68163a..a8a32db9 100644
--- a/test/conformance/hooks_test.go
+++ b/test/conformance/hooks_test.go
@@ -274,7 +274,11 @@ func TestHookReturnsBeforeRemoteIngestionAndSkipsUnchangedUploads(t *testing.T)
case <-time.After(5 * time.Second):
t.Fatal("new session did not submit unchanged content")
}
-
+ // Receiving the request precedes the detached worker's durable acknowledgement
+ // and status writes. Wait for delivery before removing its runtime directory.
+ waitCollectorStatus(t, harness, func(activity hooks.Activity) bool {
+ return activity.Agent == "opencode" && activity.RunID == "second-session" && activity.Stage == "ingestion" && activity.Status == "complete"
+ })
}
func isolatedCollectorHarness(t *testing.T) serviceHarness {