From c4dc7109fd498f87a07cd3bbddac7c5a47118ff2 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:48:15 +0000 Subject: [PATCH] fix(desktop): draw Home in its own shape while the task list loads Home drew nothing until the task list was read, so every launch showed a blank window. It now stands a skeleton shaped like the page: a block for the greeting beside New task, then heading blocks over placeholder rows for the Inbox and Recent. It says no word that hangs on the list, so a new account's welcome replaces it without having claimed otherwise. Home's Inbox section drew a lone ring at the section's edge while its items were read; it now stands InboxListSkeleton's rows, three of them with a block for each row's context, through new optional rows and context props. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../src/renderer/desktop/desktop-home.test.ts | 67 +++++++++++ .../src/renderer/desktop/desktop-home.tsx | 109 ++++++++++++++++-- .../desktop/src/renderer/tasks/inbox-list.tsx | 31 ++++- 3 files changed, 196 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/renderer/desktop/desktop-home.test.ts b/apps/desktop/src/renderer/desktop/desktop-home.test.ts index b294d02505..c7d41d6903 100644 --- a/apps/desktop/src/renderer/desktop/desktop-home.test.ts +++ b/apps/desktop/src/renderer/desktop/desktop-home.test.ts @@ -267,6 +267,73 @@ function button(page: HTMLElement, text: string): HTMLButtonElement { return found; } +/** What each region still being read says to a reader, in the order they stand. */ +function waits(page: HTMLElement): string[] { + return [...page.querySelectorAll('[role="status"][aria-busy="true"]')].map( + (region) => region.textContent ?? "", + ); +} + +test("while the task list is unread, Home stands in its own shape: New task, and placeholder rows under the Inbox and Recent, saying no greeting", () => { + const { page } = mount({ listRead: false }); + + assert.ok(button(page, "New task")); + assert.deepEqual(waits(page), ["Reading your inbox…", "Reading your tasks…"]); + assert.ok(page.querySelectorAll(".luke-list-row .luke-skeleton").length > 0, "rows of blocks"); + assert.doesNotMatch(page.textContent ?? "", /Welcome back|Nothing needs you/u); + assert.equal(page.querySelector("h1"), null); +}); + +test("an account whose task list is unread is neither welcomed as new nor greeted back until the list says which it is", () => { + const { page } = mount({ + listRead: false, + tasks: [], + listed: { tasks: [], archived: [], inbox: [], inboxTasks: [] }, + inbox: [], + inboxTasks: [], + }); + + assert.equal(page.querySelector("[aria-label='Welcome']"), null, "no welcome stands"); + assert.doesNotMatch(page.textContent ?? "", /Start your first task|Welcome back/u); + assert.deepEqual(waits(page), ["Reading your inbox…", "Reading your tasks…"]); +}); + +test("once the task list lands, the page replaces its placeholder rows with the greeting, what waits, and the plans saved latest", async () => { + const { page } = mount({ listRead: false }); + assert.equal(waits(page).length, 2, "the page stands in its own shape first"); + + const tasks = tasksControl({ tasks: TASKS, listed: { tasks: TASKS, archived: [], ...HOLDING } }); + act(() => + root?.render( + onRegistry( + AtomRegistry.make(), + createElement(DesktopHome, { tasks, accountName: "Dean Stratakos" }), + ), + ), + ); + await settle(); + + assert.deepEqual(waits(page), []); + assert.equal(page.querySelector("h1")?.textContent, "Welcome back, Dean"); + assert.equal(rows(page, "home-inbox").length, 2); + assert.equal(rows(page, "home-recent").length, 6); +}); + +test("while what waits is read, Home's Inbox stands a few placeholder rows under its name, and no ring", () => { + const { page } = mount(); + + const section = page.querySelector('section[aria-labelledby="home-inbox"]'); + assert.ok(section, "Home's Inbox stands"); + assert.equal(section.querySelector("#home-inbox")?.textContent, "Inbox"); + assert.deepEqual(waits(section), ["Reading your inbox…"]); + assert.equal(section.querySelectorAll("li").length, 3, "fewer rows than a whole Inbox's five"); + assert.equal( + section.querySelector(".luke-spinner"), + null, + "no ring stands at the section's edge", + ); +}); + test("the home page greets the account, lists what needs it with each item's plan, and the plans saved latest with their repositories", async () => { const { page } = mount(); await settle(); diff --git a/apps/desktop/src/renderer/desktop/desktop-home.tsx b/apps/desktop/src/renderer/desktop/desktop-home.tsx index 201708eb8a..c7fa7cd377 100644 --- a/apps/desktop/src/renderer/desktop/desktop-home.tsx +++ b/apps/desktop/src/renderer/desktop/desktop-home.tsx @@ -7,6 +7,9 @@ import { FolderIcon, List, ListRow, + SKELETON_SHAPE, + Skeleton, + SkeletonRegion, TEXT_ELEMENT, TEXT_TONE, TEXT_VARIANT, @@ -21,8 +24,7 @@ import { ACTOR_SIZE, ActorMark, CreatorHeard } from "../actor-ref"; import { MemberDirectory, useMemberDirectory } from "../member-directory"; import { RepositoryPicture } from "../repository-picture"; import { Shortcut } from "../shortcut"; -import { SPINNER_SIZE, Spinner } from "../spinner"; -import { InboxItemRow } from "../tasks/inbox-list"; +import { InboxItemRow, InboxListSkeleton } from "../tasks/inbox-list"; import { holderName, INBOX_HOLDER, @@ -53,14 +55,42 @@ import { useRowKeys } from "./use-row-keys"; * ↑ and ↓ move through its rows, a launcher's way, from the window itself * as well as from a row, and Return opens the row reached, so a launch is a * key or two from a task; a key pressed anywhere else, the sidebar or a - * field, is left to it. Until the task list is read the page draws nothing, - * since what it says hangs on the list; a read refused says so under Recent, + * field, is left to it. Until the task list is read the page stands in its + * own shape: New task, which the welcome offers too, and blocks where the + * greeting, the sections' headings, and their rows will stand. It says no + * word that hangs on the list, so a new account's welcome replaces it + * without having claimed otherwise. A read refused says so under Recent, * with Retry, over whatever an earlier read listed. */ /** How many tasks Recent lists. */ const RECENT_COUNT = 6; +/** How many placeholder rows Home's Inbox stands while its items are read, fewer than a whole Inbox's. */ +const INBOX_SKELETON_ROWS = 3; + +/** What Recent's placeholder rows say to a reader while the task list is read. */ +const READING_TASKS = "Reading your tasks…"; + +/** How wide the greeting's block stands, as wide as "Welcome back" and a first name in the title's type. */ +const SKELETON_GREETING = "9em"; + +/** How wide each section heading's block stands, as wide as its word in the heading's type. */ +const SKELETON_HEADING = { INBOX: "3em", RECENT: "3.5em" } as const; + +/** How wide each of Recent's placeholder rows' names and repositories stand, one row for each task Recent lists, so the rows read as tasks of their own. */ +const SKELETON_RECENT = [ + { name: "46%", repository: "5em" }, + { name: "34%", repository: "6em" }, + { name: "52%", repository: "5em" }, + { name: "40%", repository: "7em" }, + { name: "30%", repository: "5em" }, + { name: "44%", repository: "6em" }, +] as const; + +/** How wide a placeholder row's time stands, as wide as a short "2h". */ +const SKELETON_TIME = "20px"; + /** What Recent says where the plan list could not be read, as the sidebar says it. */ const UNREAD_LINE = "Your tasks could not be read."; @@ -186,7 +216,7 @@ function HomeInbox({ tasks, now }: { tasks: TasksControl; now: number }): React. Some items could not be read. ) : empty && waiting.reading ? ( - + ) : empty ? (
{NEEDS_YOU.NONE} @@ -248,6 +278,71 @@ function Welcome({ tasks }: { tasks: TasksControl }): React.JSX.Element { ); } +/** A section heading's block, in the heading's own type, where its word will stand. */ +function HeadingSkeleton({ width }: { width: string }): React.JSX.Element { + return ( +
+ +
+ ); +} + +/** Recent's rows while the task list is read: blocks where each row's picture, name, repository, and time will stand. */ +function RecentSkeleton(): React.JSX.Element { + return ( + + + {SKELETON_RECENT.map(({ name, repository }) => ( + + + + } + title={} + meta={ + + + + + } + /> + ))} + + + ); +} + +/** + * The page while the task list is first read, in the shape of the page an + * account with tasks lands on: the greeting's block beside New task, then + * the Inbox's and Recent's heading blocks over rows of blocks, so the page + * lands where the blocks stood. + */ +function HomeSkeleton({ tasks }: { tasks: TasksControl }): React.JSX.Element { + return ( +
+
+
+ + + + +
+
+ + +
+
+ + +
+
+
+ ); +} + /** The home page, in the task's place while no task is open. */ export function DesktopHome({ tasks, @@ -256,11 +351,11 @@ export function DesktopHome({ tasks: TasksControl; /** The signed-in account's name, which greets it. */ accountName: string | undefined; -}): React.JSX.Element | null { +}): React.JSX.Element { const now = useMinute(); const [column, setColumn] = useState(null); useRowKeys(column, tasks.shown); - if (!tasks.listRead && !tasks.listFailed) return null; + if (!tasks.listRead && !tasks.listFailed) return ; // Only an account with no task in any workspace, and nothing an agent with // no task waits on, is new; a workspace of its own with none still says // what needs it elsewhere. diff --git a/apps/desktop/src/renderer/tasks/inbox-list.tsx b/apps/desktop/src/renderer/tasks/inbox-list.tsx index 122351ac78..894f4158fa 100644 --- a/apps/desktop/src/renderer/tasks/inbox-list.tsx +++ b/apps/desktop/src/renderer/tasks/inbox-list.tsx @@ -141,6 +141,9 @@ const SKELETON_ROWS = [ /** How wide a placeholder row's time stands, as wide as a short "2h". */ const SKELETON_TIME = "20px"; +/** How wide a placeholder row's context stands, as wide as a short plan's name in a row's caption type. */ +const SKELETON_CONTEXT = "7em"; + /** How wide a placeholder row's mark stands, an agent's tile at its inline size (`.actor-mark`). */ const SKELETON_MARK = "16px"; @@ -534,19 +537,39 @@ export function NeedsYouEmpty({ * block where its agent's tile, its title, its time, and the line under * them will stand, so the * items land where the blocks stood rather than a lone ring in the list's - * corner. The rows open nothing and the region says what is being read. + * corner. The rows open nothing and the region says what is being read. A + * list whose rows name what each item is about, as Home's do, asks for a + * block before each time where that name will stand, and a short list asks + * for fewer rows than the five a whole Inbox stands. */ -export function InboxListSkeleton(): React.JSX.Element { +export function InboxListSkeleton({ + rows = SKELETON_ROWS.length, + context = false, +}: { + /** How many placeholder rows stand, at most five. */ + rows?: number; + /** Whether each row holds a block for its context before its time, as an item row given a `context` draws it. */ + context?: boolean; +}): React.JSX.Element { return (
    - {SKELETON_ROWS.map(({ title, preview }) => ( + {SKELETON_ROWS.slice(0, rows).map(({ title, preview }) => ( } title={} description={} - meta={} + meta={ + context ? ( + + + + + ) : ( + + ) + } metaAt={LIST_ROW_META.TITLE} /> ))}