From 8fc12b4a3d38342c7f2a98c618cfd80d0689cac4 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 15:59:59 +1000 Subject: [PATCH 01/24] test(factory): factory calls go to the connected environment The api tests now stub the connection instead of the window origin. They expect a remote environment's calls to reach its HTTP base with its bearer token and no cookies, a cookie session to send credentials and no authorization header, a relay (DPoP) connection to fail without sending, and a connection that appears late to be awaited. All fail: api.ts has no connection seam yet and still resolves the page's own origin. Issue: #61 --- apps/web/src/factory/api.test.ts | 95 ++++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 6 deletions(-) diff --git a/apps/web/src/factory/api.test.ts b/apps/web/src/factory/api.test.ts index ab9ff5fba4477..358513b19bb02 100644 --- a/apps/web/src/factory/api.test.ts +++ b/apps/web/src/factory/api.test.ts @@ -1,9 +1,34 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vite-plus/test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; + +import type { PreparedConnection } from "@t3tools/client-runtime/connection"; + +import { + __setFactoryConnectionForTests, + __setFactoryTimeoutForTests, + FactoryApiError, + factoryApi, +} from "./api"; + +// Only the wire is stubbed: the calls go through `fetch` against the connected environment's HTTP +// base, never the window's own origin (kept at 127.0.0.1:3773 here to prove it). +const WINDOW_ORIGIN = "http://127.0.0.1:3773"; +const cookieConnection = (httpBaseUrl = WINDOW_ORIGIN) => + ({ httpBaseUrl, httpAuthorization: null }) as unknown as PreparedConnection; +const REMOTE_BASE = "https://personal-mac.tail1234.ts.net:3876"; +const bearerConnection = { + httpBaseUrl: `${REMOTE_BASE}/`, + httpAuthorization: { _tag: "Bearer", token: "tok-1" }, +} as unknown as PreparedConnection; -import { __setFactoryTimeoutForTests, FactoryApiError, factoryApi } from "./api"; - -// The real HTTP layer with only the wire stubbed: the page's calls go through `fetch` as the -// browser's would, against the window's own origin. const fetchMock = vi.fn(); beforeAll(() => { @@ -11,7 +36,7 @@ beforeAll(() => { Object.defineProperty(globalThis, "window", { configurable: true, value: { - location: { href: "http://127.0.0.1:3773/factory", origin: "http://127.0.0.1:3773" }, + location: { href: `${WINDOW_ORIGIN}/factory`, origin: WINDOW_ORIGIN }, }, }); }); @@ -21,9 +46,14 @@ afterAll(() => { vi.unstubAllGlobals(); }); +beforeEach(() => { + __setFactoryConnectionForTests(cookieConnection()); +}); + afterEach(() => { fetchMock.mockReset(); __setFactoryTimeoutForTests(); + __setFactoryConnectionForTests(); }); const answer = (status: number, body: string, contentType = "application/json") => @@ -113,3 +143,56 @@ describe("factoryApi over HTTP", () => { expect(await factoryApi.status()).toEqual({ state: "idle" }); }); }); + +describe("factoryApi on the connected environment", () => { + it("sends to a remote environment's base with its bearer token and no cookies", async () => { + __setFactoryConnectionForTests(bearerConnection); + answer(200, '{"state":"idle"}'); + await factoryApi.status(); + const [target, init] = fetchMock.mock.calls.at(-1)!; + expect(String(target)).toBe(`${REMOTE_BASE}/api/factory/status`); + expect(new URL(String(target)).origin).not.toBe(WINDOW_ORIGIN); + expect(sent().headers.get("authorization")).toBe("Bearer tok-1"); + expect(init?.credentials).toBe("omit"); + }); + + it("sends a cookie session's request with credentials and no authorization header", async () => { + __setFactoryConnectionForTests(cookieConnection("https://other-host.test:4000")); + answer(200, '{"state":"idle"}'); + await factoryApi.status(); + const [target, init] = fetchMock.mock.calls.at(-1)!; + expect(String(target)).toBe("https://other-host.test:4000/api/factory/status"); + expect(init?.credentials).toBe("include"); + expect(sent().headers.get("authorization")).toBeNull(); + }); + + it("fails without sending over a relay (DPoP) connection", async () => { + __setFactoryConnectionForTests({ + httpBaseUrl: REMOTE_BASE, + httpAuthorization: { _tag: "Dpop" }, + } as unknown as PreparedConnection); + const error = await failure(factoryApi.status()); + expect(error.status).toBe(0); + expect(error.message).toBe("The factory is not available over a relay connection yet."); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("waits for a connection that appears after the call started", async () => { + __setFactoryConnectionForTests(null); + answer(200, '{"state":"idle"}'); + const pending = factoryApi.status(); + await Promise.resolve(); + expect(fetchMock).not.toHaveBeenCalled(); + __setFactoryConnectionForTests(bearerConnection); + expect(await pending).toEqual({ state: "idle" }); + expect(String(fetchMock.mock.calls.at(-1)![0])).toBe(`${REMOTE_BASE}/api/factory/status`); + }); + + it("fails with no_connection when none appears within the time", async () => { + __setFactoryTimeoutForTests(20); + __setFactoryConnectionForTests(null); + const error = await failure(factoryApi.status()); + expect([error.status, error.code]).toEqual([0, "no_connection"]); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); From 92849533918fb12c048f81bdda6ffcb0dd7a7eb7 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:01:28 +1000 Subject: [PATCH 02/24] feat(factory): send every factory call on the environment's connection A new factory/http.ts sends a request on a prepared connection: its HTTP base, the session cookie or the bearer token, a timeout, and no attempt at a relay (DPoP) connection, which is reported as unavailable instead of sent unauthenticated. The factory API and the settings API both use it. The factory API reads the primary environment's connection when it calls and waits for it if startup has not produced one yet, so the pages work when opened from a phone paired to another origin. The Effect runtime on the page's own origin is gone. Issue: #61 --- apps/web/src/factory/api.ts | 95 ++++++++++++++---------- apps/web/src/factory/http.ts | 107 +++++++++++++++++++++++++++ apps/web/src/factory/settings/api.ts | 50 +++---------- 3 files changed, 173 insertions(+), 79 deletions(-) create mode 100644 apps/web/src/factory/http.ts diff --git a/apps/web/src/factory/api.ts b/apps/web/src/factory/api.ts index f400ff72fe18b..bd05e3a5fea35 100644 --- a/apps/web/src/factory/api.ts +++ b/apps/web/src/factory/api.ts @@ -1,10 +1,18 @@ -import * as Effect from "effect/Effect"; -import * as ManagedRuntime from "effect/ManagedRuntime"; -import { HttpClient, HttpClientRequest } from "effect/http"; +import type { PreparedConnection } from "@t3tools/client-runtime/connection"; +import * as Option from "effect/Option"; +import { Atom } from "effect/reactivity"; -import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer"; -import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary/target"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; +import { environmentSession } from "~/state/session"; import { textField } from "./format"; +import { + DEFAULT_TIMEOUT_MS, + FactoryHttpError, + factoryRequest, + failureMessage, + parseJson, +} from "./http"; /** The factory's HTTP API as the server serves it (`apps/server/src/factory/t3/http.ts`). */ @@ -109,14 +117,10 @@ export interface FactoryApi { readonly resumeClaims: () => Promise; } -let runtime: ManagedRuntime.ManagedRuntime | null = null; -const getRuntime = () => (runtime ??= ManagedRuntime.make(PrimaryEnvironmentHttpLayer.layer)); - /** * How long one call may wait for its answer. A server that hangs then fails the call (status 0, * code `timeout`) instead of pinning a page on loading and its poll on a request that never ends. */ -const DEFAULT_TIMEOUT_MS = 15_000; let timeoutMs = DEFAULT_TIMEOUT_MS; /** For tests: how long a call waits; no argument restores the default. */ @@ -124,12 +128,43 @@ export const __setFactoryTimeoutForTests = (ms?: number): void => { timeoutMs = ms ?? DEFAULT_TIMEOUT_MS; }; -const parseJson = (text: string): unknown => { - try { - return JSON.parse(text); - } catch { - return null; - } +const connectionOverrideAtom = Atom.make(undefined).pipe( + Atom.keepAlive, + Atom.withLabel("factory-connection-override"), +); + +/** For tests: the connection every call uses (null: none yet); no argument restores the real one. */ +export const __setFactoryConnectionForTests = (prepared?: PreparedConnection | null): void => { + appAtomRegistry.set(connectionOverrideAtom, prepared); +}; + +/** The primary environment's prepared connection: the server this page was opened from or paired to. */ +const primaryConnectionAtom = Atom.make((get): PreparedConnection | null => { + const override = get(connectionOverrideAtom); + if (override !== undefined) return override; + const environmentId = get(primaryEnvironmentIdAtom); + return environmentId === null + ? null + : Option.getOrNull(get(environmentSession.preparedConnectionValueAtom(environmentId))); +}).pipe(Atom.withLabel("factory-primary-connection")); + +/** The connection now, or the first one that appears within `ms` (a call can precede startup). */ +const connectionWithin = (ms: number): Promise => { + const current = appAtomRegistry.get(primaryConnectionAtom); + if (current !== null) return Promise.resolve(current); + return new Promise((resolve, reject) => { + let unsubscribe = () => {}; + const timer = setTimeout(() => { + unsubscribe(); + reject(new FactoryApiError(0, "no_connection", "The factory has no server connection yet.")); + }, ms); + unsubscribe = appAtomRegistry.subscribe(primaryConnectionAtom, (prepared) => { + if (prepared === null) return; + clearTimeout(timer); + unsubscribe(); + resolve(prepared); + }); + }); }; const call = async ( @@ -137,35 +172,15 @@ const call = async ( path: string, searchParams?: Record, ): Promise => { - const program = Effect.gen(function* () { - const client = yield* HttpClient.HttpClient; - const url = resolvePrimaryEnvironmentHttpUrl(path, searchParams); - const response = yield* client.execute( - method === "GET" ? HttpClientRequest.get(url) : HttpClientRequest.post(url), - ); - const text = yield* response.text; - return { status: response.status, text }; - }).pipe( - Effect.timeout(timeoutMs), - Effect.catchTags({ - TimeoutError: () => - Effect.fail( - new FactoryApiError( - 0, - "timeout", - `The factory did not answer within ${Math.round(timeoutMs / 1000)} seconds.`, - ), - ), - }), - ); - let result: { status: number; text: string }; + let result; try { - result = await getRuntime().runPromise(program); + const prepared = await connectionWithin(timeoutMs); + result = await factoryRequest(prepared, { method, path, searchParams, timeoutMs }); } catch (error) { if (error instanceof FactoryApiError) throw error; throw new FactoryApiError( 0, - null, + error instanceof FactoryHttpError && error.kind === "timeout" ? "timeout" : null, error instanceof Error && error.message ? error.message : "The request failed.", ); } @@ -174,7 +189,7 @@ const call = async ( throw new FactoryApiError( result.status, textField(body, "code"), - textField(body, "message") ?? `The factory answered ${result.status}.`, + failureMessage(result.status, body), ); } return body as T; diff --git a/apps/web/src/factory/http.ts b/apps/web/src/factory/http.ts new file mode 100644 index 0000000000000..cc7d5e5bf0ad1 --- /dev/null +++ b/apps/web/src/factory/http.ts @@ -0,0 +1,107 @@ +/** + * The one way the factory pages reach the server: a plain `fetch` on a connection's HTTP base that + * carries the connection's credential itself. The factory API is not part of the typed environment + * HTTP API (that is the point of the seam, see factory/docs/design.md), so T3's shared HTTP client + * does not cover it. Going through the connection, not the page's own origin, is what lets the + * pages work from a phone paired to a remote server: the session cookie when there is no bearer + * token, `Authorization: Bearer` when there is. A relay (DPoP) connection needs a signed proof per + * request, which the shared helper only issues for the typed API groups, so it is reported as + * unsupported rather than sent unauthenticated. + */ +import type { PreparedConnection } from "@t3tools/client-runtime/connection"; + +import { textField } from "./format"; + +/** How long one call may wait for its answer. */ +export const DEFAULT_TIMEOUT_MS = 15_000; + +/** A call that was not sent (`relay`) or got no answer in time (`timeout`). */ +export class FactoryHttpError extends Error { + readonly kind: "relay" | "timeout"; + constructor(kind: "relay" | "timeout", message: string) { + super(message); + this.name = "FactoryHttpError"; + this.kind = kind; + } +} + +export interface FactoryHttpRequest { + readonly method: "GET" | "POST" | "PUT"; + readonly path: string; + readonly searchParams?: Record | undefined; + /** Sent as JSON. */ + readonly body?: unknown; + readonly timeoutMs?: number; +} + +export interface FactoryHttpResponse { + readonly status: number; + readonly text: string; +} + +/** + * Sends one request on `prepared` and reads the whole answer within the timeout. Any status comes + * back as a response for the caller to map; a request that never gets one rejects. + */ +export const factoryRequest = async ( + prepared: PreparedConnection, + request: FactoryHttpRequest, +): Promise => { + const authorization = prepared.httpAuthorization; + if (authorization?._tag === "Dpop") { + throw new FactoryHttpError( + "relay", + "The factory is not available over a relay connection yet.", + ); + } + const url = new URL(`${prepared.httpBaseUrl.replace(/\/+$/, "")}${request.path}`); + for (const [key, value] of Object.entries(request.searchParams ?? {})) { + url.searchParams.set(key, value); + } + const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const controller = new AbortController(); + let timer: ReturnType | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject( + new FactoryHttpError( + "timeout", + `The factory did not answer within ${Math.round(timeoutMs / 1000)} seconds.`, + ), + ); + }, timeoutMs); + }); + const send = async (): Promise => { + const response = await fetch(url, { + method: request.method, + signal: controller.signal, + // A cookie session sends no header; a bearer token must not also send cookies. + credentials: authorization === null ? "include" : "omit", + headers: { + accept: "application/json", + ...(authorization === null ? {} : { authorization: `Bearer ${authorization.token}` }), + ...(request.body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }), + }); + return { status: response.status, text: await response.text() }; + }; + try { + return await Promise.race([send(), deadline]); + } finally { + clearTimeout(timer); + } +}; + +export const parseJson = (text: string): unknown => { + try { + return JSON.parse(text); + } catch { + return null; + } +}; + +/** The server's own `message` for a non-2xx answer, else the status. */ +export const failureMessage = (status: number, body: unknown): string => + textField(body, "message") ?? `The factory answered ${status}.`; diff --git a/apps/web/src/factory/settings/api.ts b/apps/web/src/factory/settings/api.ts index c308d5a902eb7..caa593c8894a7 100644 --- a/apps/web/src/factory/settings/api.ts +++ b/apps/web/src/factory/settings/api.ts @@ -1,13 +1,11 @@ /** - * The factory's machine config over HTTP: `GET` and `PUT /api/factory/config`. The factory API is - * not part of the typed environment HTTP API (that is the point of the seam, see - * factory/docs/design.md), so this is a plain `fetch` that carries the connection's credential - * itself: the session cookie when there is no bearer token, `Authorization: Bearer` when there is. - * A relay (DPoP) connection needs a signed proof per request, which the shared helper only issues - * for the typed API groups, so it is reported as unsupported rather than sent unauthenticated. + * The factory's machine config over HTTP: `GET` and `PUT /api/factory/config`, sent on the given + * connection by the factory's shared request helper (see `../http.ts`). */ import type { PreparedConnection } from "@t3tools/client-runtime/connection"; +import { factoryRequest, failureMessage, parseJson } from "../http"; + export interface ConfigError { readonly file: string; readonly field?: string; @@ -85,49 +83,23 @@ const request = async ( method: "GET" | "PUT", body?: MachineConfigValues, ): Promise => { - const authorization = prepared.httpAuthorization; - if (authorization?._tag === "Dpop") { - return { - kind: "error", - message: "The factory settings are not available over a relay connection yet.", - }; - } try { - const response = await fetch(`${prepared.httpBaseUrl.replace(/\/+$/, "")}${CONFIG_PATH}`, { - method, - // A cookie session sends no header; a bearer token must not also send cookies. - credentials: authorization === null ? "include" : "omit", - headers: { - accept: "application/json", - ...(authorization === null ? {} : { authorization: `Bearer ${authorization.token}` }), - ...(body === undefined ? {} : { "content-type": "application/json" }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); + const response = await factoryRequest(prepared, { method, path: CONFIG_PATH, body }); + const parsed = parseJson(response.text); if (response.status === 403) return { kind: "forbidden" }; if (response.status === 422) { - const parsed = (await response.json()) as { errors?: ReadonlyArray }; - return { kind: "invalid", errors: parsed.errors ?? [] }; + const errors = (parsed as { errors?: ReadonlyArray } | null)?.errors; + return { kind: "invalid", errors: errors ?? [] }; } - if (!response.ok) { - return { kind: "error", message: await failureMessage(response) }; + if (response.status < 200 || response.status >= 300) { + return { kind: "error", message: failureMessage(response.status, parsed) }; } - return { kind: "ok", report: (await response.json()) as MachineConfigReport }; + return { kind: "ok", report: parsed as MachineConfigReport }; } catch (cause) { return { kind: "error", message: cause instanceof Error ? cause.message : String(cause) }; } }; -const failureMessage = async (response: Response) => { - const fallback = `The factory answered ${response.status}.`; - try { - const parsed = (await response.json()) as { message?: unknown }; - return typeof parsed.message === "string" ? parsed.message : fallback; - } catch { - return fallback; - } -}; - export const fetchFactoryConfig = (prepared: PreparedConnection) => request(prepared, "GET"); export const saveFactoryConfig = (prepared: PreparedConnection, values: MachineConfigValues) => From c0b987ac4752927f3443803ad6a7622ea4ee07b1 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:01:57 +1000 Subject: [PATCH 03/24] feat(factory): add a touch size to Button and Input Both get a `touch` size that is 44 px tall below the sm breakpoint and the default control height from sm up, for the Factory pages on a phone. The two files join factory/SEAMS and the design doc's seam list, now eight. No behavior to test, so there is no red phase. Issue: #61 --- apps/web/src/components/ui/button.tsx | 1 + apps/web/src/components/ui/input.tsx | 3 ++- factory/SEAMS | 2 ++ factory/docs/design.md | 4 +++- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 81aa165c9aafa..77e799d42fb08 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -35,6 +35,7 @@ const buttonVariants = cva( sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7", "sm-multiline": "min-h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] py-[calc(--spacing(1)-1px)] whitespace-normal sm:min-h-7", + touch: "h-11 px-[calc(--spacing(3)-1px)] sm:h-8", xl: "h-11 px-[calc(--spacing(4)-1px)] text-lg sm:h-10 sm:text-base [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", xs: "h-7 gap-1 px-[calc(--spacing(2)-1px)] text-sm sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5", }, diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 21cd1a8f6a6bc..ed56294a2c048 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -6,7 +6,7 @@ import type * as React from "react"; import { cn } from "~/lib/utils"; type InputProps = Omit, "size"> & { - size?: "sm" | "compact" | "default" | "lg" | number; + size?: "sm" | "compact" | "default" | "lg" | "touch" | number; /** Monospace with tabular digits, for paths, commands, colors and numbers. */ font?: "default" | "mono"; unstyled?: boolean; @@ -26,6 +26,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", // Number fields are typed into; browser spin buttons crowd narrow fields and step by 1. diff --git a/factory/SEAMS b/factory/SEAMS index 9bd3dfd6249f8..f2b85e6c8b146 100644 --- a/factory/SEAMS +++ b/factory/SEAMS @@ -3,4 +3,6 @@ apps/server/src/server.ts apps/web/src/components/settings/settingsSearch.ts apps/web/src/components/settings/SettingsSidebarNav.tsx apps/web/src/components/sidebar/SidebarChrome.tsx +apps/web/src/components/ui/button.tsx +apps/web/src/components/ui/input.tsx apps/web/src/routeTree.gen.ts diff --git a/factory/docs/design.md b/factory/docs/design.md index e7e0fc68aaaf3..c97ec7d3af646 100644 --- a/factory/docs/design.md +++ b/factory/docs/design.md @@ -85,8 +85,10 @@ factory/ repo root: SEAMS, sync/build/install scripts 4. `apps/web/src/routeTree.gen.ts` (#28): generated, so a merge regenerates it instead of resolving it by hand. `apps/web/src/routes/settings.factory*` is factory-owned like `routes/factory*`. 5. `apps/web/src/components/settings/settingsSearch.ts` (#55): the `SettingsPath`, section label, search entry and scope map for the factory settings page. 6. `apps/web/src/components/settings/SettingsSidebarNav.tsx` (#55): the icon for that page. +7. `apps/web/src/components/ui/button.tsx` (#61): a `touch` size, 44 px tall below `sm` and the default height from `sm` up. +8. `apps/web/src/components/ui/input.tsx` (#61): the same `touch` size for inputs. -Six entries, all in `factory/SEAMS`. Nothing in `ws.ts`, `rpc.ts`, `packages/contracts` or `Migrations.ts`. Those are the files upstream churns most, and adding an RPC would touch five of them. The factory API is a separate HTTP route layer instead. +Eight entries, all in `factory/SEAMS`. Nothing in `ws.ts`, `rpc.ts`, `packages/contracts` or `Migrations.ts`. Those are the files upstream churns most, and adding an RPC would touch five of them. The factory API is a separate HTTP route layer instead. ### Boundary rules, checked by a test From 83c6d09657b7c824085280ffd41c2bba05ac3f29 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:03:18 +1000 Subject: [PATCH 04/24] test(factory): the Factory page at 375 px and 1280 px testing.ts gains renderAt(width, element), which gives the tree a window whose matchMedia answers for that width; render is renderAt(1280). The page tests expect run cards (one link each, no table) at 375 and the table at 1280, the attention items above the stale notice and the runs at both widths, a control's answer in the body under the attention items, and touch-sized buttons. All of these fail: the page still renders a table at every width, puts the notice above the attention items, shows the answer in the header and uses sm buttons. Issue: #61 --- apps/web/src/factory/FactoryPage.test.tsx | 126 +++++++++++++++++++++- apps/web/src/factory/testing.ts | 64 ++++++++++- 2 files changed, 186 insertions(+), 4 deletions(-) diff --git a/apps/web/src/factory/FactoryPage.test.tsx b/apps/web/src/factory/FactoryPage.test.tsx index 9062c55debc33..ac7ef892ded86 100644 --- a/apps/web/src/factory/FactoryPage.test.tsx +++ b/apps/web/src/factory/FactoryPage.test.tsx @@ -3,7 +3,8 @@ 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 { Button } from "~/components/ui/button"; +import { render, renderAt } from "./testing"; const NOW = Date.parse("2026-10-17T12:00:00.000Z"); @@ -373,3 +374,126 @@ 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(); + }); + + it("asks for touch-sized buttons", async () => { + populate(); + const page = await renderAt(width, createElement(FactoryPage)); + const buttons = page.renderer.root.findAllByType(Button); + expect(buttons.map((b) => b.props.size)).toEqual(buttons.map(() => "touch")); + expect(buttons.length).toBeGreaterThanOrEqual(3); + 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", + "#20", + "building", + "launch-sending", + "2h", + "#30", + ]) { + 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("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(); + }); +}); diff --git a/apps/web/src/factory/testing.ts b/apps/web/src/factory/testing.ts index 00c41d9a4227e..fb407f5253697 100644 --- a/apps/web/src/factory/testing.ts +++ b/apps/web/src/factory/testing.ts @@ -18,8 +18,63 @@ const flatten = ( return flatten((node as ReactTestRendererJSON).children); }; -/** Renders `element` and returns the renderer with helpers to read it. */ -export async function render(element: ReactElement) { +/** Whether one media query, `(min-width: Npx)` and `(max-width: Npx)` joined by `and`, holds. */ +const matchesWidth = (query: string, widthPx: number): boolean => + query.split(/\s+and\s+/).every((part) => { + const match = /^\(\s*(min|max)-width:\s*(\d+(?:\.\d+)?)px\s*\)$/.exec(part.trim()); + if (match === null) return false; + const limit = Number(match[2]); + return match[1] === "min" ? widthPx >= limit : widthPx <= limit; + }); + +let restoreViewport: (() => void) | null = null; + +/** + * Gives the code under test a `window` whose `matchMedia` answers for a viewport `widthPx` wide, + * until `restore` (or the next `viewport`, which restores first). Nothing else of a window exists. + */ +export function viewport(widthPx: number): () => void { + restoreViewport?.(); + const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window"); + const previous = (globalThis as { window?: unknown }).window; + Object.defineProperty(globalThis, "window", { + configurable: true, + writable: true, + value: { + matchMedia: (query: string) => ({ + matches: matchesWidth(query, widthPx), + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + }), + }, + }); + const restore = () => { + if (restoreViewport !== restore) return; + restoreViewport = null; + if (hadWindow) { + Object.defineProperty(globalThis, "window", { + configurable: true, + writable: true, + value: previous, + }); + } else { + Reflect.deleteProperty(globalThis, "window"); + } + }; + restoreViewport = restore; + return restore; +} + +/** Renders `element` at 1280 px wide. */ +export const render = (element: ReactElement) => renderAt(1280, element); + +/** + * Renders `element` in a viewport `widthPx` wide and returns the renderer with helpers to read it. + * The viewport stays until `unmount` or the next render, since a later update still asks it. + */ +export async function renderAt(widthPx: number, element: ReactElement) { + const restore = viewport(widthPx); let renderer!: ReactTestRenderer; await act(async () => { renderer = create(element); @@ -36,6 +91,9 @@ export async function render(element: ReactElement) { renderer.root .findAllByType("button" as never) .find((b) => flatten(b.children as never) === label), - unmount: () => act(() => renderer.unmount()), + unmount: async () => { + await act(() => renderer.unmount()); + restore(); + }, }; } From 760828bc2e14bf53b1454c15ef66e273c7f46d63 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:03:57 +1000 Subject: [PATCH 05/24] feat(factory): lay the Factory page out for a phone Below 640 px the runs are stacked cards, each one link to the run with its repo, issue, state, step, age and pull request; the table stays from 640 up. The body now reads attention items, notices, claim status and the answer to the last control, then the runs, so attention is at the top on a narrow screen. The header holds only the title and the pause or resume button. Buttons are touch-sized and long messages can break. Issue: #61 --- apps/web/src/factory/AttentionList.tsx | 2 +- apps/web/src/factory/FactoryNotices.tsx | 2 +- apps/web/src/factory/FactoryPage.tsx | 58 +++++++++------------ apps/web/src/factory/RunsTable.tsx | 67 +++++++++++++++++++++---- 4 files changed, 84 insertions(+), 45 deletions(-) diff --git a/apps/web/src/factory/AttentionList.tsx b/apps/web/src/factory/AttentionList.tsx index fc8a0283ce361..1f145c36411bc 100644 --- a/apps/web/src/factory/AttentionList.tsx +++ b/apps/web/src/factory/AttentionList.tsx @@ -28,7 +28,7 @@ export function AttentionList({ {item.issue !== null ? `#${item.issue}` : null} ) : null} - {item.message} + {item.message} {formatAge(Date.parse(item.first_seen), nowMs)} diff --git a/apps/web/src/factory/FactoryNotices.tsx b/apps/web/src/factory/FactoryNotices.tsx index b6aabe383011f..a148ab41ccfdb 100644 --- a/apps/web/src/factory/FactoryNotices.tsx +++ b/apps/web/src/factory/FactoryNotices.tsx @@ -31,7 +31,7 @@ export function FactoryError({ {message} {onRetry ? ( - ) : null} diff --git a/apps/web/src/factory/FactoryPage.tsx b/apps/web/src/factory/FactoryPage.tsx index 981e1f6f257bb..d6b0e56d5b755 100644 --- a/apps/web/src/factory/FactoryPage.tsx +++ b/apps/web/src/factory/FactoryPage.tsx @@ -52,11 +52,12 @@ export function FactoryPage() { } else { body = ( <> + {stale ? ( Could not refresh: {messageOf(stale.error)} - @@ -69,7 +70,16 @@ export function FactoryPage() { ) : null} - + {paused ? ( +

