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
48 changes: 47 additions & 1 deletion apps/web/tests/support/fake-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
* every client reads as unanswered, so a test states only what it reads.
* The session is the browser's cookie in a real tab: here it is whatever the
* test last set, read on every `get-session`, so "the cookie changed" is
* one call; a test that holds the session read answers it when it lets go.
* one call; a test that holds the session read answers it when it lets go. The task list is followed as
* the tab follows it with Realtime off: polled, the listed task's row inline.
*/

import type { LinkOpen } from "@luke/features/link-open";
import { LIVE_POLL, LIVE_POLL_URL } from "@luke/features/live-wire";
import type { WireValue } from "@luke/wire";
import { fakeHttpClientLayer } from "@luke/wire/testing";
import type { Layer } from "effect";
Expand All @@ -35,6 +37,44 @@ export const LISTED_TASK = {
repository: null,
} as const;

/** The rows each part of the task list delivers: the listed task, owned by the signed-in user, and no agent. */
const LIVE_ROWS: ReadonlyMap<string, readonly WireValue[]> = new Map<string, readonly WireValue[]>([
[
"plans",
[
{
...LISTED_TASK,
ownerId: "user-ada",
ownerName: "Ada Lovelace",
pinnedAt: null,
pinPosition: null,
archivedAt: null,
assigneeId: null,
assigneeName: null,
status: "todo",
priority: "none",
number: 1,
},
],
],
["agents", []],
]);

/** Where the task list's rows stand, which never moves in the fake: each part's digest, in the parts' name order. */
const LIVE_DIGEST = "1,1";

/** The task list as a polled read answers it: each part's rows inline at the poll address, or only where they stand where they have not moved. */
function polledTaskList(unchanged: boolean): WireValue {
const part = (name: string) => ({
capability: name,
queryFingerprint: name,
expiresAt: 4_000_000_000_000,
digest: "1",
...(unchanged ? undefined : { rows: JSON.stringify(LIVE_ROWS.get(name) ?? []) }),
});
return { url: LIVE_POLL_URL, queries: { plans: part("plans"), agents: part("agents") } };
}

/** Where the fake sends a browser to sign in to Notion. */
export const NOTION_SIGN_IN = "https://notion.example/oauth/authorize";

Expand Down Expand Up @@ -127,6 +167,12 @@ export function fakeService(options: FakeServiceOptions): FakeService {
const linkOpen = options.linkOpen === undefined ? undefined : { linkOpen: options.linkOpen };
return json({ preferences: {}, ...linkOpen });
}
if (pathname === "/api/live/tasks" && method === "GET") {
if (!signedIn) return json({ error: "unauthorized" }, 401);
// Realtime is off, so the list is answered polled, its rows inline; a poll past where they stand answers them unchanged.
const unchanged = new URL(url).searchParams.get(LIVE_POLL.AFTER) === LIVE_DIGEST;
return json(polledTaskList(unchanged));
}
if (pathname === "/api/plans" && method === "GET") {
return signedIn ? json({ plans: [LISTED_TASK] }) : json({ error: "unauthorized" }, 401);
}
Expand Down
5 changes: 4 additions & 1 deletion apps/web/tests/web-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ test("signed out, the panel draws its sign-in; a return after another tab signed
const root = document.createElement("div");
document.body.append(root);

app = await mountWebApp(root, { httpClient: service.layer, linkPlatform: LINK_PLATFORM.MAC });
app = await mountWebApp(root, {
httpClient: service.layer,
linkPlatform: LINK_PLATFORM.MAC,
});

assert.ok(app !== undefined, "the host stands");
await vi.waitFor(() =>
Expand Down
16 changes: 15 additions & 1 deletion packages/features/src/task-list-live.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { Schema as EffectSchema } from "effect";
import { codingAgentStatusAt, lineAwaitsTurn, type StatusTurn } from "./coding-agent-status.js";
import {
codingAgentStatusAt,
lineAwaitsTurn,
type StatusTurn,
statusClockEdge,
} from "./coding-agent-status.js";
import {
CODING_AGENT_ARCHIVER,
CODING_AGENT_BRIEF,
Expand Down Expand Up @@ -389,3 +394,12 @@ export function taskListOfLiveRows(rows: TaskListLiveRows, now: number): TaskLis
.map((agent) => tasklessAgentOf(agent, now)),
};
}

/** The next instant past `now` the clock alone moves the list: the first agent whose starting grace or awaiting line's grace ends; none where none waits on one. */
export function taskListGraceEdge(rows: TaskListLiveRows, now: number): number | undefined {
const edges = rows.agents.flatMap((row) => {
const edge = statusClockEdge(turnOf(row), standingOf(row, now));
return edge === undefined ? [] : [edge];
});
return edges.length === 0 ? undefined : Math.min(...edges);
}
Loading
Loading