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 ( +
+
+ + + +
+ {children} +
+ ); +} + +/** A list still being read, which `label` names: rows of a tile, a name, and what is said beside it, as a task's or an automation's row stands. */ +function RowsSkeleton({ label }: { label: string }): React.JSX.Element { + return ( + + + + + + ); +} + /** One of their agents as a row: its tile, its name, the task it works on, and when it last moved; a press opens its tab. */ function AgentRow({ each, @@ -322,6 +380,25 @@ function PersonHead({ person }: { person: Actor }): React.JSX.Element { ); } +/** The head while the members are read: a block where their face, their name, and who they are here will stand. */ +function PersonHeadSkeleton(): React.JSX.Element { + return ( + +
+ +
+ + + + + + +
+
+
+ ); +} + /** The page inside the workspace's member directory, which names the person. */ function PersonColumn({ tasks, @@ -377,25 +454,25 @@ function PersonColumn({ : { agentId: item.agentId, itemId: item.id }, ); }; - const name = headName(person); // Nobody the workspace's members or those who left name, as a link to someone outside it opens, is not found here; until both are read, nobody is. - if ( - person.kind === ACTOR_KIND.SOMEONE && - person.reason === SOMEONE_REASON.UNNAMED && - members !== undefined && - former !== undefined - ) { + const unnamed = person.kind === ACTOR_KIND.SOMEONE && person.reason === SOMEONE_REASON.UNNAMED; + if (unnamed && members !== undefined && former !== undefined) { return (
); } + // Note that someone nothing names yet is drawn as a placeholder, because only settled reads may say they are not in the workspace. + const name = unnamed ? undefined : headName(person); + const theirs = you ? "your" : "their"; return ( -
+
-

{name}

+

+ {name ?? } +

- + {unnamed ? : } + {tasks.listFailed ? ( +
+ + {`${you ? "Your" : "Their"} tasks could not be read.`} + +
+ ) : !tasks.listRead && personPageEmpty(page) ? ( + + ) : null} {page.working.length === 0 ? null : ( {page.working.map((each) => ( @@ -481,7 +567,9 @@ function PersonColumn({ Some Inbox items could not be read.
) : waiting.length === 0 && read.reading ? ( - + + + ) : null} {theirAutomations.failed ? (
@@ -490,10 +578,11 @@ function PersonColumn({
) : theirAutomations.reading ? ( - + ) : null} - {/* Note that the page is said to be empty only once both reads have answered, since either may yet name something. */} - {read.failed || + {/* Note that the page is said to be empty only once the task list, the Inbox, and the automations have answered, since any may yet name something. */} + {!tasks.listRead || + read.failed || read.reading || theirAutomations.failed || theirAutomations.reading ? null : personPageEmpty(page) && From f9faf1e72c4c76f18d9a43bd81ec0aaecbf945d3 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:44:31 +0000 Subject: [PATCH 2/2] fix(desktop): call a person page empty only once it names someone Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- apps/desktop/src/renderer/desktop/person-page.test.ts | 4 ++-- apps/desktop/src/renderer/desktop/person-page.tsx | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/desktop/person-page.test.ts b/apps/desktop/src/renderer/desktop/person-page.test.ts index 63688691fd..e037891808 100644 --- a/apps/desktop/src/renderer/desktop/person-page.test.ts +++ b/apps/desktop/src/renderer/desktop/person-page.test.ts @@ -621,13 +621,13 @@ test("while the Inbox and the automations are read, placeholder rows stand for e 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 () => { +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 nor an empty page", 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); + assert.doesNotMatch(page.textContent ?? "", /Someone|Not in this workspace|Nothing of theirs/u); answerMembers(); await settle(); diff --git a/apps/desktop/src/renderer/desktop/person-page.tsx b/apps/desktop/src/renderer/desktop/person-page.tsx index 554f1194bb..620701e36f 100644 --- a/apps/desktop/src/renderer/desktop/person-page.tsx +++ b/apps/desktop/src/renderer/desktop/person-page.tsx @@ -580,8 +580,9 @@ function PersonColumn({ ) : theirAutomations.reading ? ( ) : null} - {/* Note that the page is said to be empty only once the task list, the Inbox, and the automations have answered, since any may yet name something. */} - {!tasks.listRead || + {/* Note that the page is said to be empty only once it names someone and the task list, the Inbox, and the automations have answered, since any may yet name something, or nobody may be found. */} + {unnamed || + !tasks.listRead || read.failed || read.reading || theirAutomations.failed ||