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
70 changes: 69 additions & 1 deletion apps/desktop/src/renderer/desktop/person-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,13 +194,21 @@ 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[] = [];

beforeEach(() => {
inboxRefused = false;
automationsRefused = false;
inboxHeld = false;
automationsHeld = false;
membersHeld = false;
copied = [];
fakeAppPlatform({ ...QUIET_PANEL, ...copyingInto(copied) });
window.localStorage.clear();
Expand All @@ -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 });
}
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 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|Nothing of theirs/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);
});

Expand Down
126 changes: 108 additions & 18 deletions apps/desktop/src/renderer/desktop/person-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import {
Button,
ErrorState,
IconButton,
ListRow,
SKELETON_SHAPE,
Skeleton,
SkeletonRegion,
TEXT_ELEMENT,
TEXT_TONE,
TEXT_VARIANT,
Expand Down Expand Up @@ -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";
Expand All @@ -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) {
Expand Down Expand Up @@ -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 (
<div className="home-section">
<div className="person-section-head">
<Text as={TEXT_ELEMENT.SPAN} variant={TEXT_VARIANT.SECTION}>
<Skeleton width={SKELETON_SECTION} />
</Text>
</div>
{children}
</div>
);
}

/** 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 (
<SectionSkeleton>
<SkeletonRegion label={label}>
<ul className="sidebar-tasks">
{SKELETON_ROWS.map(({ name, context }) => (
<ListRow
key={name}
leading={<Skeleton shape={SKELETON_SHAPE.CIRCLE} width={SKELETON_MARK} />}
title={<Skeleton width={name} />}
meta={<Skeleton width={context} />}
/>
))}
</ul>
</SkeletonRegion>
</SectionSkeleton>
);
}

/** 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,
Expand Down Expand Up @@ -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 (
<SkeletonRegion label="Reading who they are…">
<header className="person-head">
<Skeleton shape={SKELETON_SHAPE.CIRCLE} width={SKELETON_FACE} />
<div className="person-head-copy">
<Text as={TEXT_ELEMENT.SPAN} variant={TEXT_VARIANT.TITLE}>
<Skeleton width={SKELETON_HEAD.NAME} />
</Text>
<Text variant={TEXT_VARIANT.CAPTION}>
<Skeleton width={SKELETON_HEAD.LINE} />
</Text>
</div>
</header>
</SkeletonRegion>
);
}

/** The page inside the workspace's member directory, which names the person. */
function PersonColumn({
tasks,
Expand Down Expand Up @@ -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 (
<section className="automations-view" aria-label="Profile">
<NotFound thing={NOT_FOUND_THING.PERSON} />
</section>
);
}
// 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 (
<section className="automations-view" aria-label={name}>
<section className="automations-view" aria-label={name ?? "Profile"}>
<header className="desktop-toolbar">
<div className="desktop-toolbar-heading">
<h1 className="desktop-toolbar-title">{name}</h1>
<h1 className="desktop-toolbar-title">
{name ?? <Skeleton width={SKELETON_HEAD.NAME} />}
</h1>
</div>
<PersonActions
tasks={tasks}
Expand All @@ -407,7 +484,16 @@ function PersonColumn({
</header>
<div className="home-scroll scroll-fade-top">
<div className="home-column person-column">
<PersonHead person={person} />
{unnamed ? <PersonHeadSkeleton /> : <PersonHead person={person} />}
Comment thread
luke-agent[bot] marked this conversation as resolved.
{tasks.listFailed ? (
<div className="home-note">
<ErrorState onRetry={tasks.onRetryList}>
{`${you ? "Your" : "Their"} tasks could not be read.`}
</ErrorState>
</div>
) : !tasks.listRead && personPageEmpty(page) ? (
<RowsSkeleton label={`Reading ${theirs} tasks…`} />
) : null}
{page.working.length === 0 ? null : (
<PersonSection title="Working now" count={page.working.length}>
{page.working.map((each) => (
Expand Down Expand Up @@ -481,7 +567,9 @@ function PersonColumn({
<ErrorState onRetry={read.onRetry}>Some Inbox items could not be read.</ErrorState>
</div>
) : waiting.length === 0 && read.reading ? (
<Spinner label="Loading inbox" size={SPINNER_SIZE.INLINE} />
<SectionSkeleton>
<InboxListSkeleton />
</SectionSkeleton>
) : null}
{theirAutomations.failed ? (
<div className="home-note">
Expand All @@ -490,10 +578,12 @@ function PersonColumn({
</ErrorState>
</div>
) : theirAutomations.reading ? (
<Spinner label="Loading automations" size={SPINNER_SIZE.INLINE} />
<RowsSkeleton label={`Reading ${theirs} automations…`} />
) : 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 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 ||
theirAutomations.reading ? null : personPageEmpty(page) &&
Expand Down
Loading