From 7106400e8d4ed06f10c5a02892105c80d80bb5b7 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:36:19 +0000 Subject: [PATCH 1/2] fix(desktop): draw the person page's waits as skeletons, never a false Someone or empty line Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../src/renderer/desktop/person-page.test.ts | 70 +++++++++- .../src/renderer/desktop/person-page.tsx | 125 +++++++++++++++--- 2 files changed, 176 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/renderer/desktop/person-page.test.ts b/apps/desktop/src/renderer/desktop/person-page.test.ts index 7a7dfaa491..63688691fd 100644 --- a/apps/desktop/src/renderer/desktop/person-page.test.ts +++ b/apps/desktop/src/renderer/desktop/person-page.test.ts @@ -194,6 +194,12 @@ let inboxRefused = false; let automationsRefused = false; /** Whether the Inbox's reads are still out, answering nothing yet. */ let inboxHeld = false; +/** Whether the automations' reads are still out, answering nothing yet. */ +let automationsHeld = false; +/** Whether the members read is held until the test answers it with `answerMembers`. */ +let membersHeld = false; +/** Answers the held members read with the fixture's members. */ +let answerMembers: () => void = () => undefined; /** Every text the page asked the window to copy, in order. */ let copied: string[] = []; @@ -201,6 +207,8 @@ beforeEach(() => { inboxRefused = false; automationsRefused = false; inboxHeld = false; + automationsHeld = false; + membersHeld = false; copied = []; fakeAppPlatform({ ...QUIET_PANEL, ...copyingInto(copied) }); window.localStorage.clear(); @@ -210,6 +218,14 @@ beforeEach(() => { request.kind === ACT_KIND.CODING_AGENTS_INBOX || request.kind === ACT_KIND.CODING_AGENTS_AGENT_INBOX; if (inbox && inboxHeld) return new Promise(() => undefined); + if (request.kind === ACT_KIND.AUTOMATIONS_LIST && automationsHeld) { + return new Promise(() => undefined); + } + if (request.kind === ACT_KIND.WORKSPACE_MEMBERS_READ && membersHeld) { + return new Promise((resolve) => { + answerMembers = () => resolve({ status: ACT_OUTCOME_STATUS.DONE, value: MEMBERS }); + }); + } if (request.kind === ACT_KIND.WORKSPACE_MEMBERS_READ) { return Promise.resolve({ status: ACT_OUTCOME_STATUS.DONE, value: MEMBERS }); } @@ -324,6 +340,13 @@ function head(page: HTMLElement): string[] { return [...page.querySelectorAll(".person-head p")].map((line) => line.textContent ?? ""); } +/** What each placeholder on the page says it is waiting on, in order. */ +function waits(page: HTMLElement): string[] { + return [...page.querySelectorAll('[role="status"][aria-busy="true"]')].map( + (region) => region.textContent ?? "", + ); +} + function button(page: HTMLElement, text: string): HTMLButtonElement { const found = [...page.querySelectorAll("button")].find((each) => each.textContent?.startsWith(text), @@ -582,7 +605,52 @@ test("automations that could not be read leave the Inbox still being read saying assert.match(page.textContent ?? "", /Their automations could not be read\./u); // The Inbox still being read stands as its own wait beside the automations' failure. - assert.equal(page.querySelectorAll('.person-column .luke-spinner[role="status"]').length, 1); + assert.deepEqual(waits(page), ["Reading your inbox…"]); + assert.doesNotMatch(page.textContent ?? "", /Nothing of theirs here yet/u); +}); + +test("while the Inbox and the automations are read, placeholder rows stand for each, never a spinner", async () => { + handState(experimentsOn(EXPERIMENT.AUTOMATIONS)); + inboxHeld = true; + automationsHeld = true; + const { page } = mount("user-sam"); + await settle(); + + assert.deepEqual(waits(page), ["Reading your inbox…", "Reading their automations…"]); + assert.equal(page.querySelector(".luke-spinner"), null); + assert.doesNotMatch(page.textContent ?? "", /Nothing of theirs here yet/u); +}); + +test("a page opened by a member's id alone stands a placeholder head while the members are read, never Someone not in this workspace", async () => { + membersHeld = true; + const { page } = mount("user-sam"); + await settle(); + + assert.deepEqual(waits(page), ["Reading who they are…"]); + assert.doesNotMatch(page.textContent ?? "", /Someone|Not in this workspace/u); + + answerMembers(); + await settle(); + + assert.deepEqual(waits(page), []); + assert.deepEqual(head(page), ["Sam Ortiz", "Member · sam@example.com"]); +}); + +test("while the task list is read, placeholder rows stand for their tasks and the page is never called empty", async () => { + const { page } = mountWith({ listRead: false }, "user-sam"); + await settle(); + + assert.deepEqual(waits(page), ["Reading their tasks…"]); + assert.doesNotMatch(page.textContent ?? "", /Nothing of theirs here yet/u); +}); + +test("a task list that could not be read says so and offers Retry, never calling the page empty", async () => { + const { page } = mountWith({ listRead: false, listFailed: true }, "user-sam"); + await settle(); + + assert.match(page.textContent ?? "", /Their tasks could not be read\./u); + assert.ok(button(page, "Retry")); + assert.deepEqual(waits(page), []); assert.doesNotMatch(page.textContent ?? "", /Nothing of theirs here yet/u); }); diff --git a/apps/desktop/src/renderer/desktop/person-page.tsx b/apps/desktop/src/renderer/desktop/person-page.tsx index 7ea25db61a..554f1194bb 100644 --- a/apps/desktop/src/renderer/desktop/person-page.tsx +++ b/apps/desktop/src/renderer/desktop/person-page.tsx @@ -10,6 +10,10 @@ import { Button, ErrorState, IconButton, + ListRow, + SKELETON_SHAPE, + Skeleton, + SkeletonRegion, TEXT_ELEMENT, TEXT_TONE, TEXT_VARIANT, @@ -40,9 +44,8 @@ import { LINK_COPY, useCopyToast } from "../copy-feedback"; import { MemberDirectory, useMemberDirectory } from "../member-directory"; import { NOT_FOUND_THING, NotFound } from "../not-found"; import { useOpenSettings } from "../open-settings"; -import { SPINNER_SIZE, Spinner } from "../spinner"; import { lineSubject } from "../tasks/agent-ref"; -import { InboxItemRow } from "../tasks/inbox-list"; +import { InboxItemRow, InboxListSkeleton } from "../tasks/inbox-list"; import { holderName, INBOX_HOLDER, type InboxListed } from "../tasks/inbox-model"; import { RelativeTime } from "../tasks/relative-time"; import { useMinute } from "../tasks/task-time"; @@ -67,14 +70,35 @@ import { useAutomations } from "./use-automations"; * agents, each from the listed workspace's task list and Inbox * (`@luke/ui/person-page-model`), and the workspace's automations they * made, a section with nothing in it left out. - * Until the Inbox is read the page says so, and a read that failed says - * that and offers Retry, so a page is called empty only once it is. + * Until the members, the task list, the Inbox, and the automations are read, + * placeholders stand where what each names will, and a read that failed + * says that and offers Retry, so a page names nobody as someone outside the + * workspace, and is called empty, only once its reads have answered. * Nothing on it is read for it: it shows a teammate only what the sidebar, * the Inbox, and an agent's card already show any member, never their * personal workspace, their answers, or another workspace's. A press on a * row opens what it names, as Home's do, and Escape leaves the page. */ +/** How wide a placeholder section's name stands, as a short section name does. */ +const SKELETON_SECTION = "120px"; + +/** How wide the placeholder head's name and the line under it stand, as a name and a role and email do. */ +const SKELETON_HEAD = { NAME: "180px", LINE: "240px" } as const; + +/** The placeholder head's face, at the page's size (`.actor-mark[data-size="page"]`). */ +const SKELETON_FACE = "48px"; + +/** A placeholder row's tile, an automation's or a repository's at its inline size (`.home-row-picture`). */ +const SKELETON_MARK = "16px"; + +/** How wide each placeholder row's name and what is said beside it stand, so the rows read as rows of their own. */ +const SKELETON_ROWS = [ + { name: "46%", context: "96px" }, + { name: "38%", context: "120px" }, + { name: "54%", context: "80px" }, +] as const; + /** What a page's head says under a name, by who the person is to the workspace. */ function headLine(person: Actor): string | undefined { if (person.kind === ACTOR_KIND.PERSON) { @@ -117,6 +141,40 @@ function PersonSection({ ); } +/** A section still being read: a block where its name will stand, over the placeholder rows in `children`. */ +function SectionSkeleton({ children }: { children: ReactNode }): React.JSX.Element { + return ( +