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
22 changes: 18 additions & 4 deletions apps/web/src/ProjectsNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ function readCollapsedGroups() {
export function ProjectsNav() {
const projects = useProjectsNavState();
const sidebar = useSidebarState();
const { heads, current, openStream, openPicker, seen } = useCatalog();
const { heads, current, openStream, openPicker, seen, collecting } =
useCatalog();
const [closed, setClosed] = useState(readCollapsedGroups);
const [query, setQuery] = useState("");
const now = useNow();
Expand Down Expand Up @@ -204,6 +205,7 @@ export function ProjectsNav() {
) : (
<ProjectsEmpty
loaded={heads.loaded}
collecting={collecting}
error={heads.error}
onRetry={heads.retry}
/>
Expand Down Expand Up @@ -302,10 +304,12 @@ function NewMark() {

function ProjectsEmpty({
loaded,
collecting,
error,
onRetry,
}: {
loaded: boolean;
collecting: boolean;
error: string;
onRetry: () => void;
}) {
Expand All @@ -319,9 +323,19 @@ function ProjectsEmpty({
</div>
);
return (
<div className="projects-empty" role="status">
<p>{loaded ? "No current changes" : "Loading projects…"}</p>
{loaded && (
<div
className="projects-empty"
role="status"
aria-busy={collecting || !loaded}
>
<p>
{collecting
? "Collecting projects…"
: loaded
? "No current changes"
: "Loading projects…"}
</p>
{loaded && !collecting && (
<p className="projects-empty-hint">
Agent hooks and diffx sync publish changed checkouts here.
</p>
Expand Down
100 changes: 59 additions & 41 deletions apps/web/src/session-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,24 +191,30 @@ export function SessionProvider({ children }: { children: ReactNode }) {
);
const { remove: removeHead, retry: reloadHeads } = heads;
// Syncs the local checkouts; null when it fails or one is running.
const collectLatest = useCallback(async () => {
if (collectingRef.current) return null;
collectingRef.current = true;
setCollecting(true);
setCollectError("");
try {
const { data, error } = await api.POST("/api/v2/collections");
if (!data) throw new Error(errorDetail(error, "Unable to sync changes"));
reloadHeads();
return data;
} catch (error) {
setCollectError(errorDetail(error, "Unable to sync changes"));
return null;
} finally {
collectingRef.current = false;
setCollecting(false);
}
}, [reloadHeads]);
const collectLatest = useCallback(
async (initial = false) => {
if (collectingRef.current) return null;
collectingRef.current = true;
setCollecting(true);
setCollectError("");
try {
const { data, error } = await api.POST("/api/v2/collections", {
params: { query: { initial } },
});
if (!data)
throw new Error(errorDetail(error, "Unable to sync changes"));
reloadHeads();
return data;
} catch (error) {
setCollectError(errorDetail(error, "Unable to sync changes"));
return null;
} finally {
collectingRef.current = false;
setCollecting(false);
}
},
[reloadHeads],
);
const headsRef = useRef(heads.contexts);
useEffect(() => {
headsRef.current = heads.contexts;
Expand Down Expand Up @@ -283,7 +289,11 @@ export function SessionProvider({ children }: { children: ReactNode }) {
if (!collectOnLoad || collected.current === route) return;
// Effect replay must not treat the in-flight collection as a failed one.
collected.current = route;
void collectLatest().then((result) => {
void collectLatest(true).then((result) => {
if (result?.failures.length)
setCollectError(
`Unable to sync ${result.failures.length} ${result.failures.length === 1 ? "checkout" : "checkouts"}: ${result.failures.map((failure) => `${failure.path} (${failure.detail})`).join(", ")}`,
);
const contexts = result?.contexts ?? [];
const head =
route.kind === "live"
Expand Down Expand Up @@ -386,6 +396,8 @@ export function SessionProvider({ children }: { children: ReactNode }) {
// The root URL keeps waiting for changed work while showing the empty state.
const empty =
heads.loaded && !selectedId && route.kind === "root" && !collectOnLoad;
const statusError =
state.status === "error" ? state.detail : heads.error || collectError;
useEffect(() => {
if (!restorePickerFocus.current || (!empty && state.status === "loading"))
return;
Expand Down Expand Up @@ -423,36 +435,36 @@ export function SessionProvider({ children }: { children: ReactNode }) {
<main
className="session-status"
role="status"
aria-busy={!statusError && !empty && !missing}
ref={statusRef}
tabIndex={-1}
>
<h1>
{missing
? "No current snapshot"
: empty
? heads.contexts.length
? "No changes to review"
: "No review contexts yet"
: state.status === "error" || heads.error
? "Cannot load diffx"
{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"}
</h1>
<p>
{missing
? collectError ||
"This stream's snapshots expired or were deleted. Pick current work from Projects."
: 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."
: state.status === "error"
? state.detail
: heads.error ||
(collectOnLoad
? "Reading checkouts for new changes…"
: "Reading review context…")}
{statusError ||
(missing
? "This stream's snapshots expired or were deleted. Pick current work from Projects."
: 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…")}
</p>
{empty && remote && (
<Button
Expand All @@ -462,12 +474,18 @@ export function SessionProvider({ children }: { children: ReactNode }) {
Create a collector token
</Button>
)}
{(state.status === "error" || heads.error) && (
{statusError && (
<Button
type="button"
variant="outline"
onClick={() => {
setState({ status: "loading" });
if (collectError) {
collected.current = null;
setCollectError("");
setMissing(false);
setUncollected(null);
}
reloadHeads();
setAttempt((current) => current + 1);
}}
Expand Down
145 changes: 142 additions & 3 deletions apps/web/test/browser/local-workspace.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1783,7 +1783,7 @@ for (const mode of ["local", "remote"]) {
}),
);
let collections = 0;
await page.route("**/api/v2/collections", (route) => {
await page.route("**/api/v2/collections*", (route) => {
collections++;
return route.fulfill({ json: { contexts: heads, failures: [] } });
});
Expand Down Expand Up @@ -1896,6 +1896,142 @@ for (const mode of ["local", "remote"]) {
});
}

test("local first launch waits for collection with an empty catalog", async ({
page,
}) => {
const state = await localWorkspace(page);
await page.route("**/api/v2/bootstrap", (route) =>
route.fulfill({
json: {
mode: "local",
signupEnabled: false,
user: null,
csrfToken: null,
collect: true,
},
}),
);
const snapshot = {
...state.context,
streamId: "local-stream",
latestContextId: state.context.id,
};
let heads: ApiContext[] = [];
await page.route("**/api/v2/contexts?*", (route) =>
route.fulfill({ json: { contexts: heads, nextCursor: null } }),
);
let collections = 0;
let pending: Route | undefined;
await page.route("**/api/v2/collections*", (route) => {
collections++;
expect(new URL(route.request().url()).searchParams.get("initial")).toBe(
"true",
);
pending = route;
});

await page.goto("/");
const status = page.locator("main.session-status");
await expect(status).toContainText("Collecting latest changes");
await expect(status).toHaveAttribute("aria-busy", "true");
const projects = page.getByRole("navigation", { name: "Projects" });
await expect(projects).toContainText("Collecting projects…");
await expect(projects).not.toContainText("No current changes");
await expect(projects).not.toContainText("Agent hooks and diffx sync");
await expect(
page.getByRole("heading", { name: "No review contexts yet" }),
).toHaveCount(0);
await expect(page.locator("#viewer")).toHaveCount(0);
await expect.poll(() => collections).toBe(1);
if (!pending) throw new Error("Missing startup collection");
heads = [snapshot];
await pending.fulfill({ json: { contexts: heads, failures: [] } });
await ready(page);
await expect(page).toHaveURL(/\/streams\/local-stream$/);
expect(collections).toBe(1);
});

for (const path of ["/", "/streams/local-stream"]) {
test(`local startup collection failure retries from ${path} without stored reviews`, async ({
page,
}) => {
const state = await localWorkspace(page);
await page.route("**/api/v2/bootstrap", (route) =>
route.fulfill({
json: {
mode: "local",
signupEnabled: false,
user: null,
csrfToken: null,
collect: true,
},
}),
);
let heads: ApiContext[] = [];
await page.route("**/api/v2/contexts?*", (route) =>
route.fulfill({ json: { contexts: heads, nextCursor: null } }),
);
let collections = 0;
let pending: Route | undefined;
await page.route("**/api/v2/collections*", (route) => {
collections++;
if (collections === 1)
return route.fulfill({
status: 422,
json: { detail: "Unable to collect checkouts: git failed" },
});
pending = route;
});
await page.goto(path);
const status = page.locator("main.session-status");
await expect(status).toContainText("Cannot sync changes");
await expect(status).toContainText("git failed");
await expect(status).toHaveAttribute("aria-busy", "false");
await status.getByRole("button", { name: "Retry", exact: true }).click();
await expect(status).toContainText("Collecting latest changes");
await expect(status).toHaveAttribute("aria-busy", "true");
await expect.poll(() => collections).toBe(2);
if (!pending) throw new Error("Missing retried collection");
heads = [{ ...state.context, streamId: "local-stream" }];
await pending.fulfill({ json: { contexts: heads, failures: [] } });
await ready(page);
await expect(page).toHaveURL(/\/streams\/local-stream$/);
await expect(page.getByRole("alert")).toHaveCount(0);
expect(collections).toBe(2);
});
}

test("local startup reports failed checkouts while opening collected changes", async ({
page,
}) => {
const state = await localWorkspace(page);
await page.route("**/api/v2/bootstrap", (route) =>
route.fulfill({
json: {
mode: "local",
signupEnabled: false,
user: null,
csrfToken: null,
collect: true,
},
}),
);
await page.route("**/api/v2/collections*", (route) =>
route.fulfill({
json: {
contexts: [{ ...state.context, streamId: "local-stream" }],
failures: [{ path: "/workspace/broken", detail: "git failed" }],
},
}),
);
await page.goto("/");
await ready(page);
await expect(page).toHaveURL(/\/streams\/local-stream$/);
await expect(page.getByRole("alert")).toContainText(
"Unable to sync 1 checkout: /workspace/broken (git failed)",
);
});

test("local live URLs sync the checkouts on every load", async ({ page }) => {
const state = await localWorkspace(page);
await page.route("**/api/v2/bootstrap", (route) =>
Expand Down Expand Up @@ -1930,7 +2066,7 @@ test("local live URLs sync the checkouts on every load", async ({ page }) => {
release = resolve;
});
let failNext = false;
await page.route("**/api/v2/collections", async (route) => {
await page.route("**/api/v2/collections*", async (route) => {
collected++;
if (collected === 1) await held;
if (failNext)
Expand Down Expand Up @@ -2020,8 +2156,11 @@ test("local sync collects every checkout without replacing the open review", asy
});
const collections: string[] = [];
let fail = false;
await page.route("**/api/v2/collections", (route) => {
await page.route("**/api/v2/collections*", (route) => {
collections.push(route.request().method());
expect(new URL(route.request().url()).searchParams.get("initial")).toBe(
"false",
);
if (fail)
return route.fulfill({
status: 422,
Expand Down
Loading
Loading