Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8fc12b4
test(factory): factory calls go to the connected environment
rawphp Oct 17, 2026
9284953
feat(factory): send every factory call on the environment's connection
rawphp Oct 17, 2026
c0b987a
feat(factory): add a touch size to Button and Input
rawphp Oct 17, 2026
83c6d09
test(factory): the Factory page at 375 px and 1280 px
rawphp Oct 17, 2026
760828b
feat(factory): lay the Factory page out for a phone
rawphp Oct 17, 2026
0722580
test(factory): the run page at 375 px and 1280 px
rawphp Oct 17, 2026
808bdd2
feat(factory): lay the run page out for a phone
rawphp Oct 17, 2026
d201d7d
test(factory): the settings page asks for touch-sized controls
rawphp Oct 17, 2026
c10a01a
feat(factory): lay the settings page out for a phone
rawphp Oct 17, 2026
3ceb4ac
docs(factory): explain remote and phone use
rawphp Oct 17, 2026
ac50d16
fix(factory): keep the repo row's lint suppression on its element
rawphp Oct 17, 2026
7b23a16
Merge remote-tracking branch 'origin/main' into t3/factory-issue-61
rawphp Oct 17, 2026
5ebd048
chore(factory): list mainAppLocation as a seam
rawphp Oct 17, 2026
461c45d
fix(factory): import the sidebar test from vite-plus/test
rawphp Oct 17, 2026
bb2c7b2
test(factory): cover the settings API against a stubbed fetch
rawphp Oct 17, 2026
455328e
fix(factory): report a non-JSON config answer as an error
rawphp Oct 17, 2026
2bfc4f7
test(factory): expect run cards to link the issue and pull request
rawphp Oct 17, 2026
43cdba3
fix(factory): link the issue and pull request on run cards
rawphp Oct 17, 2026
ddff2fd
test(factory): pin the 640 px breakpoint on both pages
rawphp Oct 17, 2026
4d85ded
test(factory): assert touch size on the error-path buttons
rawphp Oct 17, 2026
116f509
docs(factory): list mainAppLocation as the ninth seam
rawphp Oct 17, 2026
271756e
test(factory): restore the test window when each test ends
rawphp Oct 17, 2026
cf705e6
test(factory): pin the 375 px layout of attention items and the confi…
rawphp Oct 17, 2026
6264d37
fix(factory): keep attention messages and the config path readable at…
rawphp Oct 17, 2026
443e4f8
Merge remote-tracking branch 'origin/main' into t3/factory-issue-61
rawphp Oct 17, 2026
4180601
Merge remote-tracking branch 'origin/main' into t3/factory-issue-61-1
rawphp Oct 17, 2026
f05078a
fix(factory): one deadline per call, upstream's Button, links through…
rawphp Oct 17, 2026
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
3 changes: 2 additions & 1 deletion apps/web/src/components/ui/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type * as React from "react";
import { cn } from "~/lib/utils";

