diff --git a/.github/workflows/lazurio-fork-ci.yml b/.github/workflows/lazurio-fork-ci.yml index 0caadabb4743..11e1ab24ef24 100644 --- a/.github/workflows/lazurio-fork-ci.yml +++ b/.github/workflows/lazurio-fork-ci.yml @@ -64,6 +64,13 @@ jobs: # Configurable release repository and semver release index; proposed upstream. packages/shared/src/cliRelease.test.ts packages/shared/src/cliRelease.ts + # Prepared prompt from the Lazurio shell by link (Lazurio/t3code#35): the link is + # taken off the address at boot and the chat layout opens it as an unsent draft. + apps/web/src/lazurio/LazurioPromptDraft.tsx + apps/web/src/lazurio/promptDraft.test.ts + apps/web/src/lazurio/promptDraft.ts + apps/web/src/main.tsx + apps/web/src/routes/_chat.tsx ) unexpected="$(git diff --name-only "$UPSTREAM_SHA..HEAD" | grep -E '^(apps/(web|mobile|desktop)|packages)/' | @@ -106,6 +113,10 @@ jobs: src/environment/ServerEnvironmentLabel.test.ts pnpm exec vp test run src/server.test.ts + - name: Test the Lazurio web overlay + working-directory: apps/web + run: pnpm exec vp test run src/lazurio + - name: Validate Lazurio release contract run: node --test scripts/lazurio-release-contract.test.mjs diff --git a/apps/web/src/lazurio/LazurioPromptDraft.tsx b/apps/web/src/lazurio/LazurioPromptDraft.tsx new file mode 100644 index 000000000000..d5c324078922 --- /dev/null +++ b/apps/web/src/lazurio/LazurioPromptDraft.tsx @@ -0,0 +1,91 @@ +import { useAtomValue } from "@effect/atom-react"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect, useRef } from "react"; + +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { findProjectByPath, inferProjectTitleFromPath } from "../lib/projectPaths"; +import { newProjectId } from "../lib/utils"; +import { readProjects, waitForProject } from "../state/entities"; +import { projectEnvironment } from "../state/projects"; +import { primaryServerConfigAtom } from "../state/server"; +import { environmentShell } from "../state/shell"; +import { useAtomCommand } from "../state/use-atom-command"; +import { openPromptDraft, takeLazurioPromptLink } from "./promptDraft"; + +// The primary environment once its shell is live: its project list is +// complete, so a missing project is really missing, and commands reach it. +// Null until then (still loading, or disconnected), and the link waits. +const livePrimaryEnvironmentIdAtom = Atom.make((get) => { + const serverConfig = get(primaryServerConfigAtom); + if (serverConfig === null) return null; + const environmentId = serverConfig.environment.environmentId; + return get(environmentShell.stateValueAtom(environmentId)).status === "live" + ? environmentId + : null; +}).pipe(Atom.withLabel("lazurio-prompt-draft-live-primary-environment")); + +/** + * Lazurio overlay (Lazurio/t3code#35): once the primary environment's shell + * is live, opens the prompt a Lazurio link named in a new thread of the + * primary environment's project rooted at the prompt's folder, with the text + * in the composer and nothing sent. Renders nothing; without a link it does + * nothing at all. + */ +export function LazurioPromptDraft() { + const primaryEnvironmentId = useAtomValue(livePrimaryEnvironmentIdAtom); + const handleNewThread = useNewThreadHandler(); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const started = useRef(false); + + useEffect(() => { + if (started.current || primaryEnvironmentId === null) return; + const link = takeLazurioPromptLink(); + if (link === null) return; + started.current = true; + void openPromptDraft(link, { + origin: window.location.origin, + fetch: (url, init) => window.fetch(url, init), + findProject: (cwd) => { + const project = findProjectByPath( + readProjects().filter((candidate) => candidate.environmentId === primaryEnvironmentId), + cwd, + ); + return project ? scopeProjectRef(project.environmentId, project.id) : null; + }, + addProject: async (cwd) => { + const projectId = newProjectId(); + const created = await createProject({ + environmentId: primaryEnvironmentId, + input: { + projectId, + title: inferProjectTitleFromPath(cwd), + workspaceRoot: cwd, + createWorkspaceRootIfMissing: false, + defaultModelSelection: null, + }, + }); + if (created._tag === "Failure") return null; + const projectRef = scopeProjectRef(primaryEnvironmentId, projectId); + await waitForProject(projectRef, 3_000).catch(() => null); + return projectRef; + }, + openThread: (projectRef) => handleNewThread(projectRef), + setPrompt: (draftId, text) => useComposerDraftStore.getState().setPrompt(draftId, text), + }).then((outcome) => { + if (outcome === "failed") { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not open the prepared prompt", + description: "Nothing was added to the composer.", + }), + ); + } + }); + }, [createProject, handleNewThread, primaryEnvironmentId]); + + return null; +} diff --git a/apps/web/src/lazurio/promptDraft.test.ts b/apps/web/src/lazurio/promptDraft.test.ts new file mode 100644 index 000000000000..ea043dee4dba --- /dev/null +++ b/apps/web/src/lazurio/promptDraft.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + capturePromptLink, + fetchPrompt, + openPromptDraft, + type PromptDraftDependencies, + readPromptLink, + stripPromptLink, +} from "./promptDraft"; + +const origin = "https://t3code.vm-01.example.lazurio.io"; +const link = { id: "new-module", organization: "Example-Org" } as const; +const document = { + schema: "lazurio.prompt.v1", + id: "new-module", + text: "I want to found a new module.\n\nAsk me first.", + cwd: "/home/operator/Lazurio/organizations/example_GEN3", +}; + +function answer(body: unknown, init: ResponseInit & { url?: string } = {}): Response { + const response = new Response(typeof body === "string" ? body : JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json; charset=utf-8" }, + ...init, + }); + Object.defineProperty(response, "url", { + value: init.url ?? `${origin}/.lazurio/prompts/new-module?org=Example-Org`, + }); + return response; +} + +describe("the link", () => { + it("carries only a prompt id and an Organization login, in the fragment", () => { + expect( + readPromptLink( + new URL(`${origin}/pair#token=abc&lazurio-prompt=new-module&lazurio-org=Example-Org`), + ), + ).toEqual(link); + expect( + readPromptLink(new URL(`${origin}/#lazurio-prompt=new-module&lazurio-org=Example-Org`)), + ).toEqual(link); + }); + + it("refuses text, a query, a missing or repeated part and anything outside the grammar", () => { + for (const href of [ + `${origin}/#lazurio-prompt=new-module`, + `${origin}/#lazurio-org=Example-Org`, + `${origin}/?lazurio-prompt=new-module&lazurio-org=Example-Org`, + `${origin}/#lazurio-prompt=new-module&lazurio-org=Example-Org&lazurio-prompt=other`, + `${origin}/#lazurio-prompt=Do%20this%20now&lazurio-org=Example-Org`, + `${origin}/#lazurio-prompt=new-module&lazurio-org=a%2Fb`, + `${origin}/#lazurio-prompt=new-module&lazurio-org=-x`, + `${origin}/#lazurio-prompt=${"a".repeat(65)}&lazurio-org=Example-Org`, + `${origin}/#lazurio-text=hello&lazurio-org=Example-Org`, + `${origin}/pair#token=abc`, + ]) { + expect([href, readPromptLink(new URL(href))]).toEqual([href, null]); + } + }); + + it("is stripped from the address, leaving everything else as it was", () => { + expect( + stripPromptLink( + new URL(`${origin}/pair#token=abc&lazurio-prompt=new-module&lazurio-org=Example-Org`), + ).href, + ).toBe(`${origin}/pair#token=abc`); + expect( + stripPromptLink(new URL(`${origin}/#lazurio-prompt=new-module&lazurio-org=Example-Org`)).href, + ).toBe(`${origin}/`); + expect(stripPromptLink(new URL(`${origin}/pair#token=abc`)).href).toBe( + `${origin}/pair#token=abc`, + ); + }); + + it("is taken once at boot, before the router reads the address", () => { + const replace = vi.fn(); + const location = { + href: `${origin}/pair#token=abc&lazurio-prompt=new-module&lazurio-org=Example-Org`, + }; + const take = capturePromptLink(location, replace); + expect(replace).toHaveBeenCalledWith(`${origin}/pair#token=abc`); + expect(take()).toEqual(link); + expect(take()).toBeNull(); + const untouched = vi.fn(); + expect(capturePromptLink({ href: `${origin}/pair#token=abc` }, untouched)()).toBeNull(); + expect(untouched).not.toHaveBeenCalled(); + }); +}); + +describe("the text", () => { + it("comes only from this origin's /.lazurio/prompts/, without credentials elsewhere", async () => { + const fetcher = vi.fn(async () => answer(document)); + await expect(fetchPrompt(link, origin, fetcher)).resolves.toEqual({ + text: document.text, + cwd: document.cwd, + }); + expect(fetcher).toHaveBeenCalledTimes(1); + const [url, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(`${origin}/.lazurio/prompts/new-module?org=Example-Org`); + expect(init).toMatchObject({ + credentials: "same-origin", + redirect: "error", + cache: "no-store", + }); + }); + + it("is nothing for an unknown id, a failed fetch, a non-JSON or wrong answer, or another origin", async () => { + const cases: Array<[string, () => Promise]> = [ + ["404", async () => answer({ error: "not-found" }, { status: 404 })], + [ + "network", + async () => { + throw new TypeError("Failed to fetch"); + }, + ], + ["html", async () => answer("", { headers: { "content-type": "text/html" } })], + ["not json", async () => answer("{", { headers: { "content-type": "application/json" } })], + ["schema", async () => answer({ ...document, schema: "lazurio.prompt.v2" })], + ["id", async () => answer({ ...document, id: "other" })], + ["empty text", async () => answer({ ...document, text: " " })], + ["long text", async () => answer({ ...document, text: "x".repeat(16 * 1024 + 1) })], + ["text type", async () => answer({ ...document, text: 42 })], + ["relative cwd", async () => answer({ ...document, cwd: "organizations/example" })], + ["no cwd", async () => answer({ ...document, cwd: undefined })], + [ + "other origin", + async () => answer(document, { url: "https://evil.example/.lazurio/prompts/new-module" }), + ], + ["array", async () => answer([document])], + ]; + for (const [name, fetcher] of cases) { + await expect([name, await fetchPrompt(link, origin, fetcher)]).toEqual([name, null]); + } + }); +}); + +function dependencies(overrides: Partial = {}) { + const calls: string[] = []; + const projectRef = { environmentId: "primary", projectId: "project-1" } as never; + const base: PromptDraftDependencies = { + origin, + fetch: async () => answer(document), + findProject: (cwd) => { + calls.push(`find ${cwd}`); + return projectRef; + }, + addProject: async (cwd) => { + calls.push(`add ${cwd}`); + return projectRef; + }, + openThread: async () => { + calls.push("open"); + return { draftId: "draft-1" as never }; + }, + setPrompt: (draftId, text) => { + calls.push(`prompt ${String(draftId)} ${text}`); + }, + }; + return { calls, dependencies: { ...base, ...overrides } }; +} + +describe("the draft", () => { + it("opens a new thread in the project rooted at cwd and leaves the text in its composer", async () => { + const { calls, dependencies: deps } = dependencies(); + await expect(openPromptDraft(link, deps)).resolves.toBe("opened"); + expect(calls).toEqual([`find ${document.cwd}`, "open", `prompt draft-1 ${document.text}`]); + }); + + it("adds the Organization's folder as a project when there is none", async () => { + const { calls, dependencies: deps } = dependencies({ + findProject: (cwd) => { + calls.push(`find ${cwd}`); + return null; + }, + }); + await expect(openPromptDraft(link, deps)).resolves.toBe("opened"); + expect(calls).toEqual([ + `find ${document.cwd}`, + `add ${document.cwd}`, + "open", + `prompt draft-1 ${document.text}`, + ]); + }); + + it("asks again when another navigation overtook the new thread", async () => { + let attempts = 0; + const { calls, dependencies: deps } = dependencies({ + openThread: async () => { + attempts += 1; + calls.push("open"); + return attempts === 1 ? null : { draftId: "draft-2" as never }; + }, + }); + await expect(openPromptDraft(link, deps)).resolves.toBe("opened"); + expect(calls.slice(1)).toEqual(["open", "open", `prompt draft-2 ${document.text}`]); + }); + + it("inserts nothing when the text, the project or the thread is not there", async () => { + for (const overrides of [ + { fetch: async () => answer({ error: "not-found" }, { status: 404 }) }, + { findProject: () => null, addProject: async () => null }, + { openThread: async () => null }, + { + openThread: async () => { + throw new Error("navigation failed"); + }, + }, + ] satisfies Array>) { + const { calls, dependencies: deps } = dependencies(overrides); + await expect(openPromptDraft(link, deps)).resolves.toBe("failed"); + expect(calls.some((call) => call.startsWith("prompt"))).toBe(false); + } + }); +}); diff --git a/apps/web/src/lazurio/promptDraft.ts b/apps/web/src/lazurio/promptDraft.ts new file mode 100644 index 000000000000..c013f896909e --- /dev/null +++ b/apps/web/src/lazurio/promptDraft.ts @@ -0,0 +1,171 @@ +import type { ScopedProjectRef } from "@t3tools/contracts"; + +import type { DraftId } from "../composerDraftStore"; + +/** + * Lazurio overlay: a prepared prompt opened by link, left unsent in a new + * thread's composer (Lazurio/t3code#35). + * + * The Lazurio Launchpad of the Environment opens Chat with + * `#lazurio-prompt=&lazurio-org=` in the fragment, after the + * pairing token when there is one. A link never carries text: the text and + * the project folder come from this page's own origin, where the + * Environment's gateway serves `/.lazurio/prompts/?org=` from the + * Launchpad behind the same sign-in. Anything else (an unknown id, a failed + * or redirected fetch, an answer of another shape) puts nothing in the + * composer, and nothing here ever sends a message. + */ + +export interface PromptLink { + readonly id: string; + readonly organization: string; +} + +export interface PreparedPrompt { + readonly text: string; + /** The absolute root of the project the new thread opens in. */ + readonly cwd: string; +} + +const PROMPT_PARAM = "lazurio-prompt"; +const ORGANIZATION_PARAM = "lazurio-org"; +const PROMPT_ID = /^[a-z][a-z0-9-]{0,63}$/; +const GITHUB_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/; +const PROMPT_SCHEMA = "lazurio.prompt.v1"; +const MAX_PROMPT_LENGTH = 16 * 1024; +const ABSOLUTE_PATH = /^(?:\/|[A-Za-z]:[\\/])/; + +const fragmentOf = (url: URL) => new URLSearchParams(url.hash.slice(1)); + +/** The prompt a link names, or null: exactly one id and one login, of their grammar. */ +export function readPromptLink(url: URL): PromptLink | null { + const fragment = fragmentOf(url); + const ids = fragment.getAll(PROMPT_PARAM); + const organizations = fragment.getAll(ORGANIZATION_PARAM); + const [id] = ids; + const [organization] = organizations; + if (ids.length !== 1 || organizations.length !== 1 || !id || !organization) return null; + if (!PROMPT_ID.test(id) || !GITHUB_LOGIN.test(organization)) return null; + return { id, organization }; +} + +/** The same address without the prompt's parameters; everything else stays as it was. */ +export function stripPromptLink(url: URL): URL { + const next = new URL(url.href); + const fragment = fragmentOf(next); + if (!fragment.has(PROMPT_PARAM) && !fragment.has(ORGANIZATION_PARAM)) return next; + fragment.delete(PROMPT_PARAM); + fragment.delete(ORGANIZATION_PARAM); + next.hash = fragment.toString(); + return next; +} + +/** + * Takes the prompt link off the address once, at boot, before the router or + * the pairing read it. Returns the reader of the link taken: it hands it over + * once and is empty afterwards. Kept in memory only. + */ +export function capturePromptLink( + location: { readonly href: string }, + replace: (href: string) => void, +): () => PromptLink | null { + const url = new URL(location.href); + let pending = readPromptLink(url); + const stripped = stripPromptLink(url); + if (stripped.href !== url.href) replace(stripped.href); + return () => { + const taken = pending; + pending = null; + return taken; + }; +} + +/** The prompt's text and project folder from this origin, or null on anything unexpected. */ +export async function fetchPrompt( + link: PromptLink, + origin: string, + fetcher: (url: string, init: RequestInit) => Promise, +): Promise { + const url = new URL(`/.lazurio/prompts/${encodeURIComponent(link.id)}`, origin); + url.searchParams.set("org", link.organization); + try { + const response = await fetcher(url.href, { + credentials: "same-origin", + redirect: "error", + cache: "no-store", + headers: { accept: "application/json" }, + }); + if (!response.ok || new URL(response.url).origin !== url.origin) return null; + if (!(response.headers.get("content-type") ?? "").startsWith("application/json")) return null; + const value: unknown = await response.json(); + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const { schema, id, text, cwd } = value as Record; + if (schema !== PROMPT_SCHEMA || id !== link.id) return null; + if (typeof text !== "string" || text.trim().length === 0 || text.length > MAX_PROMPT_LENGTH) { + return null; + } + if (typeof cwd !== "string" || !ABSOLUTE_PATH.test(cwd)) return null; + return { text, cwd }; + } catch { + return null; + } +} + +/** What opening a prompt needs from the app; none of it can send a message. */ +export interface PromptDraftDependencies { + readonly origin: string; + readonly fetch: (url: string, init: RequestInit) => Promise; + /** The primary environment's project rooted exactly at `cwd`, if any. */ + readonly findProject: (cwd: string) => ScopedProjectRef | null; + /** Adds `cwd` as a project of the primary environment, as "Add project" does. */ + readonly addProject: (cwd: string) => Promise; + /** Opens a new thread draft in the project; null when another navigation overtook it. */ + readonly openThread: (projectRef: ScopedProjectRef) => Promise<{ draftId: DraftId } | null>; + readonly setPrompt: (draftId: DraftId, text: string) => void; +} + +const OPEN_ATTEMPTS = 3; + +/** + * Opens the prompt a link named: a new thread in the project rooted at its + * folder (added as a project when there is none), with the text in the + * composer, unsent. + */ +export async function openPromptDraft( + link: PromptLink, + dependencies: PromptDraftDependencies, +): Promise<"opened" | "failed"> { + const prompt = await fetchPrompt(link, dependencies.origin, dependencies.fetch); + if (prompt === null) return "failed"; + try { + const projectRef = + dependencies.findProject(prompt.cwd) ?? (await dependencies.addProject(prompt.cwd)); + if (projectRef === null) return "failed"; + // The index route opens a draft of the latest project at the same time; + // whichever navigation lands second makes the other one return null. + for (let attempt = 0; attempt < OPEN_ATTEMPTS; attempt += 1) { + const opened = await dependencies.openThread(projectRef); + if (opened !== null) { + dependencies.setPrompt(opened.draftId, prompt.text); + return "opened"; + } + } + return "failed"; + } catch { + return "failed"; + } +} + +let takePending: () => PromptLink | null = () => null; + +/** Call once in `main.tsx`, before the router is created. */ +export function captureLazurioPromptLink(): void { + takePending = capturePromptLink(window.location, (href) => + window.history.replaceState(window.history.state, "", href), + ); +} + +/** The link taken at boot, once. */ +export function takeLazurioPromptLink(): PromptLink | null { + return takePending(); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 81d1c2140af6..ed433720d21f 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -14,8 +14,11 @@ import { } from "./lib/windowControlsOverlay"; import { AppRoot } from "./AppRoot"; import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; +import { captureLazurioPromptLink } from "./lazurio/promptDraft"; prepareProviderAuthDelivery(); +// Lazurio overlay: take a prompt link off the address before the router reads it. +captureLazurioPromptLink(); // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 0a0b1adee06b..79f83aa9490a 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -27,6 +27,7 @@ import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { primaryServerKeybindingsAtom } from "~/state/server"; +import { LazurioPromptDraft } from "~/lazurio/LazurioPromptDraft"; function ChatRouteGlobalShortcuts() { const clearSelection = useThreadSelectionStore((state) => state.clearSelection); @@ -216,6 +217,7 @@ function ChatRouteLayout() { return ( <> + {threadTarget ? : } ); diff --git a/docs/operations/lazurio-fork-release.md b/docs/operations/lazurio-fork-release.md index ff15793abfd0..7df71f604ad5 100644 --- a/docs/operations/lazurio-fork-release.md +++ b/docs/operations/lazurio-fork-release.md @@ -90,6 +90,7 @@ nabídne normálně. | `hosted: explicit environment label` | `T3CODE_ENVIRONMENT_LABEL` pojmenuje kontejnerový Workspace (například `Acme / Management`). | retain: upstream čte jen `PRETTY_HOSTNAME` a hostname, Machines nastavují proměnnou | | `feat: in-app update from the configured release channel` | `T3CODE_RELEASE_REPOSITORY` a server-advertised `availableServerUpdate`: Mašina nabízí aktualizaci na nejvyšší vydání z nastaveného repozitáře a instaluje ho tlačítkem Update. Navrženo upstreamu. | retain: upstream má pevné `pingdotgg/t3code` a bere první vydání kanálu | | `release: Lazurio distribution` | Tento dokument, `Dockerfile.lazurio`, `.dockerignore`, kontraktní test a workflow `lazurio-fork-ci.yml`, `lazurio-cli-archives.yml` a `lazurio-release.yml`. | retain | +| `lazurio: unsent prompt draft by link from the shell` | „+ Nový modul“ otevře Chat se zadáním v poli zprávy nového vlákna, neodeslaným ([Zadání z Launchpadu](#zadání-z-launchpadu)). | retain: upstream nemá vstup pro koncept zprávy zvenku | Nenastavené proměnné zachovají upstream chování. Commit odstraň, jakmile upstream nabídne ekvivalent. Klienty, sdílené balíčky a wire kontrakty @@ -143,6 +144,50 @@ vývoj upstreamu) zůstanou elementy nedefinované, šířka railu je 0 a klient chová jako upstream. Oficiální desktop a mobilní aplikace jsou upstream a shell nemají. +### Zadání z Launchpadu + +**Implementováno (Lazurio/t3code#35).** „+ Nový modul“ v Apps otevře Chat +téhož Environmentu s připraveným zadáním v poli zprávy nového vlákna. Zadání +se nikdy neodešle samo; odešle ho až člověk. + +- Launchpad otevře Chat přes párování a za token přidá do fragmentu + `lazurio-prompt=&lazurio-org=`, bez párování + na holý origin. Odkaz nikdy nenese text. Fragment se neposílá na server ani + do logu a T3 bez overlaye ho ignoruje. +- `main.tsx` při startu odkaz z adresy odebere (`captureLazurioPromptLink`), + dřív než ho přečte router nebo párování; token zůstane. +- Až je primární environment načtený, `` v layoutu + `_chat` stáhne text z vlastního originu: + `GET /.lazurio/prompts/?org=` (brána Environmentu ho za stejným + přihlášením předá Launchpadu), `credentials: "same-origin"`, + `redirect: "error"`. Přijme jen JSON `lazurio.prompt.v1` se stejným `id`, + neprázdným `text` do 16 KiB a absolutním `cwd`. +- Otevře nové vlákno v projektu primárního environmentu s kořenem `cwd`. + Když takový projekt není, přidá složku jako projekt, jako to dělá „Add + project“, ale bez zakládání složky. Text vloží do pole zprávy, nic + neodešle. +- Neznámé `id`, neúspěšný nebo přesměrovaný fetch, jiný tvar odpovědi nebo + cizí origin nevloží nic. Člověk uvidí jen chybový toast „Could not open the + prepared prompt“. +- Kdo zadání dostane, rozhoduje Launchpad. Dnes jediné zadání `new-module` + dostane jen Environment, jehož GitHub identita je Owner Organizace. Komukoli + jinému odpoví stejným 404. + +Launchpad podává zadání odkazem jen tam, kde to T3 na Environmentu umí: zeptá +se `t3 --version` a odkaz použije od prvního vydání s tímto overlayem +(`0.0.45-lazurio.2`, preview od `0.0.45-preview.20261004.1`). Jinde zadání +zkopíruje do schránky jako dřív. **Při vydání ověř, že první vydání s tímto +commitem má právě tato čísla.** Jinak je oprav v Lazurio/LazurioPlatform +(`chatPromptsSince` v `src/launchpad/chat.ts`). + +Overlay se dotýká jen dvou řádků upstream souborů: zachycení v `main.tsx` +a `` v `routes/_chat.tsx`. Zbytek je ve složce +`apps/web/src/lazurio/`. Všech pět souborů je v allowlistu. Kontraktní test +hlídá, že zachycení proběhne před vytvořením routeru, že layout komponentu +vykreslí, že text přichází jen z vlastního originu bez přesměrování a že +overlay nemá cestu, jak zprávu odeslat. CI spouští testy overlaye +(`vp test run src/lazurio`). + ## Kdy vydávat - **Nový upstream stable** (`pingdotgg/t3code` vydal `vX.Y.Z`): přestav diff --git a/scripts/lazurio-release-contract.test.mjs b/scripts/lazurio-release-contract.test.mjs index 13507bfafe22..b01fc1d435cb 100644 --- a/scripts/lazurio-release-contract.test.mjs +++ b/scripts/lazurio-release-contract.test.mjs @@ -7,6 +7,13 @@ import * as NodeTest from "node:test"; import { cliReleaseChannelOf } from "../packages/shared/src/cliRelease.ts"; const read = (path) => NodeFSP.readFile(path, "utf8"); +const promptOverlay = [ + "apps/web/src/lazurio/LazurioPromptDraft.tsx", + "apps/web/src/lazurio/promptDraft.test.ts", + "apps/web/src/lazurio/promptDraft.ts", + "apps/web/src/main.tsx", + "apps/web/src/routes/_chat.tsx", +]; const [release, archives, ci, dockerfile, dockerignore, docs, updateSource] = await Promise.all([ read(".github/workflows/lazurio-release.yml"), read(".github/workflows/lazurio-cli-archives.yml"), @@ -220,3 +227,44 @@ NodeTest.test("the overlay may change only listed upstream client and shared fil } NodeAssert.equal(new Set(entries).size, entries.length, "allowlist entries must be unique"); }); + +// Lazurio/t3code#35: the Lazurio shell opens Chat with a prompt id and an +// Organization login in the link; the overlay fetches the text from its own +// origin and leaves it unsent in a new thread's composer. The seam is two +// lines in upstream files, so a rebase onto a new upstream tag that loses +// either fails here. +NodeTest.test("the prompt hand-off overlay keeps its seam and never sends", async () => { + const [main, chatLayout, promptDraft, component] = await Promise.all( + [ + "apps/web/src/main.tsx", + "apps/web/src/routes/_chat.tsx", + "apps/web/src/lazurio/promptDraft.ts", + "apps/web/src/lazurio/LazurioPromptDraft.tsx", + ].map(read), + ); + // The link leaves the address before the router or the pairing read it. + const capture = main.indexOf("\ncaptureLazurioPromptLink();\n"); + NodeAssert.ok(capture > 0, "main.tsx must capture the prompt link"); + NodeAssert.ok(capture < main.indexOf("createBrowserHistory()"), "capture before the history"); + NodeAssert.ok(capture < main.indexOf("getRouter(history)"), "capture before the router"); + // The chat layout, which renders only once the environment is signed in, opens it. + NodeAssert.match(chatLayout, //); + // Text only from this origin's /.lazurio/prompts/, never from the link. + NodeAssert.match( + promptDraft, + /new URL\(`\/\.lazurio\/prompts\/\$\{encodeURIComponent\(link\.id\)\}`, origin\)/, + ); + NodeAssert.match(promptDraft, /credentials: "same-origin"/); + NodeAssert.match(promptDraft, /redirect: "error"/); + NodeAssert.match(component, /origin: window\.location\.origin/); + // Nothing here can start a turn: it only writes the composer draft. + for (const source of [promptDraft, component]) { + NodeAssert.doesNotMatch(source, /turn\.start|startTurn|dispatchCommand|queuedMessage|onSend/); + } + NodeAssert.match(component, /\.setPrompt\(draftId, text\)/); + // Every overlay file is a reviewed allowlist entry. + for (const file of promptOverlay) { + NodeAssert.match(ci, new RegExp(`\\n {12}${file.replaceAll(".", "\\.")}\\n`), file); + } + NodeAssert.match(ci, /working-directory: apps\/web\n\s+run: pnpm exec vp test run src\/lazurio/); +});