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
32 changes: 25 additions & 7 deletions apps/web/src/document-rename.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,31 @@ export function DocumentRename(
let cancelled = useRef(false);

useEffect(() => {
input.current?.focus();
input.current?.select();
// Taken in the same task as focus, so no key falls between the hold and the field.
let held = replay ? heldTyping : undefined;
if (replay) holdTyping(false);
if (held?.text) setTitle(held.text);
if (held?.enter) void save(held.text || channel.title);
let field = input.current;
let layer = field?.closest<HTMLElement>(".document-route-layer");
let observer: MutationObserver | undefined;
let focus = () => {
if (!field || layer?.inert || layer?.hidden) return;
field.focus();
if (document.activeElement !== field) return;
observer?.disconnect();
// Taken in the same task as focus, so no key falls between the hold and the field.
let held = replay ? heldTyping : undefined;
if (replay) holdTyping(false);
if (held?.text) {
// Key presses after focus append to the held prefix, not replace it.
field.value = held.text;
field.setSelectionRange(held.text.length, held.text.length);
setTitle(held.text);
} else field.select();
if (held?.enter) void save(held.text || channel.title);
};
if (layer) {
observer = new MutationObserver(focus);
observer.observe(layer, { attributes: true, attributeFilter: ["inert", "hidden"] });
}
focus();
return () => observer?.disconnect();
}, []);

function report(next: unknown) {
Expand Down
26 changes: 19 additions & 7 deletions apps/web/src/document-route-swap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,23 +105,35 @@ test("reversing to a loaded layer preserves metadata and accepts the requested s
source: { slug: "a" },
};
state = transitionDocumentRoute(state, { route: requested, type: "requested" });
expect(state).toEqual({
current: { ...requested, resolution: "loaded" },
previous: b,
});
// B never reached the screen while A was leaving, so it is dropped.
expect(state).toEqual({ current: { ...requested, resolution: "loaded" } });
expect(state.current.source).toBe(requested.source);
});