type InputProps = Omit<InputPrimitive.Props & React.RefAttributes<HTMLInputElement>, "size"> & {
size?: "sm" | "compact" | "default" | "lg" | number;
size?: "sm" | "compact" | "default" | "lg" | "touch" | number;
font?: "default" | "mono";
unstyled?: boolean;
nativeInput?: boolean;
Expand All @@ -25,6 +25,7 @@ function Input({
size === "compact" && "h-7 px-[calc(--spacing(2.5)-1px)] text-xs leading-7 sm:h-7 sm:leading-7",
size === "sm" && "h-7.5 px-[calc(--spacing(2.5)-1px)] leading-7.5 sm:h-6.5 sm:leading-6.5",
size === "lg" && "h-9.5 leading-9.5 sm:h-8.5 sm:leading-8.5",
size === "touch" && "h-11 leading-11 sm:h-7.5 sm:leading-7.5",
props.type === "search" &&
"[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none",
props.type === "number" &&
Expand Down
24 changes: 13 additions & 11 deletions apps/web/src/factory/AttentionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,22 @@ export function AttentionList({
<ul className="flex flex-col gap-2">
{items.map((item) => (
<li
className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border bg-card px-3 py-2 text-sm"
className="flex flex-col gap-1 rounded-lg border bg-card px-3 py-2 text-sm"
key={item.id}
>
<Badge variant="warning">{item.kind}</Badge>
{item.repo ? (
<span className="font-mono text-xs">
{item.repo}
{item.issue !== null ? `#${item.issue}` : null}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<Badge variant="warning">{item.kind}</Badge>
{item.repo ? (
<span className="min-w-0 break-all font-mono text-xs">
{item.repo}
{item.issue !== null ? `#${item.issue}` : null}
</span>
) : null}
<span className="ml-auto text-xs text-muted-foreground">
{formatAge(Date.parse(item.first_seen), nowMs)}
</span>
) : null}
<span className="min-w-0 flex-1">{item.message}</span>
<span className="text-xs text-muted-foreground">
{formatAge(Date.parse(item.first_seen), nowMs)}
</span>
</div>
<p className="w-full break-words">{item.message}</p>
</li>
))}
</ul>
Expand Down
175 changes: 174 additions & 1 deletion apps/web/src/factory/FactoryPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { createElement, act, type ReactNode } from "react";
import type { ReactTestInstance } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { __setFactoryApiForTests, FactoryApiError, type FactoryApi } from "./api";
import type { FactoryResource } from "./useFactoryData";
import { render } from "./testing";
import { render, renderAt } from "./testing";

const NOW = Date.parse("2026-10-17T12:00:00.000Z");

Expand Down Expand Up @@ -373,3 +374,175 @@ describe("FactoryPage", () => {
expect(page.text()).toContain("Missing permission orchestration:operate");
});
});

const ATTENTION = {
id: "stuck:20",
kind: "stuck",
repo: "original-solutions/t3code-factory",
issue: 20,
run: RUN_ID,
message: "run ended stuck: the gate failed",
first_seen: "2026-10-17T11:00:00.000Z",
};

describe.each([375, 1280])("FactoryPage at %i px", (width) => {
const narrow = width < 640;
const populate = () => {
state.status = resource({ data: statusData({ attention: [ATTENTION] }) });
state.runs = resource({
status: "error",
error: new Error("fetch failed"),
data: {
runs: [runRow(), runRow({ id: "acme__old__3__1", started_ts: "2026-10-16T12:00:00.000Z" })],
},
});
};

it(narrow ? "lists the runs as cards, not a table" : "lists the runs in a table", async () => {
populate();
const page = await renderAt(width, createElement(FactoryPage));
expect(page.all("table")).toHaveLength(narrow ? 0 : 1);
const cards = page.all("ul").filter((ul) => ul.props["data-layout"] === "cards");
expect(cards).toHaveLength(narrow ? 1 : 0);
await page.unmount();
});

it("puts the attention items above the runs", async () => {
populate();
const page = await renderAt(width, createElement(FactoryPage));
const text = page.text();
expect(text.indexOf("run ended stuck")).toBeGreaterThan(-1);
expect(text.indexOf("run ended stuck")).toBeLessThan(text.indexOf("launch-sending"));
expect(text.indexOf("Could not refresh")).toBeGreaterThan(text.indexOf("run ended stuck"));
expect(text.indexOf("Could not refresh")).toBeLessThan(text.indexOf("launch-sending"));
await page.unmount();
});

it("shows a control's answer in the body, below the attention items", async () => {
populate();
__setFactoryApiForTests(
api({
pauseClaims: async () => ({
paused: true as const,
already: false,
since: "2026-10-17T12:00:00.000Z",
message: "claims paused",
}),
}),
);
const page = await renderAt(width, createElement(FactoryPage));
await act(async () => page.button("Pause claims")!.props.onClick());
const text = page.text();
expect(text.indexOf("claims paused")).toBeGreaterThan(text.indexOf("run ended stuck"));
expect(text.indexOf("claims paused")).toBeLessThan(text.indexOf("launch-sending"));
await page.unmount();
});
});

describe("FactoryPage attention items at 375 px", () => {
it("puts the message on its own full-width line after the badge, repo and age", async () => {
state.status = resource({ data: statusData({ attention: [ATTENTION] }) });
state.runs = resource({ data: { runs: [runRow()] } });
const page = await renderAt(375, createElement(FactoryPage));
const section = page
.all("section")
.find((node) => node.props["aria-label"] === "Needs attention")!;
const item = section.findAllByType("li" as never)[0]!;
const message = item.findAll(
(node) => typeof node.type === "string" && node.children.includes(ATTENTION.message),
)[0]!;
expect(message.type).toBe("p");
expect(String(message.props.className)).toContain("w-full");
expect(String(message.props.className)).not.toContain("flex-1");
const textOf = (node: ReactTestInstance | string): string =>
typeof node === "string" ? node : node.children.map(textOf).join(" ");
const full = textOf(item);
const order = ["stuck", ATTENTION.repo, "1h", ATTENTION.message].map((part) =>
full.indexOf(part),
);
expect(order.every((index) => index >= 0)).toBe(true);
expect(order).toEqual([...order].sort((a, b) => a - b));
await page.unmount();
});
});

describe("FactoryPage run cards at 375 px", () => {
it("shows each run's repo, issue, state, step, age and pull request, and links to the run", async () => {
state.runs = resource({ data: { runs: [runRow()] } });
const page = await renderAt(375, createElement(FactoryPage));
const link = page
.all("a")
.find(
(a) => a.props["data-to"] === "/factory/$runId" && a.props["data-params"].includes(RUN_ID),
);
expect(link).toBeDefined();
const text = link!
.findAll(() => true)
.flatMap((n) => n.children.filter((c) => typeof c === "string"))
.join(" ");
for (const part of ["original-solutions/t3code-factory", "building", "launch-sending", "2h"]) {
expect(text).toContain(part);
}
// A card is one link: nothing inside it is another link.
expect(link!.findAllByType("a" as never)).toHaveLength(1);
await page.unmount();
});

it("links the issue and the pull request outside the card's own link", async () => {
state.runs = resource({ data: { runs: [runRow()] } });
const page = await renderAt(375, createElement(FactoryPage));
const hrefs = page
.all("a")
.filter((a) => typeof a.props.href === "string")
.map((a) => a.props.href);
expect(hrefs).toEqual([
"https://github.com/original-solutions/t3code-factory/issues/20",
PR_URL,
]);
const card = page.all("a").find((a) => a.props["data-to"] === "/factory/$runId")!;
expect(card.findAllByType("a" as never)).toHaveLength(1);
await page.unmount();
});

it("leaves a non-GitHub pull request URL as text on the card", async () => {
state.runs = resource({ data: { runs: [runRow({ pr_url: "https://example.com/pr/1" })] } });
const page = await renderAt(375, createElement(FactoryPage));
expect(page.all("a").map((a) => a.props.href)).not.toContain("https://example.com/pr/1");
expect(page.text()).toContain("https://example.com/pr/1");
await page.unmount();
});

it("keeps the newest first, the empty state and the Show all runs control", async () => {
state.runs = resource({
data: {
runs: [runRow({ id: "acme__old__3__1", started_ts: "2026-10-16T12:00:00.000Z" }), runRow()],
},
});
const page = await renderAt(375, createElement(FactoryPage));
const text = page.text();
expect(text.indexOf("original-solutions/t3code-factory")).toBeLessThan(
text.indexOf("acme/old"),
);
expect(page.button("Show all runs")).toBeDefined();
await page.unmount();
state.runs = resource({ data: { runs: [] } });
const empty = await renderAt(375, createElement(FactoryPage));
expect(empty.text()).toContain("No runs in the last week");
expect(empty.button("Show all runs")).toBeDefined();
await empty.unmount();
});
});

describe.each([
[639, true],
[640, false],
])("FactoryPage at the 640 px breakpoint, %i px", (width, narrow) => {
it(narrow ? "lists cards just below 640" : "lists the table from 640", async () => {
state.runs = resource({ data: { runs: [runRow()] } });
const page = await renderAt(width, createElement(FactoryPage));
expect(page.all("table")).toHaveLength(narrow ? 0 : 1);
const cards = page.all("ul").filter((ul) => ul.props["data-layout"] === "cards");
expect(cards).toHaveLength(narrow ? 1 : 0);
await page.unmount();
});
});
56 changes: 23 additions & 33 deletions apps/web/src/factory/FactoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export function FactoryPage() {
} else {
body = (
<>
<AttentionList items={report.attention} nowMs={nowMs} />
{stale ? (
<Alert variant="error">
<AlertDescription>Could not refresh: {messageOf(stale.error)}</AlertDescription>
Expand All @@ -69,7 +70,16 @@ export function FactoryPage() {
</AlertDescription>
</Alert>
) : null}
<AttentionList items={report.attention} nowMs={nowMs} />
{paused ? (
<p className="text-sm text-muted-foreground">
Claims paused since {formatAge(Date.parse(paused.since), nowMs)}
</p>
) : null}
{note ? (
<p className="break-words text-sm text-muted-foreground" role="status">
{note}
</p>
) : null}
<RunsTable
nowMs={nowMs}
onShowAll={() => setAllRuns(true)}
Expand All @@ -85,39 +95,19 @@ export function FactoryPage() {
<FactoryFrame
header={
<div className="flex min-w-0 flex-1 items-center gap-3">
<h1 className="text-sm font-medium">Factory</h1>
<h1 className="min-w-0 truncate text-sm font-medium">Factory</h1>
{report ? (
<div className="ml-auto flex items-center gap-3">
{note ? (
<span className="truncate text-xs text-muted-foreground" role="status">
{note}
</span>
) : null}
{paused ? (
<>
<span className="text-xs text-muted-foreground">
paused since {formatAge(Date.parse(paused.since), nowMs)}
</span>
<Button
disabled={busy}
onClick={() => void control(() => factoryApi.resumeClaims())}
size="sm"
variant="outline"
>
Resume claims
</Button>
</>
) : (
<Button
disabled={busy}
onClick={() => void control(() => factoryApi.pauseClaims())}
size="sm"
variant="outline"
>
Pause claims
</Button>
)}
</div>
<Button
className="ml-auto shrink-0"
disabled={busy}
onClick={() =>
void control(() => (paused ? factoryApi.resumeClaims() : factoryApi.pauseClaims()))
}
size="sm"
variant="outline"
>
{paused ? "Resume claims" : "Pause claims"}
</Button>
) : null}
</div>
}
Expand Down
43 changes: 42 additions & 1 deletion apps/web/src/factory/FactoryRunPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"

import { __setFactoryApiForTests, FactoryApiError, type FactoryApi } from "./api";
import type { FactoryResource } from "./useFactoryData";
import { render } from "./testing";
import { render, renderAt } from "./testing";

const state = vi.hoisted(() => ({ run: null as unknown, environmentId: "env-1" as string | null }));

Expand Down Expand Up @@ -223,3 +223,44 @@ describe("FactoryRunPage", () => {
expect((await mount()).button("Stop")!.props.disabled).toBe(true);
});
});

describe.each([375, 1280])("FactoryRunPage at %i px", (width) => {
const narrow = width < 640;
const mountAt = () => renderAt(width, createElement(FactoryRunPage, { runId: RUN_ID }));

it(narrow ? "lists the gate commands and ledger, not tables" : "keeps the tables", async () => {
const page = await mountAt();
expect(page.all("table")).toHaveLength(narrow ? 0 : 2);
const lists = page.all("ul").filter((ul) => ul.props["data-layout"] === "list");
expect(lists).toHaveLength(narrow ? 2 : 0);
const text = page.text();
for (const part of ["pnpm lint", "pnpm test", "412", "26311", "session_launched", "thread-9"]) {
expect(text).toContain(part);
}
await page.unmount();
});
});

describe("FactoryRunPage lists at 375 px", () => {
it("makes a list item of each gate command and each ledger entry", async () => {
const page = await renderAt(375, createElement(FactoryRunPage, { runId: RUN_ID }));
const items = page.all("li");
// 2 gate commands, then one item per ledger entry.
expect(items).toHaveLength(2 + BUILDING.length);
expect(items[0]!.findAll(() => true).length).toBeGreaterThan(1);
await page.unmount();
});
});

describe.each([
[639, true],
[640, false],
])("FactoryRunPage at the 640 px breakpoint, %i px", (width, narrow) => {
it(narrow ? "lists just below 640" : "keeps the tables from 640", async () => {
const page = await renderAt(width, createElement(FactoryRunPage, { runId: RUN_ID }));
expect(page.all("table")).toHaveLength(narrow ? 0 : 2);
const lists = page.all("ul").filter((ul) => ul.props["data-layout"] === "list");
expect(lists).toHaveLength(narrow ? 2 : 0);
await page.unmount();
});
});
Loading
Loading