From 997bbaa0c819de7645c124eb8f755701d2263f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sun, 4 Oct 2026 10:22:40 +0200 Subject: [PATCH 1/3] lazurio: unsent prompt draft by link from the shell The Lazurio Launchpad opens Chat with lazurio-prompt= and lazurio-org= in the link's fragment (after the pairing token when there is one). The overlay takes both off the address at boot, before the router or the pairing read it, and accepts only an id and a GitHub login, never text. Once the primary environment is loaded, it fetches the text from its own origin's /.lazurio/prompts/?org= (the Environment's gateway proxies that path to the Launchpad behind the same sign-in), with same-origin credentials and redirects refused, and accepts only lazurio.prompt.v1 with the same id, a non-empty text up to 16 KiB and an absolute cwd. It then opens a new thread in the primary environment's project rooted at cwd, adding that folder as a project when there is none (as Add project does, without creating the folder), and puts the text in the composer. Nothing is sent: the overlay can only write the composer draft. An unknown id, a failed or redirected fetch or an answer of another shape inserts nothing and shows one error toast. The seam in upstream files is two lines: the capture in main.tsx and in the chat layout. All five files join the allowlist under one reason; CI runs the overlay's tests, and the release contract test fails when a rebase drops either line, lets the text come from anywhere but this origin, or gives the overlay a way to send. Refs #35. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/lazurio-fork-ci.yml | 11 + apps/web/src/lazurio/LazurioPromptDraft.tsx | 81 ++++++++ apps/web/src/lazurio/promptDraft.test.ts | 215 ++++++++++++++++++++ apps/web/src/lazurio/promptDraft.ts | 171 ++++++++++++++++ apps/web/src/main.tsx | 3 + apps/web/src/routes/_chat.tsx | 2 + scripts/lazurio-release-contract.test.mjs | 48 +++++ 7 files changed, 531 insertions(+) create mode 100644 apps/web/src/lazurio/LazurioPromptDraft.tsx create mode 100644 apps/web/src/lazurio/promptDraft.test.ts create mode 100644 apps/web/src/lazurio/promptDraft.ts 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..260626371939 --- /dev/null +++ b/apps/web/src/lazurio/LazurioPromptDraft.tsx @@ -0,0 +1,81 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +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, + useAllEnvironmentShellsBootstrapped, + waitForProject, +} from "../state/entities"; +import { usePrimaryEnvironmentId } from "../state/environments"; +import { projectEnvironment } from "../state/projects"; +import { useAtomCommand } from "../state/use-atom-command"; +import { openPromptDraft, takeLazurioPromptLink } from "./promptDraft"; + +/** + * Lazurio overlay (Lazurio/t3code#35): once the primary environment's shell + * is loaded, 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 = usePrimaryEnvironmentId(); + const bootstrapped = useAllEnvironmentShellsBootstrapped(); + const handleNewThread = useNewThreadHandler(); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const started = useRef(false); + + useEffect(() => { + if (started.current || !bootstrapped || 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.", + }), + ); + } + }); + }, [bootstrapped, 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/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/); +}); From c2c3e817e30fbdd0fe80b6c269e9a31b08fa9c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sun, 4 Oct 2026 10:22:41 +0200 Subject: [PATCH 2/3] docs: record the prompt hand-off overlay in the fork runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay table gains the commit "lazurio: unsent prompt draft by link from the shell" and a section "Zadání z Launchpadu" says what the link carries, where the text comes from, which project the thread opens in, what inserts nothing, who may get a prompt (the Launchpad decides), and which release numbers the Launchpad treats as the first with the hand-off, to be confirmed when that release is cut. Co-Authored-By: Claude Opus 5.5 --- docs/operations/lazurio-fork-release.md | 45 +++++++++++++++++++++++++ 1 file changed, 45 insertions(+) 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 From c175c2b5c6bb6ab7e739dab10ee0c64f56a8cc68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sun, 4 Oct 2026 10:52:18 +0200 Subject: [PATCH 3/3] lazurio: open the prompt only once the primary environment is live The overlay waited for every environment's shell to be bootstrapped, which also turns true when the primary environment is disconnected without a snapshot. Then its project list is empty, so the overlay would try to add the Organization's folder as a new project (a duplicate when it already exists) against an environment its commands cannot reach, and fail with the link already taken. It now waits until the primary environment's shell is live: its project list is complete and commands reach it. Until then the link stays in memory, and a reconnect in the same page load opens it. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lazurio/LazurioPromptDraft.tsx | 32 ++++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/web/src/lazurio/LazurioPromptDraft.tsx b/apps/web/src/lazurio/LazurioPromptDraft.tsx index 260626371939..d5c324078922 100644 --- a/apps/web/src/lazurio/LazurioPromptDraft.tsx +++ b/apps/web/src/lazurio/LazurioPromptDraft.tsx @@ -1,4 +1,6 @@ +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"; @@ -6,32 +8,40 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { findProjectByPath, inferProjectTitleFromPath } from "../lib/projectPaths"; import { newProjectId } from "../lib/utils"; -import { - readProjects, - useAllEnvironmentShellsBootstrapped, - waitForProject, -} from "../state/entities"; -import { usePrimaryEnvironmentId } from "../state/environments"; +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 loaded, opens the prompt a Lazurio link named in a new thread of the + * 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 = usePrimaryEnvironmentId(); - const bootstrapped = useAllEnvironmentShellsBootstrapped(); + const primaryEnvironmentId = useAtomValue(livePrimaryEnvironmentIdAtom); const handleNewThread = useNewThreadHandler(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); const started = useRef(false); useEffect(() => { - if (started.current || !bootstrapped || primaryEnvironmentId === null) return; + if (started.current || primaryEnvironmentId === null) return; const link = takeLazurioPromptLink(); if (link === null) return; started.current = true; @@ -75,7 +85,7 @@ export function LazurioPromptDraft() { ); } }); - }, [bootstrapped, createProject, handleNewThread, primaryEnvironmentId]); + }, [createProject, handleNewThread, primaryEnvironmentId]); return null; }