+ Claims paused since {formatAge(Date.parse(paused.since), nowMs)} +

+ ) : null} + {note ? ( +

+ {note} +

+ ) : null} setAllRuns(true)} @@ -85,39 +95,19 @@ export function FactoryPage() { -

Factory

+

Factory

{report ? ( -
- {note ? ( - - {note} - - ) : null} - {paused ? ( - <> - - paused since {formatAge(Date.parse(paused.since), nowMs)} - - - - ) : ( - - )} -
+ ) : null} } diff --git a/apps/web/src/factory/RunsTable.tsx b/apps/web/src/factory/RunsTable.tsx index c670a809573a3..83a2a271995e7 100644 --- a/apps/web/src/factory/RunsTable.tsx +++ b/apps/web/src/factory/RunsTable.tsx @@ -1,6 +1,7 @@ import { Link } from "@tanstack/react-router"; import { useMemo } from "react"; +import { useMediaQuery } from "~/hooks/useMediaQuery"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { Empty, EmptyHeader, EmptyTitle } from "~/components/ui/empty"; @@ -33,6 +34,47 @@ const prLabel = (url: string) => { return match ? `#${match[1]}` : "PR"; }; +const stateBadge = (run: FactoryRunRow) => + run.unreadable ? ( + unreadable + ) : ( + {labelForState(run.state)} + ); + +/** One run for a phone: a single link to the run, so the whole card is the tap target. */ +function RunCard({ run, nowMs }: { run: FactoryRunRow; nowMs: number }) { + const parsed = parseRunId(run.id); + const pr = run.pr_url + ? run.pr_url.startsWith("https://github.com/") + ? prLabel(run.pr_url) + : run.pr_url + : null; + return ( +
  • + + + + {parsed ? `${parsed.owner}/${parsed.repo}` : run.id} + + {stateBadge(run)} + + + {parsed ? ( + {/^[0-9]+$/.test(parsed.issue) ? `#${parsed.issue}` : parsed.issue} + ) : null} + {run.step ?? run.last_event ?? "—"} + {formatAge(startedMs(run), nowMs)} + {pr ? {pr} : null} + + +
  • + ); +} + /** * The runs the server listed: by default every unfinished run and the finished ones of the last * week (`partial`), kept fresh by the poll, or every run the factory ever made, read once. @@ -50,15 +92,16 @@ export function RunsTable({ onShowAll: () => void; onShowRecent: () => void; }) { + const cards = useMediaQuery("max-sm"); const sorted = useMemo(() => [...runs].sort(newestFirst), [runs]); const showAll = partial ? ( - ) : (
    Every run, read once. -
    @@ -73,6 +116,18 @@ export function RunsTable({ ); } + if (cards) { + return ( + <> +
      + {sorted.map((run) => ( + + ))} +
    + {showAll} + + ); + } return ( <> @@ -111,13 +166,7 @@ export function RunsTable({ "—" )} - - {run.unreadable ? ( - unreadable - ) : ( - {labelForState(run.state)} - )} - + {stateBadge(run)}{run.step ?? run.last_event ?? "—"}{formatAge(startedMs(run), nowMs)} From 07225801581f530d5edf0018bd0bd3d0ab61c95a Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:04:09 +1000 Subject: [PATCH 06/24] test(factory): the run page at 375 px and 1280 px At 375 the gate commands and the ledger are lists with one item per command and per entry, not tables; at 1280 both stay tables. The Stop button asks for the touch size at both widths. All of these fail: the page renders tables at every width and Stop is an sm button. Issue: #61 --- apps/web/src/factory/FactoryRunPage.test.tsx | 39 +++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/web/src/factory/FactoryRunPage.test.tsx b/apps/web/src/factory/FactoryRunPage.test.tsx index a3f17d5f8df77..1b265bdec3e4e 100644 --- a/apps/web/src/factory/FactoryRunPage.test.tsx +++ b/apps/web/src/factory/FactoryRunPage.test.tsx @@ -3,7 +3,8 @@ 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 { Button } from "~/components/ui/button"; +import { render, renderAt } from "./testing"; const state = vi.hoisted(() => ({ run: null as unknown, environmentId: "env-1" as string | null })); @@ -223,3 +224,39 @@ 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(); + }); + + it("asks for a touch-sized Stop button", async () => { + const page = await mountAt(); + const buttons = page.renderer.root.findAllByType(Button); + expect(buttons.length).toBeGreaterThanOrEqual(1); + expect(buttons.map((b) => b.props.size)).toEqual(buttons.map(() => "touch")); + 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(); + }); +}); From 808bdd27fe730dc9c1a17972569b65162a5a4a72 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:04:24 +1000 Subject: [PATCH 07/24] feat(factory): lay the run page out for a phone Below 640 px the gate commands and the ledger are stacked lists, one item per command and per entry, instead of tables; the tables stay from 640 up. The Stop button is touch-sized, and the Back and Thread links are at least 44 px tall. Step and notes can break instead of forcing the page wider. Issue: #61 --- apps/web/src/factory/FactoryRunPage.tsx | 53 ++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/apps/web/src/factory/FactoryRunPage.tsx b/apps/web/src/factory/FactoryRunPage.tsx index 80f7da28a5552..3bb6cfda8ef3e 100644 --- a/apps/web/src/factory/FactoryRunPage.tsx +++ b/apps/web/src/factory/FactoryRunPage.tsx @@ -2,6 +2,7 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { Link } from "@tanstack/react-router"; import { useState } from "react"; +import { useMediaQuery } from "~/hooks/useMediaQuery"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { @@ -41,13 +42,32 @@ function keyed(items: ReadonlyArray, base: (item: T) => string): Array<[st } function GateOutput({ entries }: { entries: ReadonlyArray }) { + const stacked = useMediaQuery("max-sm"); const gate = lastGateOutput(entries); const empty = gate.commands.length === 0 && gate.message === null && gate.stuckReason === null; return (

    Last gate output

    {empty ?

    No gate has run

    : null} - {gate.commands.length > 0 ? ( + {gate.commands.length > 0 && stacked ? ( +
      + {keyed(gate.commands, (command) => command.command).map(([key, command]) => ( +
    • + {command.command} + + + {command.exit_code} + + {command.duration_ms} ms + +
    • + ))} +
    + ) : null} + {gate.commands.length > 0 && !stacked ? (
    @@ -95,11 +115,29 @@ function Ledger({ truncated: boolean; skipped: number; }) { + const stacked = useMediaQuery("max-sm"); return (

    Ledger

    {entries.length === 0 ? (

    No ledger entries

    + ) : stacked ? ( +
      + {keyed(entries, (entry) => `${entry.ts} ${entry.event}`).map(([key, entry]) => ( +
    • + + {entry.event} + {entry.ts} + + + {JSON.stringify(entry.data)} + +
    • + ))} +
    ) : (
    @@ -181,10 +219,10 @@ export function FactoryRunPage({ runId }: { runId: string }) { <>
    {labelForState(state)} - Step: {step ?? "—"} + Step: {step ?? "—"} {threadId ? ( @@ -197,14 +235,14 @@ export function FactoryRunPage({ runId }: { runId: string }) { className="ml-auto" disabled={!canStop || busy} onClick={() => void stop()} - size="sm" + size="touch" variant="destructive-outline" > Stop
    {note ? ( -

    +

    {note}

    ) : null} @@ -218,7 +256,10 @@ export function FactoryRunPage({ runId }: { runId: string }) { - + Back

    {runId}

    From d201d7d8552db101f5ec3989b8186761b0f31696 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:04:32 +1000 Subject: [PATCH 08/24] test(factory): the settings page asks for touch-sized controls At 375 and 1280 px every input and every button on the populated settings form must carry the touch size, with Save and the repo buttons present. Both fail: the form uses sm inputs and sm and xs buttons. Issue: #61 --- .../settings/FactorySettingsPanel.test.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx b/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx index 07956a5a0b5d3..99c2a48cdb61f 100644 --- a/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx +++ b/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx @@ -8,6 +8,7 @@ import type { ReactNode } from "react"; import { act, create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { viewport } from "../testing"; import type { FactoryConfigResult, MachineConfigReport } from "./api"; const mocks = vi.hoisted(() => ({ @@ -381,4 +382,27 @@ describe("FactorySettingsPanel", () => { expect(button(root, "Save").props.disabled).toBe(true); expect(textOf(root)).toContain("read-only"); }); + describe.each([375, 1280])("at %i px", (width) => { + it("asks for touch-sized inputs and buttons, with Save present", async () => { + const restore = viewport(width); + try { + mocks.fetchFactoryConfig.mockResolvedValue(ok(populated)); + const root = await open(); + const inputs = root.findAll((node) => node.type === "input"); + // Six machine fields, then a repo and a path for each of the two repos. + expect(inputs).toHaveLength(10); + expect(inputs.map((node) => node.props.size)).toEqual(inputs.map(() => "touch")); + const buttons = root.findAll((node) => node.type === "button"); + expect(buttons.map((node) => textOf(node).trim())).toEqual([ + "Add repo", + "Remove", + "Remove", + "Save", + ]); + expect(buttons.map((node) => node.props.size)).toEqual(buttons.map(() => "touch")); + } finally { + restore(); + } + }); + }); }); From c10a01ac1e53de3dc8983d6224cd7187e3a7b4a9 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:04:56 +1000 Subject: [PATCH 09/24] feat(factory): lay the settings page out for a phone Every input and button on the machine config form is touch-sized, a repo row stacks its two inputs and Remove below 640 px, and the GitHub link of a watched repo is at least 44 px tall. The test's SettingsSection stand-in now renders headerAction so the Add repo button is checked too. Issue: #61 --- .../settings/FactorySettingsPanel.test.tsx | 10 ++++-- .../factory/settings/FactorySettingsPanel.tsx | 36 +++++++++++-------- .../web/src/factory/settings/WatchedRepos.tsx | 2 +- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx b/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx index 99c2a48cdb61f..9a84fa0a0cdd0 100644 --- a/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx +++ b/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx @@ -40,8 +40,14 @@ vi.mock("../../components/settings/settingsLayout", async () => { return { SettingsPageContainer: (props: { children: ReactNode }) => createElement("div", null, props.children), - SettingsSection: (props: { title: string; children: ReactNode }) => - createElement("section", null, createElement("h2", null, props.title), props.children), + SettingsSection: (props: { title: string; headerAction?: ReactNode; children: ReactNode }) => + createElement( + "section", + null, + createElement("h2", null, props.title), + props.headerAction, + props.children, + ), SettingsRow: (props: { title: string; description?: ReactNode; diff --git a/apps/web/src/factory/settings/FactorySettingsPanel.tsx b/apps/web/src/factory/settings/FactorySettingsPanel.tsx index 52f913bdf74bc..f95e56ef4990d 100644 --- a/apps/web/src/factory/settings/FactorySettingsPanel.tsx +++ b/apps/web/src/factory/settings/FactorySettingsPanel.tsx @@ -226,11 +226,11 @@ export function FactorySettingsPanel() { if (load.status === "error") { return ( -
    +

    {load.message}

    -
    @@ -286,7 +286,7 @@ export function FactorySettingsPanel() { control={ - + } /> @@ -365,7 +370,7 @@ export function FactorySettingsPanel() { title="Repos" headerAction={ {saveStatus === "saved" ? ( diff --git a/apps/web/src/factory/settings/WatchedRepos.tsx b/apps/web/src/factory/settings/WatchedRepos.tsx index e94820110e2c5..32078d297bd39 100644 --- a/apps/web/src/factory/settings/WatchedRepos.tsx +++ b/apps/web/src/factory/settings/WatchedRepos.tsx @@ -56,7 +56,7 @@ export function WatchedRepos({ repos }: { readonly repos: ReadonlyArray View .dw/config.yml on GitHub From 3ceb4ace37791d1c4cbb2c9d8105847afcd72ce7 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:05:25 +1000 Subject: [PATCH 10/24] docs(factory): explain remote and phone use factory/README.md gets a Remote section: pair with the fork's binary and base dir over Tailscale HTTPS, open /factory on the phone, add it to the home screen, and that a relay connection does not reach the pages yet. The design entry for #61 now says what was built. Issue: #61 --- factory/README.md | 18 ++++++++++++++++++ factory/docs/design.md | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/factory/README.md b/factory/README.md index 65d7c56c23673..38646dff25be5 100644 --- a/factory/README.md +++ b/factory/README.md @@ -67,6 +67,24 @@ It points `current` at `previous`, restarts the job, removes `previous` and runs [docs/handover.md](docs/handover.md) is the runbook for switching this repo from the Rust factory to the fork's factory, and for handing it back and moving it back. +## Remote + +The Factory pages work on a phone. They run in the fork's own web, so open the fork server from the phone and sign in like any T3 browser. The T3 mobile app from the App Store is upstream's build and has no Factory screen. + +1. Join the Mac and the phone to the same tailnet. +2. On the Mac, pair with the fork's binary and base dir (the LaunchAgent already serves on port 3876): + + ```sh + ~/.local/share/t3fac/current/t3 pair --base-dir ~/.config/t3fac --tailscale + ``` + + It maps the server to Tailscale HTTPS and prints a pairing link such as `https://..ts.net/pair#token=...`. Treat the link as a password. + +3. Open the link in the phone's browser, which signs it in. Then open `/factory` on that same origin. The machine config is at `/settings/factory`. +4. Add it to the home screen: in iOS Safari, Share, then Add to Home Screen; in Android Chrome, the menu, then Add to home screen. + +The pages use the paired session, so there is no factory token to set. Pause, resume and stop need the `orchestration:operate` scope that a normal pairing carries. A connection through T3 Connect (the relay) does not reach the Factory pages yet; pair directly as above. + ## Stopping a run by hand ```sh diff --git a/factory/docs/design.md b/factory/docs/design.md index c97ec7d3af646..d2d172615db45 100644 --- a/factory/docs/design.md +++ b/factory/docs/design.md @@ -128,7 +128,7 @@ Persistence: JSONL files under `/factory/`, the same layout as `~/.local/s - **Install health check, auto-rollback and the hand-back runbook (#59).** After restarting the job, `install.sh` waits up to `T3FAC_HEALTH_WAIT` seconds (default 90) for the port to accept connections and for a `factory started` or `no factory config` log line written after the restart, and otherwise runs `rollback.sh` and exits 1. `factory/README.md` "Recovery" is the runbook for rolling back by hand, and `factory/docs/handover.md` is the one for handing this repo back to the Rust factory and moving it back. - **A separate log-rotation LaunchAgent (#60).** `io.github.rawphp.t3fac.logrotate` runs `rotate-log.sh` every 600 seconds and copytruncates `~/Library/Logs/t3fac/t3fac.log`, because a crash-looping server writes the most log and is the one that cannot rotate it. `install.sh` and `rollback.sh` copy the script from `factory/scripts/` to `~/.local/share/t3fac/` and reload the job before restarting the server. - **Factory CI's `Lint` job, with the gate unchanged (#56).** `factory-ci.yml` has a `Lint` job beside `Check` that runs `vp check`, the command upstream's own Lint job runs. `check.sh`, and so the gate, do not run it. -- **Remote and phone use through the fork's own served web, with no native mobile screen (#61, not built yet).** The Factory pages will be reached over the tailnet from the fork server's web, paired like any T3 browser, and work at phone widths. The T3 mobile app on phones is upstream's App Store build and never contains fork code, so there is no native factory screen. +- **Remote and phone use through the fork's own served web, with no native mobile screen (#61).** The Factory pages are reached over the tailnet from the fork server's web, paired like any T3 browser. Below 640 px the runs, the gate commands and the ledger are stacked cards and lists, attention items come first, and every control is 44 px tall through a `touch` size on `Button` and `Input` (the two seams 7 and 8). Every factory call, including the settings page's, goes through `factory/http.ts` on the primary environment's prepared connection: its HTTP base, the session cookie or the bearer token, never the page's own origin. A relay (DPoP) connection is reported as unavailable because the factory routes are not in the typed API groups that T3 signs proofs for. `factory/README.md` "Remote" has the pairing steps. The T3 mobile app on phones is upstream's App Store build and never contains fork code, so there is no native factory screen. - **Factory HTTP API (#27, #28, #86).** Seven routes in `t3/http.ts`: `GET /api/factory/status`, `GET /api/factory/runs` and `GET /api/factory/runs/:id` need `orchestration:read`; `POST /api/factory/runs/:id/stop`, `POST /api/factory/claims/pause` and `resume` need `orchestration:operate`; `POST /api/factory/restart` needs `environment:maintain`, the scope T3 gives its own process control (`server.signalProcess`, `server.updateServer`), so an MCP client, whose tokens carry only read and operate, cannot end the server. The status is softfac's `status --json` report with the fork's open attention items added, and without the keys the fork has nothing for (`commit`, `self_update_repo`, `disk`, `claims_paused`, `t3_env_holds`, `t3_logs`). `runs` and `runs/:id` are `runs list` and `runs show`. `runs` lists every unfinished run and the finished runs of the last seven days, or every run with `?all=1` (#28), and reads a finished one only at the ends of its file: the first line, and back from the end to its last state change and pull request. A finished file older than the window is not read at all. Only unfinished runs are read whole. The fork accepts softfac's `is_file` filter, so a directory or symlink named like a run file is not a run and holds no slot. A stop leaves a stop request for the next pass, as `stop-run.sh` does, and wakes the loop. The route never writes the ledger: the pass takes the request when the gate does not have the run, then archives the thread and removes the worktree (#20). Pause and resume write and remove `claims-paused`. A restart answers 202 first, then logs `factory restart requested` and ends the process through an injected `ServerExit` (`t3/exit.ts`): SIGTERM to itself, which `runMain` turns into the normal shutdown, with the interrupt's exit code 130 made 0 (a failed teardown keeps its code) and `process.exit(0)` after 10 seconds if the shutdown hangs; the LaunchAgent starts the server again, and a dev server simply exits. A second request while one is pending answers 409 `restart_pending`. Open question 1 (do `layerMakeRoutes` routes get T3's auth) is answered by Decided 1 (#57): these routes use the same `withScope`, so an unauthenticated call gets 401. ## Scope of the experiment (v1) From ac50d16da3a7d9720026fbbaf7ca3e32e23e1444 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:06:15 +1000 Subject: [PATCH 11/24] fix(factory): keep the repo row's lint suppression on its element The longer row classes made the formatter split the div, which moved the key off the line the no-array-index-key suppression covers and left it unused. The classes are a constant, so the div is one line again. Issue: #61 --- apps/web/src/factory/settings/FactorySettingsPanel.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/factory/settings/FactorySettingsPanel.tsx b/apps/web/src/factory/settings/FactorySettingsPanel.tsx index f95e56ef4990d..c18c2787af327 100644 --- a/apps/web/src/factory/settings/FactorySettingsPanel.tsx +++ b/apps/web/src/factory/settings/FactorySettingsPanel.tsx @@ -112,6 +112,10 @@ function Field({ ); } +/** A repo's inputs and Remove stack on a phone and sit in a row from `sm`. */ +const REPO_ROW = + "flex flex-col items-stretch gap-2 px-3 py-2 sm:flex-row sm:flex-wrap sm:items-start sm:px-4"; + export function FactorySettingsPanel() { const { environment } = useSettingsScope(); const environmentId = environment?.environmentId ?? null; @@ -381,10 +385,7 @@ export function FactorySettingsPanel() { {form.repos.map((row, index) => ( // Rows have no identity beyond their position, which is also how the server names them. // eslint-disable-next-line react/no-array-index-key -
    +
    Date: Sat, 17 Oct 2026 16:09:58 +1000 Subject: [PATCH 12/24] chore(factory): list mainAppLocation as a seam main's b6bde560b edits mainAppLocation.ts and adds its test so /factory gets the sidebar Back button, but did not list either file in SEAMS, so check-seams.sh fails on main itself and on every branch merged from it. Issue: #61 --- factory/SEAMS | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/factory/SEAMS b/factory/SEAMS index f2b85e6c8b146..4e80fb3a9ce06 100644 --- a/factory/SEAMS +++ b/factory/SEAMS @@ -1,8 +1,10 @@ .gitignore apps/server/src/server.ts -apps/web/src/components/settings/settingsSearch.ts apps/web/src/components/settings/SettingsSidebarNav.tsx +apps/web/src/components/settings/settingsSearch.ts apps/web/src/components/sidebar/SidebarChrome.tsx +apps/web/src/components/sidebar/mainAppLocation.test.ts +apps/web/src/components/sidebar/mainAppLocation.ts apps/web/src/components/ui/button.tsx apps/web/src/components/ui/input.tsx apps/web/src/routeTree.gen.ts From 461c45d8bd5df38bf51074097e1abf47cb78991b Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:14:50 +1000 Subject: [PATCH 13/24] fix(factory): import the sidebar test from vite-plus/test mainAppLocation.test.ts came from main's b6bde560b and imported "vitest", which does not resolve in the web package (TS2307), so the web typecheck failed. Import from "vite-plus/test" like every other web test. Issue: #61 --- apps/web/src/components/sidebar/mainAppLocation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/sidebar/mainAppLocation.test.ts b/apps/web/src/components/sidebar/mainAppLocation.test.ts index 108b4917cf2d8..09b19641cd8e7 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.test.ts +++ b/apps/web/src/components/sidebar/mainAppLocation.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { isSidebarUtilityPage } from "./mainAppLocation"; From bb2c7b2808ae82f59e8a8cc37363576c8964683c Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:15:26 +1000 Subject: [PATCH 14/24] test(factory): cover the settings API against a stubbed fetch Runs the real settings/api.ts for every status it maps, the relay refusal, the request shape per connection type and the connection's own URL. Two cases fail today: a 200 whose body is not JSON returns ok with a null report, and a 422 whose body is not JSON returns invalid with no errors. Both should be an error. Issue: #61 --- apps/web/src/factory/settings/api.test.ts | 154 ++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 apps/web/src/factory/settings/api.test.ts diff --git a/apps/web/src/factory/settings/api.test.ts b/apps/web/src/factory/settings/api.test.ts new file mode 100644 index 0000000000000..750861a32557b --- /dev/null +++ b/apps/web/src/factory/settings/api.test.ts @@ -0,0 +1,154 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vite-plus/test"; + +import type { PreparedConnection } from "@t3tools/client-runtime/connection"; + +import { fetchFactoryConfig, saveFactoryConfig, type MachineConfigValues } from "./api"; + +// Only the wire is stubbed: the real settings API runs against a fetch stand-in, on a connection +// whose HTTP base is not the window's own origin. +const WINDOW_ORIGIN = "http://127.0.0.1:3773"; +const REMOTE_BASE = "https://personal-mac.tail1234.ts.net:3876"; +const cookieConnection = { + httpBaseUrl: REMOTE_BASE, + httpAuthorization: null, +} as unknown as PreparedConnection; +const bearerConnection = { + httpBaseUrl: `${REMOTE_BASE}/`, + httpAuthorization: { _tag: "Bearer", token: "tok-1" }, +} as unknown as PreparedConnection; +const dpopConnection = { + httpBaseUrl: REMOTE_BASE, + httpAuthorization: { _tag: "Dpop" }, +} as unknown as PreparedConnection; + +const fetchMock = vi.fn(); + +beforeAll(() => { + vi.stubGlobal("fetch", fetchMock); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { href: `${WINDOW_ORIGIN}/settings`, origin: WINDOW_ORIGIN } }, + }); +}); + +afterAll(() => { + Reflect.deleteProperty(globalThis, "window"); + vi.unstubAllGlobals(); +}); + +afterEach(() => { + fetchMock.mockReset(); +}); + +const answer = (status: number, body: string) => + fetchMock.mockResolvedValueOnce(new Response(body, { status })); + +const sent = () => { + const call = fetchMock.mock.calls.at(-1)!; + return new Request(call[0], call[1]); +}; + +const report = { file: "/tmp/factory.yml", exists: true, config: null, errors: [], repos: [] }; +const values: MachineConfigValues = { + gh_config_dir: "", + poll_seconds: 45, + max_concurrent_runs: 2, + max_concurrent_gates: 1, + model_instance: "", + model: "", + repos: [{ repo: "acme/app", path: "/work/app" }], +}; + +describe("fetchFactoryConfig", () => { + it("returns the report of a 200", async () => { + answer(200, JSON.stringify(report)); + expect(await fetchFactoryConfig(cookieConnection)).toEqual({ kind: "ok", report }); + }); + + it("calls the connection's /api/factory/config, not the window's origin", async () => { + answer(200, JSON.stringify(report)); + await fetchFactoryConfig(bearerConnection); + expect(sent().url).toBe(`${REMOTE_BASE}/api/factory/config`); + expect(sent().method).toBe("GET"); + }); + + it("maps a 403 to forbidden", async () => { + answer(403, JSON.stringify({ message: "no" })); + expect(await fetchFactoryConfig(cookieConnection)).toEqual({ kind: "forbidden" }); + }); + + it("maps a 422 to invalid with the server's errors", async () => { + const errors = [{ file: "factory.yml", field: "poll_seconds", message: "must be positive" }]; + answer(422, JSON.stringify({ errors })); + expect(await fetchFactoryConfig(cookieConnection)).toEqual({ kind: "invalid", errors }); + }); + + it("maps a 500 with a JSON message to an error carrying that message", async () => { + answer(500, JSON.stringify({ message: "disk on fire" })); + expect(await fetchFactoryConfig(cookieConnection)).toEqual({ + kind: "error", + message: "disk on fire", + }); + }); + + it("maps a 500 that is not JSON to an error naming the status", async () => { + answer(500, "oops"); + expect(await fetchFactoryConfig(cookieConnection)).toEqual({ + kind: "error", + message: "The factory answered 500.", + }); + }); + + it("maps a 200 that is not JSON to an error, not an ok with no report", async () => { + answer(200, "app shell"); + const result = await fetchFactoryConfig(cookieConnection); + expect(result.kind).toBe("error"); + }); + + it("maps a 422 that is not JSON to an error", async () => { + answer(422, "proxy"); + const result = await fetchFactoryConfig(cookieConnection); + expect(result.kind).toBe("error"); + }); + + it("maps a network failure to an error with its message", async () => { + fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch")); + expect(await fetchFactoryConfig(cookieConnection)).toEqual({ + kind: "error", + message: "Failed to fetch", + }); + }); + + it("reports a relay connection without sending anything", async () => { + const result = await fetchFactoryConfig(dpopConnection); + expect(result).toEqual({ + kind: "error", + message: "The factory is not available over a relay connection yet.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("saveFactoryConfig", () => { + it("PUTs the values as JSON with a bearer token and no cookies", async () => { + answer(200, JSON.stringify(report)); + const result = await saveFactoryConfig(bearerConnection, values); + expect(result.kind).toBe("ok"); + const request = sent(); + expect(request.method).toBe("PUT"); + expect(request.url).toBe(`${REMOTE_BASE}/api/factory/config`); + expect(request.headers.get("content-type")).toBe("application/json"); + expect(request.headers.get("authorization")).toBe("Bearer tok-1"); + expect(request.credentials).toBe("omit"); + expect(await request.json()).toEqual(values); + }); + + it("sends the session cookie and no authorization header on a cookie connection", async () => { + answer(200, JSON.stringify(report)); + await saveFactoryConfig(cookieConnection, values); + const request = sent(); + expect(request.credentials).toBe("include"); + expect(request.headers.get("authorization")).toBeNull(); + expect(request.headers.get("content-type")).toBe("application/json"); + }); +}); From 455328e1b56db392a5ee2bed3c56d5fbd6b19fbf Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:15:35 +1000 Subject: [PATCH 15/24] fix(factory): report a non-JSON config answer as an error A 2xx answer whose body was not JSON (an HTML fallback or a proxy page) came back as ok with a null report, and the settings panel crashed reading report.config. A 422 with such a body came back as invalid with no errors. Both are now an error, as they were before the shared request helper. Issue: #61 --- apps/web/src/factory/settings/api.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/web/src/factory/settings/api.ts b/apps/web/src/factory/settings/api.ts index caa593c8894a7..fd6b46a8abd07 100644 --- a/apps/web/src/factory/settings/api.ts +++ b/apps/web/src/factory/settings/api.ts @@ -76,6 +76,8 @@ export type FactoryConfigResult = | { readonly kind: "forbidden" } | { readonly kind: "error"; readonly message: string }; +const isObject = (value: unknown): value is object => typeof value === "object" && value !== null; + const CONFIG_PATH = "/api/factory/config"; const request = async ( @@ -87,14 +89,17 @@ const request = async ( const response = await factoryRequest(prepared, { method, path: CONFIG_PATH, body }); const parsed = parseJson(response.text); if (response.status === 403) return { kind: "forbidden" }; - if (response.status === 422) { - const errors = (parsed as { errors?: ReadonlyArray } | null)?.errors; - return { kind: "invalid", errors: errors ?? [] }; - } if (response.status < 200 || response.status >= 300) { + if (response.status === 422 && isObject(parsed)) { + const errors = (parsed as { errors?: ReadonlyArray }).errors; + return { kind: "invalid", errors: errors ?? [] }; + } return { kind: "error", message: failureMessage(response.status, parsed) }; } - return { kind: "ok", report: parsed as MachineConfigReport }; + if (!isObject(parsed)) { + return { kind: "error", message: "The factory's answer was not JSON." }; + } + return { kind: "ok", report: parsed as unknown as MachineConfigReport }; } catch (cause) { return { kind: "error", message: cause instanceof Error ? cause.message : String(cause) }; } From 2bfc4f75c25beb7d819edebdb7e0bb96432fa457 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:15:57 +1000 Subject: [PATCH 16/24] test(factory): expect run cards to link the issue and pull request At 375 px the card shows the issue and pull request as plain text, so nothing on the phone layout reaches them, while the wide table links both. The new test fails: no anchor with the issue or PR href exists on the card. A second test pins that a non-GitHub pr_url stays text. Issue: #61 --- apps/web/src/factory/FactoryPage.test.tsx | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/web/src/factory/FactoryPage.test.tsx b/apps/web/src/factory/FactoryPage.test.tsx index ac7ef892ded86..13541ef174fee 100644 --- a/apps/web/src/factory/FactoryPage.test.tsx +++ b/apps/web/src/factory/FactoryPage.test.tsx @@ -477,6 +477,30 @@ describe("FactoryPage run cards at 375 px", () => { 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: { From 43cdba3de372a32820e609ee24e1257def742e80 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:16:16 +1000 Subject: [PATCH 17/24] fix(factory): link the issue and pull request on run cards The phone layout showed the issue and pull request as plain text, so a card reached the run but never GitHub. The card now keeps one link to the run and adds a row beneath it, outside that link, with real anchors sized as tap targets. The issue and PR label/href rules moved into issueRef and prRef next to prLabel, and the table row uses them too, so each rule lives once. Issue: #61 --- apps/web/src/factory/FactoryPage.test.tsx | 9 +- apps/web/src/factory/RunsTable.tsx | 103 +++++++++++++--------- 2 files changed, 60 insertions(+), 52 deletions(-) diff --git a/apps/web/src/factory/FactoryPage.test.tsx b/apps/web/src/factory/FactoryPage.test.tsx index 13541ef174fee..60891d09ddcdd 100644 --- a/apps/web/src/factory/FactoryPage.test.tsx +++ b/apps/web/src/factory/FactoryPage.test.tsx @@ -462,14 +462,7 @@ describe("FactoryPage run cards at 375 px", () => { .findAll(() => true) .flatMap((n) => n.children.filter((c) => typeof c === "string")) .join(" "); - for (const part of [ - "original-solutions/t3code-factory", - "#20", - "building", - "launch-sending", - "2h", - "#30", - ]) { + 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. diff --git a/apps/web/src/factory/RunsTable.tsx b/apps/web/src/factory/RunsTable.tsx index 83a2a271995e7..4438b100f28ba 100644 --- a/apps/web/src/factory/RunsTable.tsx +++ b/apps/web/src/factory/RunsTable.tsx @@ -34,6 +34,23 @@ const prLabel = (url: string) => { return match ? `#${match[1]}` : "PR"; }; +/** The issue of a run id: `#20` linked to GitHub when numeric, else the bare text. */ +const issueRef = (parsed: NonNullable>) => + /^[0-9]+$/.test(parsed.issue) + ? { + text: `#${parsed.issue}`, + href: `https://github.com/${parsed.owner}/${parsed.repo}/issues/${parsed.issue}`, + } + : { text: parsed.issue, href: null }; + +/** The pull request of a run: linked only for a github.com URL, else the URL as text. */ +const prRef = (url: string | null | undefined) => { + if (!url) return null; + return url.startsWith("https://github.com/") + ? { text: prLabel(url), href: url } + : { text: url, href: null }; +}; + const stateBadge = (run: FactoryRunRow) => run.unreadable ? ( unreadable @@ -41,18 +58,42 @@ const stateBadge = (run: FactoryRunRow) => {labelForState(run.state)} ); -/** One run for a phone: a single link to the run, so the whole card is the tap target. */ +/** The table's version of `Ref`: a plain link, no tap-target sizing. */ +function TableRef({ text, href }: { text: string; href: string | null }) { + return href === null ? ( + <>{text} + ) : ( + + {text} + + ); +} + +const REF_LINK = "inline-flex min-h-11 items-center underline-offset-2 hover:underline"; + +/** A reference that is a link when it has a target, else plain text. */ +function Ref({ text, href }: { text: string; href: string | null }) { + return href === null ? ( + {text} + ) : ( + + {text} + + ); +} + +/** + * One run for a phone: a link to the run that is the card's main tap target, and below it, outside + * that link so no anchor nests in another, the links to the issue and the pull request. + */ function RunCard({ run, nowMs }: { run: FactoryRunRow; nowMs: number }) { const parsed = parseRunId(run.id); - const pr = run.pr_url - ? run.pr_url.startsWith("https://github.com/") - ? prLabel(run.pr_url) - : run.pr_url - : null; + const issue = parsed ? issueRef(parsed) : null; + const pr = prRef(run.pr_url); return ( -
  • +
  • @@ -63,14 +104,16 @@ function RunCard({ run, nowMs }: { run: FactoryRunRow; nowMs: number }) { {stateBadge(run)} - {parsed ? ( - {/^[0-9]+$/.test(parsed.issue) ? `#${parsed.issue}` : parsed.issue} - ) : null} {run.step ?? run.last_event ?? "—"} {formatAge(startedMs(run), nowMs)} - {pr ? {pr} : null} + {issue || pr ? ( + + {issue ? : null} + {pr ? : null} + + ) : null}
  • ); } @@ -145,44 +188,16 @@ export function RunsTable({ {sorted.map((run) => { const parsed = parseRunId(run.id); + const issue = parsed ? issueRef(parsed) : null; + const pr = prRef(run.pr_url); return ( {parsed ? `${parsed.owner}/${parsed.repo}` : "—"} - - {parsed ? ( - /^[0-9]+$/.test(parsed.issue) ? ( - - #{parsed.issue} - - ) : ( - parsed.issue - ) - ) : ( - "—" - )} - + {issue ? : "—"} {stateBadge(run)} {run.step ?? run.last_event ?? "—"} {formatAge(startedMs(run), nowMs)} - - {run.pr_url?.startsWith("https://github.com/") ? ( - - {prLabel(run.pr_url)} - - ) : ( - run.pr_url || "—" - )} - + {pr ? : "—"} Date: Sat, 17 Oct 2026 16:16:29 +1000 Subject: [PATCH 18/24] test(factory): pin the 640 px breakpoint on both pages 375 and 1280 sit on either side of every Tailwind breakpoint, so changing max-sm to max-md or max-lg would not fail a test. Add 639 px (cards and lists) and 640 px (table and tables) for the Factory page and the run page. These pass already; they guard the boundary. Issue: #61 --- apps/web/src/factory/FactoryPage.test.tsx | 14 ++++++++++++++ apps/web/src/factory/FactoryRunPage.test.tsx | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/apps/web/src/factory/FactoryPage.test.tsx b/apps/web/src/factory/FactoryPage.test.tsx index 60891d09ddcdd..746048f983afd 100644 --- a/apps/web/src/factory/FactoryPage.test.tsx +++ b/apps/web/src/factory/FactoryPage.test.tsx @@ -514,3 +514,17 @@ describe("FactoryPage run cards at 375 px", () => { 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(); + }); +}); diff --git a/apps/web/src/factory/FactoryRunPage.test.tsx b/apps/web/src/factory/FactoryRunPage.test.tsx index 1b265bdec3e4e..afea834b2cff4 100644 --- a/apps/web/src/factory/FactoryRunPage.test.tsx +++ b/apps/web/src/factory/FactoryRunPage.test.tsx @@ -260,3 +260,16 @@ describe("FactoryRunPage lists at 375 px", () => { 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(); + }); +}); From 4d85ded9f81241e088ea4bd9cc757ad98bd7743b Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:16:53 +1000 Subject: [PATCH 19/24] test(factory): assert touch size on the error-path buttons The size="touch" assertions only covered the populated pages. Assert it on the Retry of the Factory page (full error and the stale-poll notice), the run page's error Retry, Show recent runs after Show all, and the settings panel's load-error Retry. These pass already. Issue: #61 --- apps/web/src/factory/FactoryPage.test.tsx | 7 +++++++ apps/web/src/factory/FactoryRunPage.test.tsx | 4 ++++ .../web/src/factory/settings/FactorySettingsPanel.test.tsx | 1 + 3 files changed, 12 insertions(+) diff --git a/apps/web/src/factory/FactoryPage.test.tsx b/apps/web/src/factory/FactoryPage.test.tsx index 746048f983afd..9559274d2d4fa 100644 --- a/apps/web/src/factory/FactoryPage.test.tsx +++ b/apps/web/src/factory/FactoryPage.test.tsx @@ -79,6 +79,10 @@ const api = (overrides: Partial): FactoryApi => ({ ...overrides, }); +/** The design-system `Button` whose text is `label`. */ +const buttonNamed = (page: Awaited>, label: string) => + page.renderer.root.findAllByType(Button).find((b) => b.props.children === label)!; + beforeEach(() => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(NOW); @@ -122,6 +126,7 @@ describe("FactoryPage", () => { }); const page = await render(createElement(FactoryPage)); expect(page.text()).toContain("Missing permission orchestration:read"); + expect(buttonNamed(page, "Retry").props.size).toBe("touch"); await act(async () => page.button("Retry")!.props.onClick()); expect(refreshStatus).toHaveBeenCalledTimes(1); expect(refreshRuns).toHaveBeenCalledTimes(1); @@ -141,6 +146,7 @@ describe("FactoryPage", () => { expect(page.text()).toContain("original-solutions/t3code-factory"); expect(page.text()).toContain("fetch failed"); expect(page.text()).not.toContain("Could not load the factory"); + expect(buttonNamed(page, "Retry").props.size).toBe("touch"); await act(async () => page.button("Retry")!.props.onClick()); expect(refreshStatus).toHaveBeenCalledTimes(1); expect(refreshRuns).toHaveBeenCalledTimes(1); @@ -162,6 +168,7 @@ describe("FactoryPage", () => { expect(page.text()).toContain("o__r__1__1"); expect(page.text()).toContain("Every run, read once."); expect(page.button("Show all runs")).toBeUndefined(); + expect(buttonNamed(page, "Show recent runs").props.size).toBe("touch"); await act(async () => page.button("Show recent runs")!.props.onClick()); expect(runLinks()).toHaveLength(1); }); diff --git a/apps/web/src/factory/FactoryRunPage.test.tsx b/apps/web/src/factory/FactoryRunPage.test.tsx index afea834b2cff4..ff6f74bcf4330 100644 --- a/apps/web/src/factory/FactoryRunPage.test.tsx +++ b/apps/web/src/factory/FactoryRunPage.test.tsx @@ -115,6 +115,10 @@ describe("FactoryRunPage", () => { }); const page = await mount(); expect(page.text()).toContain("ledger exploded"); + const retry = page.renderer.root + .findAllByType(Button) + .find((b) => b.props.children === "Retry"); + expect(retry!.props.size).toBe("touch"); await act(async () => page.button("Retry")!.props.onClick()); expect(refresh).toHaveBeenCalledTimes(1); }); diff --git a/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx b/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx index 9a84fa0a0cdd0..7436ff1b35dd4 100644 --- a/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx +++ b/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx @@ -228,6 +228,7 @@ describe("FactorySettingsPanel", () => { expect(textOf(root)).toContain("the factory is unreachable"); expect(root.findAll((node) => node.type === "input")).toHaveLength(0); + expect(button(root, "Retry").props.size).toBe("touch"); await click(button(root, "Retry")); From 116f509dadb99aabe5cc2f3cb742140e2adcbc26 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:16:59 +1000 Subject: [PATCH 20/24] docs(factory): list mainAppLocation as the ninth seam factory/SEAMS also names mainAppLocation.ts and its test, from the #28 follow-up b6bde560b that makes /factory a utility page so the sidebar shows Back. The Seams list in design.md stopped at eight. Issue: #61 --- factory/docs/design.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/factory/docs/design.md b/factory/docs/design.md index d2d172615db45..ad8986674ec75 100644 --- a/factory/docs/design.md +++ b/factory/docs/design.md @@ -87,8 +87,9 @@ factory/ repo root: SEAMS, sync/build/install scripts 6. `apps/web/src/components/settings/SettingsSidebarNav.tsx` (#55): the icon for that page. 7. `apps/web/src/components/ui/button.tsx` (#61): a `touch` size, 44 px tall below `sm` and the default height from `sm` up. 8. `apps/web/src/components/ui/input.tsx` (#61): the same `touch` size for inputs. +9. `apps/web/src/components/sidebar/mainAppLocation.ts` and its test (#28 follow-up, b6bde560b): `/factory` counts as a utility page, so the sidebar shows Back. -Eight entries, all in `factory/SEAMS`. Nothing in `ws.ts`, `rpc.ts`, `packages/contracts` or `Migrations.ts`. Those are the files upstream churns most, and adding an RPC would touch five of them. The factory API is a separate HTTP route layer instead. +Nine entries, all in `factory/SEAMS`. Nothing in `ws.ts`, `rpc.ts`, `packages/contracts` or `Migrations.ts`. Those are the files upstream churns most, and adding an RPC would touch five of them. The factory API is a separate HTTP route layer instead. ### Boundary rules, checked by a test From 271756e51a2479460bc16af9f867a1a0251125ee Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:17:12 +1000 Subject: [PATCH 21/24] test(factory): restore the test window when each test ends renderAt installed a window with only matchMedia and kept it until unmount or the next render, and most tests never unmount. Register the restore with onTestFinished so no test leaves its window behind; unmount still restores too. Issue: #61 --- apps/web/src/factory/testing.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/factory/testing.ts b/apps/web/src/factory/testing.ts index fb407f5253697..814a4c487e188 100644 --- a/apps/web/src/factory/testing.ts +++ b/apps/web/src/factory/testing.ts @@ -1,4 +1,5 @@ import { act, type ReactElement } from "react"; +import { onTestFinished } from "vite-plus/test"; import { create, type ReactTestInstance, @@ -71,10 +72,12 @@ export const render = (element: ReactElement) => renderAt(1280, element); /** * Renders `element` in a viewport `widthPx` wide and returns the renderer with helpers to read it. - * The viewport stays until `unmount` or the next render, since a later update still asks it. + * The viewport stays until `unmount`, the next render, or the end of the test, since a later + * update still asks it. */ export async function renderAt(widthPx: number, element: ReactElement) { const restore = viewport(widthPx); + onTestFinished(restore); let renderer!: ReactTestRenderer; await act(async () => { renderer = create(element); From cf705e60da04abb1aea075ed58f5874a15a699ad Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:42:30 +1000 Subject: [PATCH 22/24] test(factory): pin the 375 px layout of attention items and the config path The attention message is still a flex-1 sibling of the repo span, so it is squeezed into a sliver at 375 px, and the config path sits inside one unbreakable text node. Both new tests fail: the message is not a full-width paragraph after the age, and no element holds the path with break-all. Issue: #61 --- apps/web/src/factory/FactoryPage.test.tsx | 28 +++++++++++++++++++ .../settings/FactorySettingsPanel.test.tsx | 19 ++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/web/src/factory/FactoryPage.test.tsx b/apps/web/src/factory/FactoryPage.test.tsx index 9559274d2d4fa..f9102e8731c63 100644 --- a/apps/web/src/factory/FactoryPage.test.tsx +++ b/apps/web/src/factory/FactoryPage.test.tsx @@ -1,4 +1,5 @@ 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"; @@ -455,6 +456,33 @@ describe.each([375, 1280])("FactoryPage at %i px", (width) => { }); }); +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}#20`, "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()] } }); diff --git a/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx b/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx index 7436ff1b35dd4..990b5c24d8740 100644 --- a/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx +++ b/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx @@ -201,7 +201,8 @@ describe("FactorySettingsPanel", () => { mocks.saveFactoryConfig.mockResolvedValue(ok(report({ exists: true }))); const root = await open(); - expect(textOf(root)).toContain(`No config file exists at ${FILE}`); + expect(textOf(root)).toContain("No config file exists at"); + expect(textOf(root)).toContain(FILE); expect(input(root, "Poll seconds").props.value).toBe("60"); expect(input(root, "Model instance").props.value).toBe("claudeAgent"); @@ -390,6 +391,22 @@ describe("FactorySettingsPanel", () => { expect(textOf(root)).toContain("read-only"); }); describe.each([375, 1280])("at %i px", (width) => { + it("lets a long config path break instead of overflowing", async () => { + const restore = viewport(width); + try { + const long = `/Users/someone/${"deeply/".repeat(12)}.t3/factory/config.yml`; + mocks.fetchFactoryConfig.mockResolvedValue(ok(report({ exists: false, file: long }))); + const root = await open(); + const holder = root.findAll( + (node) => typeof node.type === "string" && node.children.includes(long), + ); + expect(holder).toHaveLength(1); + expect(String(holder[0]!.props.className)).toContain("break-all"); + } finally { + restore(); + } + }); + it("asks for touch-sized inputs and buttons, with Save present", async () => { const restore = viewport(width); try { From 6264d3775f624ff6ca478ff2a4fe438a1945930b Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 16:42:47 +1000 Subject: [PATCH 23/24] fix(factory): keep attention messages and the config path readable at 375 px An attention item now has a first row of badge, repo and issue, and age (pushed right), and the message is a full-width paragraph below it instead of a flex-1 sibling that collapsed into a letter-by-letter sliver. The repo span can break anywhere. The "No config file" notice puts the path in a code element with break-all so it wraps inside the box rather than being clipped. The attention test's repo needle drops the "#20" suffix, since the test helper joins the split text nodes with a space. Issue: #61 --- apps/web/src/factory/AttentionList.tsx | 24 ++++++++++--------- apps/web/src/factory/FactoryPage.test.tsx | 2 +- .../factory/settings/FactorySettingsPanel.tsx | 5 ++-- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/apps/web/src/factory/AttentionList.tsx b/apps/web/src/factory/AttentionList.tsx index 1f145c36411bc..c569bb0dabb56 100644 --- a/apps/web/src/factory/AttentionList.tsx +++ b/apps/web/src/factory/AttentionList.tsx @@ -18,20 +18,22 @@ export function AttentionList({
      {items.map((item) => (
    • - {item.kind} - {item.repo ? ( - - {item.repo} - {item.issue !== null ? `#${item.issue}` : null} +
      + {item.kind} + {item.repo ? ( + + {item.repo} + {item.issue !== null ? `#${item.issue}` : null} + + ) : null} + + {formatAge(Date.parse(item.first_seen), nowMs)} - ) : null} - {item.message} - - {formatAge(Date.parse(item.first_seen), nowMs)} - +
      +

      {item.message}

    • ))}
    diff --git a/apps/web/src/factory/FactoryPage.test.tsx b/apps/web/src/factory/FactoryPage.test.tsx index f9102e8731c63..e7fd239c9132a 100644 --- a/apps/web/src/factory/FactoryPage.test.tsx +++ b/apps/web/src/factory/FactoryPage.test.tsx @@ -474,7 +474,7 @@ describe("FactoryPage attention items at 375 px", () => { const textOf = (node: ReactTestInstance | string): string => typeof node === "string" ? node : node.children.map(textOf).join(" "); const full = textOf(item); - const order = ["stuck", `${ATTENTION.repo}#20`, "1h", ATTENTION.message].map((part) => + const order = ["stuck", ATTENTION.repo, "1h", ATTENTION.message].map((part) => full.indexOf(part), ); expect(order.every((index) => index >= 0)).toBe(true); diff --git a/apps/web/src/factory/settings/FactorySettingsPanel.tsx b/apps/web/src/factory/settings/FactorySettingsPanel.tsx index c18c2787af327..cf461992f9dda 100644 --- a/apps/web/src/factory/settings/FactorySettingsPanel.tsx +++ b/apps/web/src/factory/settings/FactorySettingsPanel.tsx @@ -260,8 +260,9 @@ export function FactorySettingsPanel() {
    {!report.exists ? ( -

    - {`No config file exists at ${report.file}. Saving creates it.`} +

    + No config file exists at {report.file}. Saving + creates it.

    ) : null} {!canWrite ? ( From f05078afb6f891e6b9000a621e6bf643d7e0343f Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Sat, 17 Oct 2026 17:29:44 +1000 Subject: [PATCH 24/24] fix(factory): one deadline per call, upstream's Button, links through InlineButton Review round 1 on #99 (f1 to f5). A factory call waited up to the timeout for a connection and then gave the request the whole timeout again, so a server that hung could take twice the documented time to fail. The call now works out one deadline when it starts and hands the request only what the wait left, and the timeout message names the whole budget. Tests cover both orders: the request outliving the time left, and an answer that arrives within it. The primary-connection lookup had no test that reached it: every api test set the override. The upstream atoms it reads are now writable in the test, so one case proves the call goes to the primary environment's connection and not another's, one that a missing primary gives no_connection, and one that a primary whose connection is still None is waited for. Button's base class already gives every button a 44 px tap area on a coarse pointer, so the fork's `touch` size duplicated it. button.tsx is upstream's again and off the seam list; the pages use the sizes they had before #61. Input keeps `touch`, having no such mechanism. The 44 px links were hand-styled anchors without the design system's focus ring. They render through InlineButton now, with the tap height named once in the fork's TouchLink, and the table and the cards share one Ref. --- apps/web/src/components/ui/button.tsx | 1 - apps/web/src/factory/FactoryNotices.tsx | 2 +- apps/web/src/factory/FactoryPage.test.tsx | 17 --- apps/web/src/factory/FactoryPage.tsx | 4 +- apps/web/src/factory/FactoryRunPage.test.tsx | 13 -- apps/web/src/factory/FactoryRunPage.tsx | 18 +-- apps/web/src/factory/RunsTable.tsx | 50 ++++--- apps/web/src/factory/TouchLink.tsx | 12 ++ apps/web/src/factory/api.test.ts | 130 ++++++++++++++++++ apps/web/src/factory/api.ts | 21 ++- apps/web/src/factory/http.ts | 10 +- .../settings/FactorySettingsPanel.test.tsx | 15 +- .../factory/settings/FactorySettingsPanel.tsx | 8 +- .../web/src/factory/settings/WatchedRepos.tsx | 16 ++- factory/SEAMS | 1 - factory/docs/design.md | 9 +- 16 files changed, 224 insertions(+), 103 deletions(-) create mode 100644 apps/web/src/factory/TouchLink.tsx diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 9b001afa8ada4..582a6e9856cb7 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -35,7 +35,6 @@ const buttonVariants = cva( sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7", "sm-multiline": "min-h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] py-[calc(--spacing(1)-1px)] whitespace-normal sm:min-h-7", - touch: "h-11 px-[calc(--spacing(3)-1px)] sm:h-8", xl: "h-11 px-[calc(--spacing(4)-1px)] text-lg sm:h-10 sm:text-base [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", xs: "h-7 gap-1 px-[calc(--spacing(2)-1px)] text-sm sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5", }, diff --git a/apps/web/src/factory/FactoryNotices.tsx b/apps/web/src/factory/FactoryNotices.tsx index a148ab41ccfdb..b6aabe383011f 100644 --- a/apps/web/src/factory/FactoryNotices.tsx +++ b/apps/web/src/factory/FactoryNotices.tsx @@ -31,7 +31,7 @@ export function FactoryError({ {message} {onRetry ? ( - ) : null} diff --git a/apps/web/src/factory/FactoryPage.test.tsx b/apps/web/src/factory/FactoryPage.test.tsx index e7fd239c9132a..9d80535311c2c 100644 --- a/apps/web/src/factory/FactoryPage.test.tsx +++ b/apps/web/src/factory/FactoryPage.test.tsx @@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { __setFactoryApiForTests, FactoryApiError, type FactoryApi } from "./api"; import type { FactoryResource } from "./useFactoryData"; -import { Button } from "~/components/ui/button"; import { render, renderAt } from "./testing"; const NOW = Date.parse("2026-10-17T12:00:00.000Z"); @@ -80,10 +79,6 @@ const api = (overrides: Partial): FactoryApi => ({ ...overrides, }); -/** The design-system `Button` whose text is `label`. */ -const buttonNamed = (page: Awaited>, label: string) => - page.renderer.root.findAllByType(Button).find((b) => b.props.children === label)!; - beforeEach(() => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(NOW); @@ -127,7 +122,6 @@ describe("FactoryPage", () => { }); const page = await render(createElement(FactoryPage)); expect(page.text()).toContain("Missing permission orchestration:read"); - expect(buttonNamed(page, "Retry").props.size).toBe("touch"); await act(async () => page.button("Retry")!.props.onClick()); expect(refreshStatus).toHaveBeenCalledTimes(1); expect(refreshRuns).toHaveBeenCalledTimes(1); @@ -147,7 +141,6 @@ describe("FactoryPage", () => { expect(page.text()).toContain("original-solutions/t3code-factory"); expect(page.text()).toContain("fetch failed"); expect(page.text()).not.toContain("Could not load the factory"); - expect(buttonNamed(page, "Retry").props.size).toBe("touch"); await act(async () => page.button("Retry")!.props.onClick()); expect(refreshStatus).toHaveBeenCalledTimes(1); expect(refreshRuns).toHaveBeenCalledTimes(1); @@ -169,7 +162,6 @@ describe("FactoryPage", () => { expect(page.text()).toContain("o__r__1__1"); expect(page.text()).toContain("Every run, read once."); expect(page.button("Show all runs")).toBeUndefined(); - expect(buttonNamed(page, "Show recent runs").props.size).toBe("touch"); await act(async () => page.button("Show recent runs")!.props.onClick()); expect(runLinks()).toHaveLength(1); }); @@ -445,15 +437,6 @@ describe.each([375, 1280])("FactoryPage at %i px", (width) => { expect(text.indexOf("claims paused")).toBeLessThan(text.indexOf("launch-sending")); await page.unmount(); }); - - it("asks for touch-sized buttons", async () => { - populate(); - const page = await renderAt(width, createElement(FactoryPage)); - const buttons = page.renderer.root.findAllByType(Button); - expect(buttons.map((b) => b.props.size)).toEqual(buttons.map(() => "touch")); - expect(buttons.length).toBeGreaterThanOrEqual(3); - await page.unmount(); - }); }); describe("FactoryPage attention items at 375 px", () => { diff --git a/apps/web/src/factory/FactoryPage.tsx b/apps/web/src/factory/FactoryPage.tsx index d6b0e56d5b755..5dbc2800d0bba 100644 --- a/apps/web/src/factory/FactoryPage.tsx +++ b/apps/web/src/factory/FactoryPage.tsx @@ -57,7 +57,7 @@ export function FactoryPage() { Could not refresh: {messageOf(stale.error)} - @@ -103,7 +103,7 @@ export function FactoryPage() { onClick={() => void control(() => (paused ? factoryApi.resumeClaims() : factoryApi.pauseClaims())) } - size="touch" + size="sm" variant="outline" > {paused ? "Resume claims" : "Pause claims"} diff --git a/apps/web/src/factory/FactoryRunPage.test.tsx b/apps/web/src/factory/FactoryRunPage.test.tsx index ff6f74bcf4330..62738484fe707 100644 --- a/apps/web/src/factory/FactoryRunPage.test.tsx +++ b/apps/web/src/factory/FactoryRunPage.test.tsx @@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { __setFactoryApiForTests, FactoryApiError, type FactoryApi } from "./api"; import type { FactoryResource } from "./useFactoryData"; -import { Button } from "~/components/ui/button"; import { render, renderAt } from "./testing"; const state = vi.hoisted(() => ({ run: null as unknown, environmentId: "env-1" as string | null })); @@ -115,10 +114,6 @@ describe("FactoryRunPage", () => { }); const page = await mount(); expect(page.text()).toContain("ledger exploded"); - const retry = page.renderer.root - .findAllByType(Button) - .find((b) => b.props.children === "Retry"); - expect(retry!.props.size).toBe("touch"); await act(async () => page.button("Retry")!.props.onClick()); expect(refresh).toHaveBeenCalledTimes(1); }); @@ -244,14 +239,6 @@ describe.each([375, 1280])("FactoryRunPage at %i px", (width) => { } await page.unmount(); }); - - it("asks for a touch-sized Stop button", async () => { - const page = await mountAt(); - const buttons = page.renderer.root.findAllByType(Button); - expect(buttons.length).toBeGreaterThanOrEqual(1); - expect(buttons.map((b) => b.props.size)).toEqual(buttons.map(() => "touch")); - await page.unmount(); - }); }); describe("FactoryRunPage lists at 375 px", () => { diff --git a/apps/web/src/factory/FactoryRunPage.tsx b/apps/web/src/factory/FactoryRunPage.tsx index 3bb6cfda8ef3e..e90107a387387 100644 --- a/apps/web/src/factory/FactoryRunPage.tsx +++ b/apps/web/src/factory/FactoryRunPage.tsx @@ -28,6 +28,7 @@ import { stateBadgeVariant, threadIdOf, } from "./format"; +import { TouchLink } from "./TouchLink"; import { useFactoryRun } from "./useFactoryData"; /** Pairs each item with a key unique within the list, though two rows may read the same. */ @@ -221,13 +222,11 @@ export function FactoryRunPage({ runId }: { runId: string }) { {labelForState(state)} Step: {step ?? "—"} {threadId ? ( - } > Thread - + ) : ( no thread yet )} @@ -235,7 +234,7 @@ export function FactoryRunPage({ runId }: { runId: string }) { className="ml-auto" disabled={!canStop || busy} onClick={() => void stop()} - size="touch" + size="sm" variant="destructive-outline" > Stop @@ -256,12 +255,9 @@ export function FactoryRunPage({ runId }: { runId: string }) { - + } tone="muted"> Back - +

    {runId}

    } diff --git a/apps/web/src/factory/RunsTable.tsx b/apps/web/src/factory/RunsTable.tsx index 4438b100f28ba..b7747bb92e2c4 100644 --- a/apps/web/src/factory/RunsTable.tsx +++ b/apps/web/src/factory/RunsTable.tsx @@ -3,7 +3,7 @@ import { useMemo } from "react"; import { useMediaQuery } from "~/hooks/useMediaQuery"; import { Badge } from "~/components/ui/badge"; -import { Button } from "~/components/ui/button"; +import { Button, InlineButton } from "~/components/ui/button"; import { Empty, EmptyHeader, EmptyTitle } from "~/components/ui/empty"; import { Table, @@ -16,6 +16,7 @@ import { import type { FactoryRunRow } from "./api"; import { formatAge, labelForState, parseRunId, stateBadgeVariant } from "./format"; +import { TouchLink } from "./TouchLink"; const startedMs = (run: FactoryRunRow) => run.started_ts ? Date.parse(run.started_ts) : Number.NaN; @@ -58,27 +59,22 @@ const stateBadge = (run: FactoryRunRow) => {labelForState(run.state)} ); -/** The table's version of `Ref`: a plain link, no tap-target sizing. */ -function TableRef({ text, href }: { text: string; href: string | null }) { - return href === null ? ( - <>{text} - ) : ( - - {text} - - ); -} - -const REF_LINK = "inline-flex min-h-11 items-center underline-offset-2 hover:underline"; - -/** A reference that is a link when it has a target, else plain text. */ -function Ref({ text, href }: { text: string; href: string | null }) { - return href === null ? ( - {text} +/** A reference that is a link when it has a target, else plain text; `touch` on the phone cards. */ +function Ref({ + text, + href, + touch = false, +}: { + text: string; + href: string | null; + touch?: boolean; +}) { + if (href === null) return {text}; + const anchor = ; + return touch ? ( + {text} ) : ( - - {text} - + {text} ); } @@ -110,8 +106,8 @@ function RunCard({ run, nowMs }: { run: FactoryRunRow; nowMs: number }) { {issue || pr ? ( - {issue ? : null} - {pr ? : null} + {issue ? : null} + {pr ? : null} ) : null} @@ -138,13 +134,13 @@ export function RunsTable({ const cards = useMediaQuery("max-sm"); const sorted = useMemo(() => [...runs].sort(newestFirst), [runs]); const showAll = partial ? ( - ) : (
    Every run, read once. -
    @@ -193,11 +189,11 @@ export function RunsTable({ return ( {parsed ? `${parsed.owner}/${parsed.repo}` : "—"} - {issue ? : "—"} + {issue ? : "—"} {stateBadge(run)} {run.step ?? run.last_event ?? "—"} {formatAge(startedMs(run), nowMs)} - {pr ? : "—"} + {pr ? : "—"} ) { + return ; +} diff --git a/apps/web/src/factory/api.test.ts b/apps/web/src/factory/api.test.ts index 358513b19bb02..8ae307406fb4a 100644 --- a/apps/web/src/factory/api.test.ts +++ b/apps/web/src/factory/api.test.ts @@ -10,6 +10,13 @@ import { } from "vite-plus/test"; import type { PreparedConnection } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import type { Atom } from "effect/reactivity"; + +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; +import { environmentSession } from "~/state/session"; import { __setFactoryConnectionForTests, @@ -18,6 +25,47 @@ import { factoryApi, } from "./api"; +// The two upstream atoms the lookup reads are writable here, so a test can be the environment +// catalog and the session: which environment is primary, and what connection each one holds. +vi.mock("~/state/primaryEnvironment", async () => { + const { Atom } = await import("effect/reactivity"); + return { + primaryEnvironmentIdAtom: Atom.make(null).pipe(Atom.keepAlive), + }; +}); +vi.mock("~/state/session", async () => { + const { Atom } = await import("effect/reactivity"); + const Option = await import("effect/Option"); + const connections = new Map>>(); + return { + environmentSession: { + preparedConnectionValueAtom: (environmentId: string) => { + let atom = connections.get(environmentId); + if (atom === undefined) { + atom = Atom.make(Option.none()).pipe(Atom.keepAlive); + connections.set(environmentId, atom); + } + return atom; + }, + }, + }; +}); + +const ENV_1 = "env-1" as EnvironmentId; +const ENV_2 = "env-2" as EnvironmentId; +const setPrimaryEnvironment = (environmentId: EnvironmentId | null) => + appAtomRegistry.set( + primaryEnvironmentIdAtom as Atom.Writable, + environmentId, + ); +const setConnectionOf = (environmentId: EnvironmentId, prepared: PreparedConnection | null) => + appAtomRegistry.set( + environmentSession.preparedConnectionValueAtom(environmentId) as Atom.Writable< + Option.Option + >, + prepared === null ? Option.none() : Option.some(prepared), + ); + // Only the wire is stubbed: the calls go through `fetch` against the connected environment's HTTP // base, never the window's own origin (kept at 127.0.0.1:3773 here to prove it). const WINDOW_ORIGIN = "http://127.0.0.1:3773"; @@ -51,9 +99,13 @@ beforeEach(() => { }); afterEach(() => { + vi.useRealTimers(); fetchMock.mockReset(); __setFactoryTimeoutForTests(); __setFactoryConnectionForTests(); + setPrimaryEnvironment(null); + setConnectionOf(ENV_1, null); + setConnectionOf(ENV_2, null); }); const answer = (status: number, body: string, contentType = "application/json") => @@ -195,4 +247,82 @@ describe("factoryApi on the connected environment", () => { expect([error.status, error.code]).toEqual([0, "no_connection"]); expect(fetchMock).not.toHaveBeenCalled(); }); + + it("gives the request only the time the wait for a connection left", async () => { + vi.useFakeTimers(); + __setFactoryTimeoutForTests(2000); + __setFactoryConnectionForTests(null); + fetchMock.mockImplementationOnce(() => new Promise(() => {})); + const pending = failure(factoryApi.status()); + await vi.advanceTimersByTimeAsync(1200); + __setFactoryConnectionForTests(bearerConnection); + await vi.advanceTimersByTimeAsync(799); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(await Promise.race([pending, Promise.resolve("still waiting")])).toBe("still waiting"); + await vi.advanceTimersByTimeAsync(1); + const error = await pending; + expect([error.status, error.code]).toEqual([0, "timeout"]); + expect(error.message).toBe("The factory did not answer within 2 seconds."); + }); + + it("keeps an answer that comes within the time left after the wait", async () => { + vi.useFakeTimers(); + __setFactoryTimeoutForTests(2000); + __setFactoryConnectionForTests(null); + fetchMock.mockImplementationOnce( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve( + new Response('{"state":"idle"}', { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + 500, + ); + }), + ); + const pending = factoryApi.status(); + await vi.advanceTimersByTimeAsync(1200); + __setFactoryConnectionForTests(bearerConnection); + await vi.advanceTimersByTimeAsync(500); + expect(await pending).toEqual({ state: "idle" }); + }); +}); + +describe("factoryApi on the primary environment", () => { + beforeEach(() => { + __setFactoryConnectionForTests(); + }); + + it("sends on the primary environment's prepared connection, not another environment's", async () => { + setConnectionOf(ENV_2, cookieConnection("https://other-host.test:4000")); + setConnectionOf(ENV_1, bearerConnection); + setPrimaryEnvironment(ENV_1); + answer(200, '{"state":"idle"}'); + expect(await factoryApi.status()).toEqual({ state: "idle" }); + expect(String(fetchMock.mock.calls.at(-1)![0])).toBe(`${REMOTE_BASE}/api/factory/status`); + expect(sent().headers.get("authorization")).toBe("Bearer tok-1"); + }); + + it("fails with no_connection while no environment is primary", async () => { + __setFactoryTimeoutForTests(20); + setConnectionOf(ENV_1, bearerConnection); + const error = await failure(factoryApi.status()); + expect([error.status, error.code]).toEqual([0, "no_connection"]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("waits while the primary environment has no prepared connection yet", async () => { + setPrimaryEnvironment(ENV_1); + answer(200, '{"state":"idle"}'); + const pending = factoryApi.status(); + await Promise.resolve(); + expect(fetchMock).not.toHaveBeenCalled(); + setConnectionOf(ENV_1, bearerConnection); + expect(await pending).toEqual({ state: "idle" }); + expect(String(fetchMock.mock.calls.at(-1)![0])).toBe(`${REMOTE_BASE}/api/factory/status`); + }); }); diff --git a/apps/web/src/factory/api.ts b/apps/web/src/factory/api.ts index bd05e3a5fea35..47e5e2b8ed44f 100644 --- a/apps/web/src/factory/api.ts +++ b/apps/web/src/factory/api.ts @@ -12,6 +12,7 @@ import { factoryRequest, failureMessage, parseJson, + timeoutMessage, } from "./http"; /** The factory's HTTP API as the server serves it (`apps/server/src/factory/t3/http.ts`). */ @@ -118,8 +119,9 @@ export interface FactoryApi { } /** - * How long one call may wait for its answer. A server that hangs then fails the call (status 0, - * code `timeout`) instead of pinning a page on loading and its poll on a request that never ends. + * How long one call may wait for its answer, the wait for a connection included. A server that + * hangs then fails the call (status 0, code `timeout`) instead of pinning a page on loading and its + * poll on a request that never ends. */ let timeoutMs = DEFAULT_TIMEOUT_MS; @@ -172,15 +174,26 @@ const call = async ( path: string, searchParams?: Record, ): Promise => { + // One deadline for the whole call: the request gets what the wait for a connection left. + const deadline = Date.now() + timeoutMs; + const timedOut = () => new FactoryApiError(0, "timeout", timeoutMessage(timeoutMs)); let result; try { const prepared = await connectionWithin(timeoutMs); - result = await factoryRequest(prepared, { method, path, searchParams, timeoutMs }); + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) throw timedOut(); + result = await factoryRequest(prepared, { + method, + path, + searchParams, + timeoutMs: remainingMs, + }); } catch (error) { if (error instanceof FactoryApiError) throw error; + if (error instanceof FactoryHttpError && error.kind === "timeout") throw timedOut(); throw new FactoryApiError( 0, - error instanceof FactoryHttpError && error.kind === "timeout" ? "timeout" : null, + null, error instanceof Error && error.message ? error.message : "The request failed.", ); } diff --git a/apps/web/src/factory/http.ts b/apps/web/src/factory/http.ts index cc7d5e5bf0ad1..0ed14fe3211bc 100644 --- a/apps/web/src/factory/http.ts +++ b/apps/web/src/factory/http.ts @@ -15,6 +15,9 @@ import { textField } from "./format"; /** How long one call may wait for its answer. */ export const DEFAULT_TIMEOUT_MS = 15_000; +export const timeoutMessage = (timeoutMs: number): string => + `The factory did not answer within ${Math.round(timeoutMs / 1000)} seconds.`; + /** A call that was not sent (`relay`) or got no answer in time (`timeout`). */ export class FactoryHttpError extends Error { readonly kind: "relay" | "timeout"; @@ -64,12 +67,7 @@ export const factoryRequest = async ( const deadline = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); - reject( - new FactoryHttpError( - "timeout", - `The factory did not answer within ${Math.round(timeoutMs / 1000)} seconds.`, - ), - ); + reject(new FactoryHttpError("timeout", timeoutMessage(timeoutMs))); }, timeoutMs); }); const send = async (): Promise => { diff --git a/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx b/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx index 990b5c24d8740..2cd1f6fc0f5b3 100644 --- a/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx +++ b/apps/web/src/factory/settings/FactorySettingsPanel.test.tsx @@ -4,7 +4,7 @@ import { type AuthEnvironmentScope, } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import type { ReactNode } from "react"; +import type { ReactElement, ReactNode } from "react"; import { act, create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -34,7 +34,14 @@ vi.mock("./api", () => ({ saveFactoryConfig: mocks.saveFactoryConfig, })); vi.mock("../../components/ui/input", () => ({ Input: "input" })); -vi.mock("../../components/ui/button", () => ({ Button: "button" })); +vi.mock("../../components/ui/button", async () => { + const { cloneElement } = await import("react"); + return { + Button: "button", + InlineButton: ({ render, children }: { render: ReactElement; children?: ReactNode }) => + cloneElement(render, undefined, children), + }; +}); vi.mock("../../components/settings/settingsLayout", async () => { const { createElement } = await import("react"); return { @@ -229,7 +236,6 @@ describe("FactorySettingsPanel", () => { expect(textOf(root)).toContain("the factory is unreachable"); expect(root.findAll((node) => node.type === "input")).toHaveLength(0); - expect(button(root, "Retry").props.size).toBe("touch"); await click(button(root, "Retry")); @@ -407,7 +413,7 @@ describe("FactorySettingsPanel", () => { } }); - it("asks for touch-sized inputs and buttons, with Save present", async () => { + it("asks for touch-sized inputs, with Save present", async () => { const restore = viewport(width); try { mocks.fetchFactoryConfig.mockResolvedValue(ok(populated)); @@ -423,7 +429,6 @@ describe("FactorySettingsPanel", () => { "Remove", "Save", ]); - expect(buttons.map((node) => node.props.size)).toEqual(buttons.map(() => "touch")); } finally { restore(); } diff --git a/apps/web/src/factory/settings/FactorySettingsPanel.tsx b/apps/web/src/factory/settings/FactorySettingsPanel.tsx index cf461992f9dda..53db0af14dc48 100644 --- a/apps/web/src/factory/settings/FactorySettingsPanel.tsx +++ b/apps/web/src/factory/settings/FactorySettingsPanel.tsx @@ -234,7 +234,7 @@ export function FactorySettingsPanel() {

    {load.message}

    -
    @@ -375,7 +375,7 @@ export function FactorySettingsPanel() { title="Repos" headerAction={ {saveStatus === "saved" ? ( diff --git a/apps/web/src/factory/settings/WatchedRepos.tsx b/apps/web/src/factory/settings/WatchedRepos.tsx index 32078d297bd39..1079d35183406 100644 --- a/apps/web/src/factory/settings/WatchedRepos.tsx +++ b/apps/web/src/factory/settings/WatchedRepos.tsx @@ -1,5 +1,6 @@ import { SettingsRow, SettingsSection } from "../../components/settings/settingsLayout"; import type { RepoBlock } from "./api"; +import { TouchLink } from "../TouchLink"; const list = (items: ReadonlyArray) => (items.length === 0 ? "None" : items.join(", ")); @@ -52,14 +53,17 @@ export function WatchedRepos({ repos }: { readonly repos: ReadonlyArray + } > View .dw/config.yml on GitHub - + } /> diff --git a/factory/SEAMS b/factory/SEAMS index 3d3eb23068245..1aa99130ae542 100644 --- a/factory/SEAMS +++ b/factory/SEAMS @@ -6,6 +6,5 @@ apps/web/src/components/settings/settingsSearch.ts apps/web/src/components/sidebar/SidebarChrome.tsx apps/web/src/components/sidebar/mainAppLocation.test.ts apps/web/src/components/sidebar/mainAppLocation.ts -apps/web/src/components/ui/button.tsx apps/web/src/components/ui/input.tsx apps/web/src/routeTree.gen.ts diff --git a/factory/docs/design.md b/factory/docs/design.md index e2e3f59fb88ba..915c186090d7e 100644 --- a/factory/docs/design.md +++ b/factory/docs/design.md @@ -85,11 +85,10 @@ factory/ repo root: SEAMS, sync/build/install scripts 4. `apps/web/src/routeTree.gen.ts` (#28): generated, so a merge regenerates it instead of resolving it by hand. `apps/web/src/routes/settings.factory*` is factory-owned like `routes/factory*`. 5. `apps/web/src/components/settings/settingsSearch.ts` (#55): the `SettingsPath`, section label, search entry and scope map for the factory settings page. 6. `apps/web/src/components/settings/SettingsSidebarNav.tsx` (#55): the icon for that page. -7. `apps/web/src/components/ui/button.tsx` (#61): a `touch` size, 44 px tall below `sm` and the default height from `sm` up. -8. `apps/web/src/components/ui/input.tsx` (#61): the same `touch` size for inputs. -9. `apps/web/src/components/sidebar/mainAppLocation.ts` and its test (#28 follow-up, b6bde560b): `/factory` counts as a utility page, so the sidebar shows Back. +7. `apps/web/src/components/ui/input.tsx` (#61): a `touch` size, 44 px tall below `sm` and the default height from `sm` up. `Button` needs none: its base class already gives every button a 44 px tap area on a coarse pointer. +8. `apps/web/src/components/sidebar/mainAppLocation.ts` and its test (#28 follow-up, b6bde560b): `/factory` counts as a utility page, so the sidebar shows Back. -Nine entries, all in `factory/SEAMS`. Nothing in `ws.ts`, `rpc.ts`, `packages/contracts` or `Migrations.ts`. Those are the files upstream churns most, and adding an RPC would touch five of them. The factory API is a separate HTTP route layer instead. +Eight entries, all in `factory/SEAMS`. Nothing in `ws.ts`, `rpc.ts`, `packages/contracts` or `Migrations.ts`. Those are the files upstream churns most, and adding an RPC would touch five of them. The factory API is a separate HTTP route layer instead. ### Boundary rules, checked by a test @@ -129,7 +128,7 @@ Persistence: JSONL files under `/factory/`, the same layout as `~/.local/s - **Install health check, auto-rollback and the hand-back runbook (#59).** After restarting the job, `install.sh` waits up to `T3FAC_HEALTH_WAIT` seconds (default 90) for the port to accept connections and for a `factory started` or `no factory config` log line written after the restart, and otherwise runs `rollback.sh` and exits 1. `factory/README.md` "Recovery" is the runbook for rolling back by hand, and `factory/docs/handover.md` is the one for handing this repo back to the Rust factory and moving it back. - **A separate log-rotation LaunchAgent (#60).** `io.github.rawphp.t3fac.logrotate` runs `rotate-log.sh` every 600 seconds and copytruncates `~/Library/Logs/t3fac/t3fac.log`, because a crash-looping server writes the most log and is the one that cannot rotate it. `install.sh` and `rollback.sh` copy the script from `factory/scripts/` to `~/.local/share/t3fac/` and reload the job before restarting the server. - **Factory CI's `Lint` job, with the gate unchanged (#56).** `factory-ci.yml` has a `Lint` job beside `Check` that runs `vp check`, the command upstream's own Lint job runs. `check.sh`, and so the gate, do not run it. -- **Remote and phone use through the fork's own served web, with no native mobile screen (#61).** The Factory pages are reached over the tailnet from the fork server's web, paired like any T3 browser. Below 640 px the runs, the gate commands and the ledger are stacked cards and lists, attention items come first, and every control is 44 px tall through a `touch` size on `Button` and `Input` (the two seams 7 and 8). Every factory call, including the settings page's, goes through `factory/http.ts` on the primary environment's prepared connection: its HTTP base, the session cookie or the bearer token, never the page's own origin. A relay (DPoP) connection is reported as unavailable because the factory routes are not in the typed API groups that T3 signs proofs for. `factory/README.md` "Remote" has the pairing steps. The T3 mobile app on phones is upstream's App Store build and never contains fork code, so there is no native factory screen. +- **Remote and phone use through the fork's own served web, with no native mobile screen (#61).** The Factory pages are reached over the tailnet from the fork server's web, paired like any T3 browser. Below 640 px the runs, the gate commands and the ledger are stacked cards and lists, attention items come first, and every control is a 44 px tap target: inputs through a `touch` size on `Input` (seam 7), buttons through the coarse-pointer hit area upstream's `Button` already has, inline links through the factory's own `TouchLink`. Every factory call, including the settings page's, goes through `factory/http.ts` on the primary environment's prepared connection: its HTTP base, the session cookie or the bearer token, never the page's own origin. A relay (DPoP) connection is reported as unavailable because the factory routes are not in the typed API groups that T3 signs proofs for. `factory/README.md` "Remote" has the pairing steps. The T3 mobile app on phones is upstream's App Store build and never contains fork code, so there is no native factory screen. - **Factory HTTP API (#27, #28, #86).** Seven routes in `t3/http.ts`: `GET /api/factory/status`, `GET /api/factory/runs` and `GET /api/factory/runs/:id` need `orchestration:read`; `POST /api/factory/runs/:id/stop`, `POST /api/factory/claims/pause` and `resume` need `orchestration:operate`; `POST /api/factory/restart` needs `environment:maintain`, the scope T3 gives its own process control (`server.signalProcess`, `server.updateServer`), so an MCP client, whose tokens carry only read and operate, cannot end the server. The status is softfac's `status --json` report with the fork's open attention items added, and without the keys the fork has nothing for (`commit`, `self_update_repo`, `disk`, `claims_paused`, `t3_env_holds`, `t3_logs`). `runs` and `runs/:id` are `runs list` and `runs show`. `runs` lists every unfinished run and the finished runs of the last seven days, or every run with `?all=1` (#28), and reads a finished one only at the ends of its file: the first line, and back from the end to its last state change and pull request. A finished file older than the window is not read at all. Only unfinished runs are read whole. The fork accepts softfac's `is_file` filter, so a directory or symlink named like a run file is not a run and holds no slot. A stop leaves a stop request for the next pass, as `stop-run.sh` does, and wakes the loop. The route never writes the ledger: the pass takes the request when the gate does not have the run, then archives the thread and removes the worktree (#20). Pause and resume write and remove `claims-paused`. A restart answers 202 first, then logs `factory restart requested` and ends the process through an injected `ServerExit` (`t3/exit.ts`): SIGTERM to itself, which `runMain` turns into the normal shutdown, with the interrupt's exit code 130 made 0 (a failed teardown keeps its code) and `process.exit(0)` after 10 seconds if the shutdown hangs; the LaunchAgent starts the server again, and a dev server simply exits. A second request while one is pending answers 409 `restart_pending`. Open question 1 (do `layerMakeRoutes` routes get T3's auth) is answered by Decided 1 (#57): these routes use the same `withScope`, so an unauthenticated call gets 401. ## Scope of the experiment (v1)