test("rapid requests cancel pending work and preserve the requested visible route", () => {
test("rapid requests replace pending work and keep the leaving route until the newest is ready", () => {
let state: DocumentRouteSwap<Route> = { current: a };
state = transitionDocumentRoute(state, { route: b, type: "requested" });
state = transitionDocumentRoute(state, { key: b.key, type: "ready" });
state = transitionDocumentRoute(state, { route: a, type: "requested" });
state = transitionDocumentRoute(state, { route: c, type: "requested" });
state = transitionDocumentRoute(state, { route: b, type: "requested" });

expect(state.pending).toBeUndefined();
expect(state.current).toEqual(b);
expect(state.current.key).toBe(a.key);
expect(state.previous).toBeUndefined();
expect(state.pending).toEqual(b);
});

test("a route that becomes ready while another is leaving replaces the never-shown one", () => {
let state: DocumentRouteSwap<Route> = { current: a };
state = transitionDocumentRoute(state, { route: b, type: "requested" });
state = transitionDocumentRoute(state, { key: b.key, type: "ready" });
// A is still leaving, so B has not been shown yet.
state = transitionDocumentRoute(state, { route: c, type: "requested" });
state = transitionDocumentRoute(state, { key: c.key, type: "ready" });

expect(state).toEqual({ current: c, previous: a });
state = transitionDocumentRoute(state, { key: a.key, type: "closed" });
expect(state).toEqual({ current: c, pending: undefined });
});

test("stale ready and close events cannot replace or remove current content", () => {
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/document-route-swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,10 @@ export function transitionDocumentRoute<T extends KeyedDocumentRoute, R = unknow
previous: state.previous,
};
}
// While a route is still leaving, the current one has never been shown, so a
// return to the leaving route simply keeps it.
if (requested.key === state.previous?.key) {
return {
current: retarget(state.previous),
previous: state.current,
};
return { current: retarget(state.previous) };
}
return { ...state, pending: requested };
}
Expand All @@ -79,7 +78,8 @@ export function transitionDocumentRoute<T extends KeyedDocumentRoute, R = unknow
? route
: { ...route, resolution: action.resolution };
if (state.pending?.key === action.key) {
return { current: ready(state.pending), previous: state.current };
// A route that never reached the screen is dropped, not shown on its way out.
return { current: ready(state.pending), previous: state.previous ?? state.current };
}
if (state.current.key === action.key && action.resolution !== undefined) {
if (state.current.resolution === action.resolution) return state;
Expand Down
25 changes: 23 additions & 2 deletions apps/web/src/document-workspace-host.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ function workspaceProps(
};
}

// How long after a request the outgoing document may wait for this one to sync. Past
// it, the header and body would disagree with the sidebar for too long.
const REVEAL_LIMIT = 200;

export default function DocumentWorkspaceHost(
{
agent,
Expand Down Expand Up @@ -138,6 +142,7 @@ export default function DocumentWorkspaceHost(
let active = true;
let controller = new AbortController();
let requestedRoute = routeRef.current;
let requestedAt = performance.now();
send({ type: "loading" });
let currentState = stateRef.current;
let current = currentState.status === "ready" ? currentState.loaded : undefined;
Expand Down Expand Up @@ -188,11 +193,27 @@ export default function DocumentWorkspaceHost(
repository: parent.repository.name,
slug: parent.channel.slug,
});
onReady(layerKey, {
let resolution = {
canonicalPath: prepared.pathname,
channel: (child ?? parent).channel,
routeKey,
});
};
// The outgoing document stays on screen until this one has synced, briefly.
let synced = `[data-workspace-room="${
CSS.escape(resolution.channel.id)
}"] [data-plan-synced]`;
let reveal = () => {
if (!active) return;
if (
parentSurface.current?.closest(".document-route-layer")?.querySelector(synced)
|| performance.now() - requestedAt > REVEAL_LIMIT
) {
onReady(layerKey, resolution);
} else {
requestAnimationFrame(reveal);
}
};
reveal();
}, reason => {
if (active) {
send({ error: reason, type: "failed" });
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/hosted.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ function DocumentRouteSwap(
let layers = [state.previous, state.current, state.pending].filter(
(layer): layer is DocumentRouteLayer => layer !== undefined,
);
let motion = motionContract("content-swap");
let motion = motionContract("route-swap");
let { onDocumentLoaded, onDocumentRouteSettled } = useNavigationDocument();
let ready = useCallback((
key: DocumentRouteIdentity,
Expand Down Expand Up @@ -284,14 +284,20 @@ function DocumentRouteSwap(
let source = layer.source;
return (
<ContentSwapLayer
active={layer.key === state.current.key}
// The incoming route loads unseen and enters once the outgoing one has left.
// A newer request keeps the outgoing route on screen, so a route the user
// has already moved past never flashes in.
active={layer === state.current
? !state.previous
: layer === state.previous && !!state.pending}
className="document-route-layer h-full min-h-0"
immediately={state.current.immediately}
key={layer.key}
motion={motion}
onClosed={layer.key === state.previous?.key
? () => dispatch({ key: layer.key, type: "closed" })
: undefined}
staged={layer !== state.previous && (layer === state.pending || !!state.previous)}
>
{source.page === "document" || source.page === "child"
? (
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/motion-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ describe("motion contracts", () => {
closeDuration: 250,
states: MOTION_STATES,
});
expect(motionContract("route-swap")).toEqual({
className: "motion-route-swap",
closeDuration: 30,
states: MOTION_STATES,
});
expect(motionContract("feedback")).toEqual({
className: "motion-feedback",
closeDuration: 180,
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/motion-contract.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
export type MotionKind = "collapse" | "content-swap" | "feedback" | "popover" | "sidebar";
export type MotionKind =
| "collapse"
| "content-swap"
| "feedback"
| "popover"
| "route-swap"
| "sidebar";

export const MOTION_STATES = ["", "is-open", "is-closing"] as const;

Expand All @@ -23,6 +29,8 @@ let contracts = {
},
feedback: { className: "motion-feedback", closeDuration: 180, states: MOTION_STATES },
popover: { className: "motion-popover", closeDuration: 150, states: MOTION_STATES },
// `closeDelay` adds 50ms, so the incoming route starts as `--route-swap-out-dur` ends.
"route-swap": { className: "motion-route-swap", closeDuration: 30, states: MOTION_STATES },
sidebar: { className: "motion-sidebar", closeDuration: 180, states: MOTION_STATES },
} as const satisfies Record<MotionKind, MotionContract>;

Expand Down
25 changes: 24 additions & 1 deletion apps/web/src/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,7 @@ body {
display: grid;
}

.content-swap-stack > .motion-content-swap {
.content-swap-stack > :is(.motion-content-swap, .motion-route-swap) {
grid-area: 1 / 1;
min-width: 0;
}
Expand All @@ -627,6 +627,28 @@ body {
transform: translateX(calc(var(--page-slide-distance) * var(--swap-direction, 1) * -1));
}

/*
* Switching documents keeps the header, tabs and Chat frame still. Only the
* document body and transcript change: out, then in, never both at once. A
* staged route is laid out but unseen while its document syncs.
*/
.motion-route-swap[data-content-swap-state="staged"] {
visibility: hidden;
}

.motion-route-swap :is([data-workspace-document-swap], [data-chat-stack]) {
opacity: 0;
transition: opacity var(--route-swap-in-dur) var(--ease-out);
}

.motion-route-swap.is-open :is([data-workspace-document-swap], [data-chat-stack]) {
opacity: 1;
}

.motion-route-swap.is-closing :is([data-workspace-document-swap], [data-chat-stack]) {
transition-duration: var(--route-swap-out-dur);
}

.workspace-root:not([data-workspace-mode="split"]) .workspace-chat-panel {
position: absolute;
inset: 0;
Expand Down Expand Up @@ -1393,6 +1415,7 @@ button.question-add:disabled {
.motion-popover,
.motion-panel,
.motion-content-swap,
.motion-route-swap :is([data-workspace-document-swap], [data-chat-stack]),
.motion-collapse,
.motion-collapse-content {
transition: none;
Expand Down
99 changes: 85 additions & 14 deletions e2e/document-navigation.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,38 @@ function documentRouteLayers(page: import("@playwright/test").Page, selector: st
});
}

// Records visible route layers every frame; exit and entry are too brief to poll.
async function recordRouteLayers(page: import("@playwright/test").Page) {
await page.evaluate(() => {
let samples: string[] = [];
(window as Window & { __routeSamples?: string[] }).__routeSamples = samples;
let sample = () => {
let layers = document.querySelectorAll<HTMLElement>(
".document-route-swap > [data-content-swap-state]:not([hidden])",
);
samples.push(
[...layers].map(layer =>
`${layer.dataset.contentSwapState}${layer.hasAttribute("inert") ? ":inert" : ""}`
).join(" "),
);
if (samples.length < 1200) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
});
}

async function routeLayerSamples(page: import("@playwright/test").Page) {
let samples = await page.evaluate(() =>
(window as Window & { __routeSamples?: string[] }).__routeSamples ?? []
);
// Two routes are never interactive at once.
for (let sample of samples) {
expect(sample.split(" ").filter(layer => layer && !layer.endsWith(":inert")).length)
.toBeLessThanOrEqual(1);
}
return samples;
}

async function headerAction(page: import("@playwright/test").Page, action: string) {
await headerActions(page).click();
await page.getByRole("menuitem", { name: action, exact: true }).click();
Expand Down Expand Up @@ -562,33 +594,26 @@ test("document switches preserve navigation state and avoid catalogue reloads",
),
).toBe("preserved");
await page.keyboard.press("Shift");
await recordRouteLayers(page);
release.resolve();
let visibleRoutes = documentRouteLayers(page, "[data-content-swap-state]:not([hidden])");
let outgoingRoutes = documentRouteLayers(
page,
'[data-content-swap-state="outgoing"]:not([hidden])',
);
// Inspect the short-lived exit layer in one browser turn, before its timer removes it.
await expect.poll(() =>
visibleRoutes.evaluateAll(routes =>
routes.map(route => ({
outgoing: route.getAttribute("data-content-swap-state") === "outgoing",
inert: route.hasAttribute("inert"),
hidden: route.getAttribute("aria-hidden"),
}))
)
).toEqual([
{ outgoing: true, inert: true, hidden: "true" },
{ outgoing: false, inert: false, hidden: null },
]);
await expect(headerDocument(page)).toHaveAccessibleName(`Document: ${originalTitle}`);
await expect(visibleRoutes).toHaveCount(1);
// The incoming route waits unseen and inert while the outgoing one leaves.
expect(await routeLayerSamples(page)).toContain("outgoing:inert staged:inert");
await expect(headerDocument(page)).toHaveAccessibleName(`Document: ${originalTitle}`);
await expect(page).toHaveURL(originalPath!);

let createdLink = projects.getByRole("link", { name: createdTitle, exact: true });
await recordRouteLayers(page);
await createdLink.click();
await expect(headerDocument(page)).toHaveAccessibleName(`Document: ${createdTitle}`);
await expect(visibleRoutes).toHaveCount(2);
await expect(visibleRoutes).toHaveCount(1);
expect(await routeLayerSamples(page)).toContain("outgoing:inert staged:inert");
await expect(page).toHaveURL(createdPath!);
await page.unroute(documentRoutePattern);

Expand Down Expand Up @@ -631,6 +656,52 @@ test("document switches preserve navigation state and avoid catalogue reloads",
await expect(outgoingRoutes).toHaveCount(0);
});

test("rapid document switches and history jumps settle on one visible document", async ({ baseURL, join, seed }) => {
await seed("Burst origin document.\n");
let databasePort = Number(new URL(baseURL!).port);
let documents = await Promise.all([0, 1, 2, 3].map(async index => {
let id = crypto.randomUUID();
await createChannel(databasePort, id);
await seedChannel(databasePort, id, `Burst document ${index}.\n`);
return { text: `Burst document ${index}.`, title: `Test ${id.slice(0, 8)}` };
}));
let page = await join("ana");
let projects = sidebar(page);
for (let document of documents) {
await expect(projects.getByRole("link", { name: document.title, exact: true })).toBeVisible();
}
let routes = page.locator(".document-route-swap > [data-content-swap-state]:not([hidden])");
let interactive = page.locator(
".document-route-swap > [data-content-swap-state]:not([hidden]):not([inert])",
);
let settled = async (document: { text: string; title: string }) => {
await expect(headerDocument(page)).toHaveAccessibleName(`Document: ${document.title}`);
await expect(routes).toHaveCount(1);
await expect(interactive).toHaveCount(1);
await expect(interactive.getByText(document.text, { exact: true })).toBeVisible();
};

// Clicks land faster than a route can finish leaving.
await page.evaluate(async titles => {
for (let title of titles) {
[...document.querySelectorAll<HTMLAnchorElement>("a")]
.find(link => link.textContent?.trim() === title)!.click();
await new Promise(resolve => setTimeout(resolve, 80));
}
}, documents.map(document => document.title));
await settled(documents[3]!);

await page.evaluate(async () => {
for (let step of [-1, 1, -1, -1]) {
history.go(step);
await new Promise(resolve => setTimeout(resolve, 60));
}
});
await expect(page).toHaveURL(/\/documents\/octo-org\/score\//);
let current = (await projects.locator('a[aria-current="page"]').textContent())!.trim();
await settled(documents.find(document => document.title === current)!);
});

test("overlapping document workspaces keep IDs, ARIA targets, and focus instance-scoped", async ({ baseURL, join }) => {
let page = await join("ana", { viewport: { width: 390, height: 844 } });
let created = crypto.randomUUID();
Expand Down
Loading
Loading