diff --git a/CONTEXT.md b/CONTEXT.md index 120d9431..20fb5e22 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -380,9 +380,9 @@ A Deployment Task Timeline section for one Deployment Result Resource, presentin ### Deployment Task Success Record -The conclusion a Deployment Task Timeline appends once Deployment Result Readiness is reached and every required access endpoint has passed its protocol probe. It carries only facts the deployment declared — product name, verified HTTP or WebSocket entries, first-use steps — so the Timeline never presents an address or instruction the runner cannot evidence. Each entry is headed the way a Public Address is shown everywhere else: the Port Display Name form of the App Listening Port it reaches (`game · 5200`, or `5200` alone for an unnamed port), as it stood at verification time; an entry no App Listening Port can be found for keeps the name its source declared. Two entries reaching the same port carry the same heading; the record stays a flat list. A record with a single entry heads it with nothing, whatever heading its source would have given it, as the node draws a lone Public Address. HTTP(S) entries can be opened and copied; WS(S) entries are copied. Its Open control opens the Default Open Port through its best Public Address as decided when the record was written — the record is a snapshot, so a later rename or Default Open Port change does not rewrite it. A verified deployment with no endpoint uses the neutral `Deployment completed` headline, while `You can start using it` is reserved for a verified actionable entry. A task with no required Deployment Result Resource publishes no record and keeps reporting progress. It is part of the task-owned timeline snapshot, not a Chat message or a toast, and its Timeline revision doubles as its identity. +The conclusion a Deployment Task Timeline appends once Deployment Result Readiness is reached and every required access endpoint has passed its protocol probe. It carries only facts the deployment declared — product name, verified HTTP or WebSocket entries, first-use steps — so the Timeline never presents an address or instruction the runner cannot evidence. Each entry is headed the way a Public Address is shown everywhere else: the Port Display Name form of the App Listening Port it reaches (`game · 5200`, or `5200` alone for an unnamed port), as it stood at verification time; an entry no App Listening Port can be found for keeps the name its source declared. Two entries reaching the same port carry the same heading; the record stays a flat list. A record with a single entry heads it with nothing, whatever heading its source would have given it, as the node draws a lone Public Address. HTTP(S) entries can be opened and copied; WS(S) entries are copied. Its Open control opens the Default Open Port through its best Public Address as decided when the record was written — the record is a snapshot, so a later rename or Default Open Port change does not rewrite it. A verified deployment with no endpoint uses the neutral `Deployment completed` headline, while `You can start using it` is reserved for a verified actionable entry. A task with no required Deployment Result Resource publishes no record and keeps reporting progress. It is part of the task-owned timeline snapshot, not a Chat message or a toast, and its Timeline revision doubles as its identity. Its primary HTTP(S) entry may be shared — copied, shown as a QR code, or posted to a social network — as the product's own public address; this shares nothing of Brain and is not Public Project Preview Sharing, which no longer exists. Its first-use steps appear under the user-facing heading `Next steps`, and only when the deployment declared them; a record without declared steps shows no heading. A record written for a template deployment also snapshots the template's catalog name as its product id and the template's declared categories (`game`, `ai`, …) as they stood when the task was created, so the share copy can speak to a game or an AI app without asking the catalog again; other sources declare neither. -_Avoid_: success toast, deploy done banner, completion notification, "Public address" as an entry heading. +_Avoid_: success toast, deploy done banner, completion notification, "Public address" as an entry heading, share project, share deployment. ### Deployment Celebration diff --git a/apps/ui/package.json b/apps/ui/package.json index 1dc07f82..a300e6cb 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -50,6 +50,7 @@ "nuqs": "^2.8.9", "ogl": "^1.0.11", "pg": "^8.20.0", + "qrcode.react": "^4.2.0", "react": "19.2.6", "react-dom": "19.2.6", "react-zoom-pan-pinch": "^3.7.0", diff --git a/apps/ui/src/features/chat/tool/chat-deploy-task-tool.test.ts b/apps/ui/src/features/chat/tool/chat-deploy-task-tool.test.ts index 956edad1..84b84b28 100644 --- a/apps/ui/src/features/chat/tool/chat-deploy-task-tool.test.ts +++ b/apps/ui/src/features/chat/tool/chat-deploy-task-tool.test.ts @@ -482,3 +482,90 @@ test("chat createDeployTask refuses behind the pre-deploy wall and never creates }); assert.equal(createCalls, 0); }); + +const templateSource = { + args: { port: "25565" }, + kind: "template", + templateName: "eaglercraft-server", +} as const; + +function templateCatalogItem(name: string, category: string[]) { + return { + args: [], + category, + description: `${name} template`, + icon: "", + name, + readme: "", + sourceRepos: [], + title: name, + }; +} + +async function createTemplateTask( + listTemplateCatalog: () => Promise[]> +) { + const sources: unknown[] = []; + const { createDeployTaskTools } = await import("./chat-deploy-task-tool"); + const deployTaskTools = createDeployTaskTools(githubToolOptions(), { + createDeployTaskAction: (_context, input) => { + sources.push(input.create.source); + return Promise.resolve({ + kind: "created", + launched: null, + task: { id: "task-11" } as never, + }); + }, + getDeployTaskEngineContext: () => null as never, + getDeployTaskSnapshot: () => Promise.resolve(null), + listTemplateCatalog, + runDeployTask: () => Promise.resolve(), + toDeployTaskDTO: (task: unknown) => task as never, + }); + assert.ok(deployTaskTools.createDeployTask.execute); + const result = await deployTaskTools.createDeployTask.execute( + { + intention: "deploy the eaglercraft template", + source: templateSource, + target: { kind: "newProject", displayName: "eaglercraft" }, + }, + { context: {}, messages: [], toolCallId: "tool-call-11" } + ); + assert.ok(result != null && "ok" in result && result.ok); + assert.equal(sources.length, 1); + return sources[0]; +} + +test("chat createDeployTask snapshots the template's catalog categories into the source", async () => { + const source = await createTemplateTask(() => + Promise.resolve([ + templateCatalogItem("memos", ["tool"]), + templateCatalogItem("eaglercraft-server", ["game", "tool"]), + ]) + ); + assert.deepEqual(source, { + ...templateSource, + templateCategories: ["game", "tool"], + }); +}); + +test("chat createDeployTask never lets the model declare template categories", () => { + const parsed = createDeployTaskToolInputSchema.parse({ + intention: "deploy the eaglercraft template", + source: { ...templateSource, templateCategories: ["ai"] }, + target: { kind: "newProject" }, + }); + assert.equal("templateCategories" in parsed.source, false); +}); + +test("chat createDeployTask still creates a template task when the catalog cannot answer", async () => { + const unknown = await createTemplateTask(() => + Promise.resolve([templateCatalogItem("memos", ["tool"])]) + ); + assert.deepEqual(unknown, templateSource); + + const unreachable = await createTemplateTask(() => + Promise.reject(new Error("TEMPLATE_PROVIDER_URL is not configured.")) + ); + assert.deepEqual(unreachable, templateSource); +}); diff --git a/apps/ui/src/features/chat/tool/chat-deploy-task-tool.ts b/apps/ui/src/features/chat/tool/chat-deploy-task-tool.ts index a4003e79..8b7bd247 100644 --- a/apps/ui/src/features/chat/tool/chat-deploy-task-tool.ts +++ b/apps/ui/src/features/chat/tool/chat-deploy-task-tool.ts @@ -40,9 +40,11 @@ import { toDeployTaskDTO, } from "@/features/deploy/task/service"; import { + type DeploymentTaskSource, type DeploymentTaskTarget, submitDeployTaskInputSchema, } from "@/features/deploy/task/types"; +import { listTemplateCatalog } from "@/features/deploy/template-provider-core"; import { IdentityBindingSupersededError } from "@/lib/identity-fingerprint-core"; const GITHUB_CONNECTION_REQUIRED_ERROR = @@ -108,6 +110,7 @@ export function createDeployTaskTools( getDeployTaskSnapshot?: typeof getDeployTaskSnapshot; getDeployTaskTimelineSnapshot?: typeof getDeployTaskTimelineSnapshot; judgeWorkspaceBillingStandingForActor?: typeof judgeWorkspaceBillingStandingForActor; + listTemplateCatalog?: typeof listTemplateCatalog; runDeployTask?: typeof runDeployTask; submitDeployTaskInputAction?: typeof submitDeployTaskInputAction; toDeployTaskDTO?: typeof toDeployTaskDTO; @@ -135,6 +138,34 @@ export function createDeployTaskTools( dependencies.adoptLegacyGithubConnectionForOwner ?? adoptLegacyGithubConnectionForOwner; + const readCatalog = dependencies.listTemplateCatalog ?? listTemplateCatalog; + /** + * A template source snapshots the catalog's declared categories into the + * task so the Deployment Task Success Record can speak to a game or an AI + * app later (AIM-354). The model is never trusted to copy them: they are + * read off the catalog by template name here, the way the panes read them + * off the chosen catalog item. An unreachable catalog or an unknown name + * leaves the source as declared — the deploy itself does not depend on it. + */ + async function withTemplateCategories( + source: DeploymentTaskSource + ): Promise { + if (source.kind !== "template") { + return source; + } + try { + const catalog = await readCatalog(); + const categories = catalog.find( + (item) => item.name === source.templateName + )?.category; + return categories == null || categories.length === 0 + ? source + : { ...source, templateCategories: [...categories] }; + } catch { + return source; + } + } + const judgeStanding = dependencies.judgeWorkspaceBillingStandingForActor ?? judgeWorkspaceBillingStandingForActor; @@ -241,6 +272,7 @@ export function createDeployTaskTools( credentialBinding = bindingResolution.credentialBinding; } + const source = await withTemplateCategories(input.source); const result = await createTask(engineContext(), { create: { ...(credentialBinding == null ? {} : { credentialBinding }), @@ -248,8 +280,8 @@ export function createDeployTaskTools( creatingActor: actionActor, namespace, prompt: input.prompt, - runner: defaultRunnerForSource(input.source), - source: input.source, + runner: defaultRunnerForSource(source), + source, target, }, resolveTarget: resolveDeployTaskTargetForCreate, @@ -263,7 +295,7 @@ export function createDeployTaskTools( // persists a stripped copy, so sensitive values reach the // runner only through this in-memory hand-off (ADR 0037). sourceArgValues: - input.source.kind === "template" ? input.source.args : undefined, + source.kind === "template" ? source.args : undefined, taskId: task.id, }), }); diff --git a/apps/ui/src/features/deploy/deployment-task-success-section.tsx b/apps/ui/src/features/deploy/deployment-task-success-section.tsx index cc0c42e2..11169926 100644 --- a/apps/ui/src/features/deploy/deployment-task-success-section.tsx +++ b/apps/ui/src/features/deploy/deployment-task-success-section.tsx @@ -6,9 +6,11 @@ import { cn } from "@workspace/ui/lib/utils"; import { Check, Copy, ExternalLink } from "lucide-react"; import { memo, useEffect, useRef } from "react"; import { prefersReducedMotion } from "@/features/deploy/deployment-task-success-confetti"; +import { DeploymentTaskSuccessShareStrip } from "@/features/deploy/deployment-task-success-share"; import type { DeploymentTaskSuccessEntry, DeploymentTaskSuccessSnapshot, + DeploymentTaskSuccessStep, } from "@/features/deploy/task/timeline"; import { useCopyFeedback } from "@/features/deploy/use-copy-feedback"; @@ -147,7 +149,7 @@ function SecondaryEntryRow({
{headed && entry.label != null ? ( {entry.label} @@ -176,6 +178,49 @@ function SecondaryEntryRow({ ); } +/** + * The declared first-use steps as the Timeline's numbered trail: a numbered + * circle per step, a hairline down to the next, the label and its optional + * monospace detail. Rendered only when the record declared at least one step; + * the sanitizer already caps the list, so the UI adds no cap of its own. + */ +function NextStepsTrail({ steps }: { steps: DeploymentTaskSuccessStep[] }) { + const last = steps.length - 1; + return ( +
    + {steps.map((step, index) => ( +
  1. + {index === last ? null : ( + + )} + + {index + 1} + +
    + + {step.label} + + {step.detail == null ? null : ( + + {step.detail} + + )} +
    +
  2. + ))} +
+ ); +} + /** * The verified-usable conclusion, appended after the Timeline's own steps * (issue #160). It exists only when Result Readiness was reached AND every @@ -183,9 +228,13 @@ function SecondaryEntryRow({ * task status: absent fields stay absent, and an address is only ever the one * the contract declared — the UI never builds one from a host or a port. * - * Shape: the celebration (halo, drawn check, headline) leads; the primary - * address is a copy chip under one wide Open; every other verified address - * drops into a quiet list beneath a hairline, then the declared guidance. + * Shape: the card leads — the celebration (halo, drawn check, headline), the + * primary address as a copy chip over one wide Open, every other verified + * address in a quiet list beneath a hairline. Under the card sit a share strip + * for the primary HTTP(S) entry (AIM-354) and the declared first-use steps as + * a `Next steps` trail. The section owns the whole conclusion, so the arrival + * (scroll into view) and the section slot belong to the wrapper, and the + * Timeline pane keeps rendering one section (ADR-0078). */ export const DeploymentTaskSuccessSection = memo( function DeploymentTaskSuccessSection({ @@ -228,106 +277,98 @@ export const DeploymentTaskSuccessSection = memo( return (
- - -

- {headline} -

- {success.productName == null ? null : ( +
+ +

- {success.productName} + {headline}

- )} - {primaryEntry == null ? null : ( - <> - + {success.productName == null ? null : ( +

+ {success.productName} +

+ )} + {primaryEntry == null ? null : ( + <> + +
+ + + {openLabel} + + } + /> +
+ + )} + {secondaryEntries.length === 0 ? null : (
- - - {openLabel} - - } - /> + {secondaryEntries.map((entry, index) => ( + + ))}
- + )} +
+ {primaryEntry == null ? null : ( + )} - {secondaryEntries.length === 0 ? null : ( + {guidance.length === 0 ? null : (
- {secondaryEntries.map((entry, index) => ( - - ))} +

+ Next steps +

+
)} - {guidance.length === 0 ? null : ( -
    - {guidance.map((step, index) => ( -
  1. - - {index + 1}. - -
    - - {step.label} - - {step.detail == null ? null : ( - - {step.detail} - - )} -
    -
  2. - ))} -
- )}
); } diff --git a/apps/ui/src/features/deploy/deployment-task-success-share.test.tsx b/apps/ui/src/features/deploy/deployment-task-success-share.test.tsx new file mode 100644 index 00000000..00b2d29c --- /dev/null +++ b/apps/ui/src/features/deploy/deployment-task-success-share.test.tsx @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { Popover } from "@workspace/ui/components/popover"; +import { renderToStaticMarkup } from "react-dom/server"; +import { + DEPLOYMENT_TASK_SUCCESS_SHARE_CHANNELS, + DeploymentTaskSuccessQrPanel, + redditTitle, + shareHost, + shareVoice, + xPostText, +} from "./deployment-task-success-share"; + +const URL_WITH_RESERVED = "https://demo.sealos.run/path?a=1&b=2#top"; +const URL_WITH_SPACE = "https://demo.sealos.run/my page?a=1&b=2#top"; +const OPEN_ON_PHONE_RE = /Open on your phone/; +const PANEL_HOST_RE = />meetinghub\.sealos\.run]*data-slot="popover-title"[^>]*>Open on your phone candidate.id === id + ); + assert.ok(found, `channel ${id} is declared`); + return found; +} + +const URL = "https://meetinghub.sealos.run"; + +test("the generic X post names the product and Sealos in three beats", () => { + assert.equal( + xPostText({ productName: "MeetingHub", url: URL }), + [ + "Just deployed MeetingHub with Sealos. @Sealos_io", + "From idea to live app.", + "", + `Try it here: ${URL}`, + "", + "#Sealos #BuildInPublic", + ].join("\n") + ); +}); + +test("the X post invents no name and no number when the record has none", () => { + assert.equal( + xPostText({ url: URL }), + [ + "Just deployed with Sealos. @Sealos_io", + "From idea to live app.", + "", + `Try it here: ${URL}`, + "", + "#Sealos #BuildInPublic", + ].join("\n") + ); + assert.equal( + xPostText({ productCategories: ["ai"], url: URL }).split("\n")[0], + "I just took an idea to a live app with Sealos. @Sealos_io" + ); + assert.equal(xPostText({ url: URL }).includes("minute"), false); +}); + +test("the voice follows the hard-coded product first, then the first category", () => { + assert.equal(shareVoice({}), "generic"); + assert.equal(shareVoice({ productCategories: ["game", "ai"] }), "game"); + assert.equal(shareVoice({ productCategories: ["ai", "game"] }), "ai"); + assert.equal(shareVoice({ productCategories: [" Game "] }), "game"); + assert.equal(shareVoice({ productCategories: ["tool"] }), "generic"); + assert.equal( + shareVoice({ productCategories: ["ai"], productId: "eaglercraft-server" }), + "eaglercraft" + ); + assert.equal(shareVoice({ productId: "EaglerCraft-Server" }), "eaglercraft"); + assert.equal(shareVoice({ productId: "minecraft" }), "generic"); +}); + +test("a game server and the EaglerCraft server invite people to join", () => { + assert.equal( + xPostText({ + productCategories: ["game"], + productName: "Minecraft", + url: URL, + }), + [ + "My own game server is live! @Sealos_io", + "Deployed with Sealos.", + "", + `Join here: ${URL}`, + "", + "#Sealos", + ].join("\n") + ); + assert.equal( + xPostText({ + productCategories: ["game"], + productId: "eaglercraft-server", + productName: "EaglerCraft Server", + url: URL, + }), + [ + "My own Eaglercraft server is live! @Sealos_io", + "Deployed with Sealos.", + "", + `Join here: ${URL}`, + "", + "#Eaglercraft #Minecraft #Sealos", + ].join("\n") + ); +}); + +test("an AI app takes the idea-to-live-app voice", () => { + assert.equal( + xPostText({ productCategories: ["ai"], productName: "FastGPT", url: URL }), + [ + "I just took FastGPT from idea to live app with Sealos. @Sealos_io", + "No complicated setup. Just deploy, share, and start building.", + "", + `Try it here: ${URL}`, + "", + "#AI #BuildInPublic #Sealos", + ].join("\n") + ); +}); + +test("the Reddit title names the product when it can", () => { + assert.equal( + redditTitle("MeetingHub"), + "MeetingHub is live — just shipped with Sealos" + ); + assert.equal( + redditTitle(undefined), + "My app is live — just shipped with Sealos" + ); +}); + +test("the host falls back to the raw address when it cannot be parsed", () => { + assert.equal(shareHost("not a url"), "not a url"); + assert.equal(shareHost("https://demo.sealos.run/path"), "demo.sealos.run"); +}); + +test("the four channels are declared in order with accessible labels", () => { + assert.deepEqual( + DEPLOYMENT_TASK_SUCCESS_SHARE_CHANNELS.map((entry) => [ + entry.id, + entry.label, + ]), + [ + ["x", "Post on X"], + ["linkedin", "Share on LinkedIn"], + ["facebook", "Share on Facebook"], + ["reddit", "Post on Reddit"], + ] + ); +}); + +test("every channel encodes the address so reserved characters survive", () => { + const encodedUrl = encodeURIComponent(URL_WITH_RESERVED); + const subject = { productName: "My App", url: URL_WITH_RESERVED }; + assert.equal( + channel("x").href(subject), + `https://x.com/intent/post?text=${encodeURIComponent(xPostText(subject))}` + ); + // Line breaks reach X as %0A, and the blank lines between the beats as a + // doubled one, so the post keeps its shape. + assert.ok(channel("x").href(subject).includes("%0A%0A")); + assert.equal( + channel("linkedin").href(subject), + `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}` + ); + assert.equal( + channel("facebook").href(subject), + `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}` + ); + assert.equal( + channel("reddit").href(subject), + `https://www.reddit.com/submit?url=${encodedUrl}&title=${encodeURIComponent( + "My App is live — just shipped with Sealos" + )}` + ); + // The raw `&`, `#` and space never appear unencoded in a query value, + // whether they come from the address or from the product name. + for (const entry of DEPLOYMENT_TASK_SUCCESS_SHARE_CHANNELS) { + const href = entry.href({ productName: "My App", url: URL_WITH_SPACE }); + assert.equal(href.includes("#"), false, `${entry.id} encodes #`); + assert.equal(href.includes("&b=2"), false, `${entry.id} encodes &`); + assert.equal(href.includes(" "), false, `${entry.id} encodes spaces`); + assert.ok( + href.includes(encodeURIComponent(URL_WITH_SPACE)), + `${entry.id} carries the whole address` + ); + } +}); + +test("without a product name Reddit still gets a title", () => { + assert.equal( + channel("reddit").href({ url: "https://demo.sealos.run/x" }), + `https://www.reddit.com/submit?url=${encodeURIComponent( + "https://demo.sealos.run/x" + )}&title=${encodeURIComponent("My app is live — just shipped with Sealos")}` + ); +}); + +test("the QR panel says what a scan opens and shows the host", () => { + const html = renderToStaticMarkup( + + + + ); + assert.match(html, OPEN_ON_PHONE_RE); + // What a scan opens names the popover's dialog. + assert.match(html, POPOVER_TITLE_RE); + assert.match(html, PANEL_HOST_RE); + // The code itself is an SVG, dark on a white tile whatever the theme. + assert.match(html, SVG_RE); + assert.match(html, WHITE_TILE_RE); + assert.match(html, PANEL_TITLE_RE); +}); diff --git a/apps/ui/src/features/deploy/deployment-task-success-share.tsx b/apps/ui/src/features/deploy/deployment-task-success-share.tsx new file mode 100644 index 00000000..3a2a1c25 --- /dev/null +++ b/apps/ui/src/features/deploy/deployment-task-success-share.tsx @@ -0,0 +1,382 @@ +"use client"; + +import { AppIconButton } from "@workspace/ui/components/app-icon-button"; +import { + Popover, + PopoverContent, + PopoverTitle, + PopoverTrigger, +} from "@workspace/ui/components/popover"; +import { cn } from "@workspace/ui/lib/utils"; +import { QrCode } from "lucide-react"; +import { QRCodeSVG } from "qrcode.react"; +import type { ComponentProps, ReactNode } from "react"; + +/** + * Sharing the Deployment Task Success Record's primary HTTP(S) entry + * (AIM-354): a QR code for a phone and one-click posts to four networks. + * + * What is shared is the product's own public address exactly as the record + * snapshotted it — nothing of Brain, and no access model of its own + * (CONTEXT.md, Deployment Task Success Record). The copy speaks in the + * user's voice and names Sealos; it picks a voice from the facts the record + * snapshotted (product id and categories), never from the live catalog. + * This module is feature-local on purpose: it moves to `@workspace/ui` only + * if a second consumer appears. + */ + +/* ------------------------------------------------------------- pure copy */ + +/** The facts a share is built from, all read off the success record. */ +export interface DeploymentTaskShareSubject { + /** The product's snapshotted catalog categories, e.g. `game`, `ai`. */ + productCategories?: readonly string[]; + /** The product's catalog identity; the template name for a template deployment. */ + productId?: string; + productName?: string; + /** The primary HTTP(S) entry exactly as the record snapshotted it. */ + url: string; +} + +const SEALOS_X_HANDLE = "@Sealos_io"; +const EAGLERCRAFT_PRODUCT_ID = "eaglercraft-server"; + +/** + * Which voice the X post speaks in. The one hard-coded product is + * EaglerCraft (AIM-354: a deliberate product branch, superseding #336's + * "no product branch"); after that only the first snapshotted category + * counts, and anything else is the generic launch post. + */ +export type DeploymentTaskShareVoice = + | "ai" + | "eaglercraft" + | "game" + | "generic"; + +export function shareVoice( + subject: Pick +): DeploymentTaskShareVoice { + if (subject.productId?.trim().toLowerCase() === EAGLERCRAFT_PRODUCT_ID) { + return "eaglercraft"; + } + switch (subject.productCategories?.[0]?.trim().toLowerCase()) { + case "ai": + return "ai"; + case "game": + return "game"; + default: + return "generic"; + } +} + +/** + * The X post in three beats separated by blank lines — the announcement, + * the link, the hashtags — so it scans the way posts on X usually do. The + * product name is dropped, never invented. + */ +export function xPostText(subject: DeploymentTaskShareSubject): string { + const name = subject.productName; + const { announcement, link, tags } = ((): { + announcement: [string, string]; + link: string; + tags: string; + } => { + switch (shareVoice(subject)) { + case "eaglercraft": + return { + announcement: [ + `My own Eaglercraft server is live! ${SEALOS_X_HANDLE}`, + "Deployed with Sealos.", + ], + link: `Join here: ${subject.url}`, + tags: "#Eaglercraft #Minecraft #Sealos", + }; + case "game": + return { + announcement: [ + `My own game server is live! ${SEALOS_X_HANDLE}`, + "Deployed with Sealos.", + ], + link: `Join here: ${subject.url}`, + tags: "#Sealos", + }; + case "ai": + return { + announcement: [ + name == null + ? `I just took an idea to a live app with Sealos. ${SEALOS_X_HANDLE}` + : `I just took ${name} from idea to live app with Sealos. ${SEALOS_X_HANDLE}`, + "No complicated setup. Just deploy, share, and start building.", + ], + link: `Try it here: ${subject.url}`, + tags: "#AI #BuildInPublic #Sealos", + }; + default: + return { + announcement: [ + name == null + ? `Just deployed with Sealos. ${SEALOS_X_HANDLE}` + : `Just deployed ${name} with Sealos. ${SEALOS_X_HANDLE}`, + "From idea to live app.", + ], + link: `Try it here: ${subject.url}`, + tags: "#Sealos #BuildInPublic", + }; + } + })(); + return [...announcement, "", link, "", tags].join("\n"); +} + +/** The Reddit link-post title; the post's content is the address itself. */ +export function redditTitle(productName: string | undefined): string { + return productName == null + ? "My app is live — just shipped with Sealos" + : `${productName} is live — just shipped with Sealos`; +} + +/** The host of `url`, or `url` itself when it cannot be parsed. */ +export function shareHost(url: string) { + try { + return new URL(url).host; + } catch { + return url; + } +} + +/* --------------------------------------------------------------- channels */ + +function XIcon(props: ComponentProps<"svg">) { + return ( + + X + + + ); +} + +function LinkedInIcon(props: ComponentProps<"svg">) { + return ( + + LinkedIn + + + ); +} + +function FacebookIcon(props: ComponentProps<"svg">) { + return ( + + Facebook + + + ); +} + +function RedditIcon(props: ComponentProps<"svg">) { + return ( + + Reddit + + + ); +} + +export interface DeploymentTaskSuccessShareChannel { + /** The share URL for the subject; a pure function of the record's facts. */ + href: (subject: DeploymentTaskShareSubject) => string; + Icon: (props: ComponentProps<"svg">) => ReactNode; + id: "facebook" | "linkedin" | "reddit" | "x"; + /** The accessible name of the control, also its title. */ + label: string; +} + +/** The fixed share channels, in the order the strip draws them. */ +export const DEPLOYMENT_TASK_SUCCESS_SHARE_CHANNELS: readonly DeploymentTaskSuccessShareChannel[] = + [ + { + Icon: XIcon, + href: (subject) => + `https://x.com/intent/post?text=${encodeURIComponent(xPostText(subject))}`, + id: "x", + label: "Post on X", + }, + { + Icon: LinkedInIcon, + href: ({ url }) => + `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`, + id: "linkedin", + label: "Share on LinkedIn", + }, + { + Icon: FacebookIcon, + href: ({ url }) => + `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`, + id: "facebook", + label: "Share on Facebook", + }, + { + Icon: RedditIcon, + href: ({ productName, url }) => + `https://www.reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent(redditTitle(productName))}`, + id: "reddit", + label: "Post on Reddit", + }, + ]; + +/* ---------------------------------------------------------------- the QR */ + +/** + * A QR on a white tile: scanners want dark-on-light whatever the theme. The + * quiet zone is drawn by the code itself (`marginSize`) so the tile sits + * flush inside its frame. + */ +function QrTile({ size, url }: { size: number; url: string }) { + return ( + + + + ); +} + +/** The four viewfinder corners, each a bracket drawn from two borders. */ +const SCAN_CORNERS = [ + "top-0 left-0 rounded-tl-sm border-t border-l", + "top-0 right-0 rounded-tr-sm border-t border-r", + "bottom-0 left-0 rounded-bl-sm border-b border-l", + "right-0 bottom-0 rounded-br-sm border-r border-b", +] as const; + +/** Viewfinder brackets around a QR: the four corners read as "scan me". */ +function ScanFrame({ children }: { children: ReactNode }) { + return ( + + {SCAN_CORNERS.map((corner) => ( + + ))} + {children} + + ); +} + +const QR_SIZE = 128; + +/** + * The popover's body: the framed QR, what a scan opens, and the host it + * opens. What a scan opens is the popover's title, so the dialog the popover + * announces carries a name; the panel therefore lives inside a `Popover`. + */ +export function DeploymentTaskSuccessQrPanel({ url }: { url: string }) { + return ( + <> + + + +
+ + Open on your phone + + + {shareHost(url)} + +
+ + ); +} + +function QrPopover({ url }: { url: string }) { + return ( + + + + + } + /> + + + + + ); +} + +/* -------------------------------------------------------------- the strip */ + +/** + * `Share ` on the left; on the right the QR trigger, then one icon + * link per channel, all drawn as the quiet small app icon button so they + * match the copy controls in the card. Every link opens in a new tab and + * sends no referrer, so the Brain tab stays put and shares nothing of Brain. + */ +export function DeploymentTaskSuccessShareStrip({ + className, + subject, +}: { + className?: string; + subject: DeploymentTaskShareSubject; +}) { + const { productName, url } = subject; + return ( +
+ + {productName == null ? "Share" : `Share ${productName}`} + +
+ + {DEPLOYMENT_TASK_SUCCESS_SHARE_CHANNELS.map( + ({ Icon, href, id, label }) => ( + + } + size="sm" + title={label} + variant="quiet" + > + + + ) + )} +
+
+ ); +} diff --git a/apps/ui/src/features/deploy/deployment-task-timeline-pane.test.tsx b/apps/ui/src/features/deploy/deployment-task-timeline-pane.test.tsx index a21b4bcd..1c03c119 100644 --- a/apps/ui/src/features/deploy/deployment-task-timeline-pane.test.tsx +++ b/apps/ui/src/features/deploy/deployment-task-timeline-pane.test.tsx @@ -1112,6 +1112,312 @@ test("the EaglerCraft fixture teaches a player how to join the server", () => { assert.doesNotMatch(html, DECLARED_ADDRESS_RE); }); +/* -------------------------------------------------------------------------- */ +/* Share strip and Next steps trail (AIM-354) */ +/* -------------------------------------------------------------------------- */ + +const SHARE_SLOT = 'data-slot="deployment-task-success-share"'; +const NEXT_STEPS_SLOT = 'data-slot="deployment-task-success-next-steps"'; +const QR_TRIGGER_RE = + /]*aria-label="Show QR code")[^>]*data-slot="deployment-task-success-share-qr"/; +const NEXT_STEPS_HEADING_RE = /Next steps/; +const SHARE_LABEL_RE = />Share EaglerCraft Server]*aria-hidden="true"[\s\S]*<\/svg><\/a>$/; +const BARE_SHARE_LABEL_RE = />Share1<\/span>/; +const TRAIL_SECOND_NUMBER_RE = />2<\/span>/; +const CARD_LIST_NUMBER_RE = />1\.<\/span>/; +const MONO_DETAIL_RE = + /Keep it open in another tab\.<\/span>/; +const SHARE_CHANNELS = [ + { href: "https://x.com/intent/post?text=", label: "Post on X" }, + { + href: "https://www.linkedin.com/sharing/share-offsite/?url=", + label: "Share on LinkedIn", + }, + { + href: "https://www.facebook.com/sharer/sharer.php?u=", + label: "Share on Facebook", + }, + { href: "https://www.reddit.com/submit?url=", label: "Post on Reddit" }, +]; + +/** The `` element carrying `label`, or null when the strip does not render it. */ +function shareLink(html: string, label: string): string | null { + const match = html.match( + new RegExp(`]*aria-label="${label}")[^>]*>[\\s\\S]*?`) + ); + return match?.[0] ?? null; +} + +/** Attribute values in static markup are HTML-escaped, so `&` reads as `&`. */ +function escapeAttribute(value: string): string { + return value.replace(/&/g, "&"); +} + +test("the share strip and the Next steps trail follow the card, in that order", () => { + const html = renderPaneContent(successSnapshot({})); + + const primaryActionAt = html.indexOf(PRIMARY_ACTION_SLOT); + const shareAt = html.indexOf(SHARE_SLOT); + const nextStepsAt = html.indexOf(NEXT_STEPS_SLOT); + assert.ok(primaryActionAt !== -1); + assert.ok(shareAt !== -1); + assert.ok(nextStepsAt !== -1); + assert.ok(primaryActionAt < shareAt); + assert.ok(shareAt < nextStepsAt); + assert.ok(shareAt < html.indexOf("Next steps")); + // The strip names the product; the chip stays the way to copy the address. + assert.match(html, SHARE_LABEL_RE); + assert.match(html, COPY_ADDRESS_LABEL_RE); + assert.match(html, QR_TRIGGER_RE); + // The whole conclusion is one section: card, strip and trail share a root. + assert.equal((html.match(SUCCESS_SLOT_ALL_RE) ?? []).length, 1); + assert.ok( + html.indexOf('data-slot="deployment-task-success"') < primaryActionAt + ); +}); + +test("each share channel posts the snapshotted address in a new tab without a referrer", () => { + const html = renderPaneContent(successSnapshot({})); + const url = "https://eaglercraft.demo.sealos.run"; + const encodedUrl = encodeURIComponent(url); + const expectedHrefs: Record = { + "Post on Reddit": `https://www.reddit.com/submit?url=${encodedUrl}&title=${encodeURIComponent( + "EaglerCraft Server is live — just shipped with Sealos" + )}`, + // The record names the product but declares no id or category, so the + // post is the generic launch in the user's voice. + "Post on X": `https://x.com/intent/post?text=${encodeURIComponent( + [ + "Just deployed EaglerCraft Server with Sealos. @Sealos_io", + "From idea to live app.", + "", + `Try it here: ${url}`, + "", + "#Sealos #BuildInPublic", + ].join("\n") + )}`, + "Share on Facebook": `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, + "Share on LinkedIn": `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`, + }; + + for (const { label } of SHARE_CHANNELS) { + const tag = shareLink(html, label); + assert.ok(tag, `${label} is rendered as a link`); + assert.ok( + tag.includes(`href="${escapeAttribute(expectedHrefs[label] ?? "")}"`), + `${label} shares the snapshotted address: ${tag}` + ); + assert.ok(tag.includes('target="_blank"'), `${label} opens a new tab`); + assert.ok( + tag.includes('rel="noopener noreferrer"'), + `${label} sends no referrer` + ); + assert.ok(tag.includes(`title="${label}"`), `${label} carries its title`); + assert.match( + tag, + CHANNEL_ICON_RE, + `${label} draws its icon inside the link` + ); + } + // The strip draws the channels in the declared order. + const positions = SHARE_CHANNELS.map(({ href }) => + html.indexOf(`href="${escapeAttribute(href)}`) + ); + assert.deepEqual( + positions, + [...positions].sort((a, b) => a - b) + ); +}); + +test("a record without a product name shares plainly and invents no name", () => { + const url = "https://web-app.demo.sealos.run"; + const html = renderPaneContent( + successSnapshot({ + success: { + contractVersion: 1, + entries: [{ url }], + revision: 3, + verifiedAt: VERIFIED_AT, + }, + }) + ); + + assert.match(html, BARE_SHARE_LABEL_RE); + const x = shareLink(html, "Post on X"); + assert.ok(x); + assert.ok( + x.includes( + `href="https://x.com/intent/post?text=${encodeURIComponent( + [ + "Just deployed with Sealos. @Sealos_io", + "From idea to live app.", + "", + `Try it here: ${url}`, + "", + "#Sealos #BuildInPublic", + ].join("\n") + )}"` + ), + x + ); + const reddit = shareLink(html, "Post on Reddit"); + assert.ok(reddit); + assert.ok( + reddit.includes( + `href="${escapeAttribute( + `https://www.reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent( + "My app is live — just shipped with Sealos" + )}` + )}"` + ), + reddit + ); + // No guidance was declared, so no heading is invented either. + assert.doesNotMatch(html, NEXT_STEPS_HEADING_RE); + assert.equal(html.includes(NEXT_STEPS_SLOT), false); +}); + +test("the X post speaks to the product the record snapshotted, not the catalog", () => { + const url = "https://mc.demo.sealos.run"; + const eaglercraft = renderPaneContent( + successSnapshot({ + success: { + contractVersion: 2, + entries: [{ protocol: "https", url }], + productCategories: ["game"], + productId: "eaglercraft-server", + productName: "EaglerCraft Server", + revision: 3, + verifiedAt: VERIFIED_AT, + }, + }) + ); + const eaglercraftX = shareLink(eaglercraft, "Post on X"); + assert.ok(eaglercraftX); + assert.ok( + eaglercraftX.includes( + encodeURIComponent("My own Eaglercraft server is live! @Sealos_io") + ), + eaglercraftX + ); + assert.ok(eaglercraftX.includes(encodeURIComponent("#Eaglercraft"))); + + const ai = renderPaneContent( + successSnapshot({ + success: { + contractVersion: 2, + entries: [{ protocol: "https", url }], + productCategories: ["ai"], + productId: "fastgpt", + productName: "FastGPT", + revision: 3, + verifiedAt: VERIFIED_AT, + }, + }) + ); + const aiX = shareLink(ai, "Post on X"); + assert.ok(aiX); + assert.ok( + aiX.includes( + encodeURIComponent( + "I just took FastGPT from idea to live app with Sealos. @Sealos_io" + ) + ), + aiX + ); +}); + +test("a record whose only entries are WebSocket addresses offers no share strip", () => { + const html = renderPaneContent( + successSnapshot({ + success: { + contractVersion: 2, + entries: [ + { protocol: "wss", url: "wss://eaglercraft.demo.sealos.run/server" }, + { protocol: "ws", url: "ws://eaglercraft.demo.sealos.run/lobby" }, + ], + revision: 3, + verifiedAt: VERIFIED_AT, + }, + }) + ); + + assert.equal(html.includes(SHARE_SLOT), false); + assert.doesNotMatch(html, QR_TRIGGER_RE); + for (const { label } of SHARE_CHANNELS) { + assert.equal(shareLink(html, label), null, `${label} is not offered`); + } +}); + +test("a record with no entry offers no share strip", () => { + const html = renderPaneContent( + successSnapshot({ + success: { + contractVersion: 1, + headline: "Deployment completed", + revision: 3, + verifiedAt: VERIFIED_AT, + }, + }) + ); + + assert.match(html, SUCCESS_SLOT_RE); + assert.equal(html.includes(SHARE_SLOT), false); + assert.doesNotMatch(html, QR_TRIGGER_RE); + assert.equal(html.includes(NEXT_STEPS_SLOT), false); +}); + +test("declared steps stand under Next steps as a numbered trail, outside the card", () => { + const html = renderPaneContent(successSnapshot({})); + + assert.match(html, NEXT_STEPS_HEADING_RE); + const trailAt = html.indexOf(NEXT_STEPS_SLOT); + assert.ok(trailAt !== -1); + // Every step label and detail is listed, in order, after the heading. + const openAt = html.indexOf("Open the client."); + const detailAt = html.indexOf("Keep it open in another tab."); + const addAt = html.indexOf("Add the server in Multiplayer."); + assert.ok(html.indexOf("Next steps") < openAt); + assert.ok(openAt < detailAt); + assert.ok(detailAt < addAt); + // The trail's own numbers, not the card's `1.` list. + const trail = html.slice(trailAt); + assert.match(trail, TRAIL_LIST_RE); + assert.match(trail, TRAIL_FIRST_NUMBER_RE); + assert.match(trail, TRAIL_SECOND_NUMBER_RE); + assert.doesNotMatch(trail, CARD_LIST_NUMBER_RE); + // The detail is monospace so an address to paste is easy to select. + assert.match(trail, MONO_DETAIL_RE); +}); + +test("declared steps stay visible even when there is nothing to share", () => { + const html = renderPaneContent( + successSnapshot({ + success: { + contractVersion: 2, + entries: [ + { protocol: "wss", url: "wss://eaglercraft.demo.sealos.run/server" }, + ], + guidance: [{ label: "Add the server in Multiplayer." }], + revision: 3, + verifiedAt: VERIFIED_AT, + }, + }) + ); + + assert.equal(html.includes(SHARE_SLOT), false); + assert.match(html, NEXT_STEPS_HEADING_RE); + assert.ok(html.includes(NEXT_STEPS_SLOT)); + assert.match(html, ADD_SERVER_STEP_RE); +}); + test("a success that arrives live celebrates once and stays readable", async () => { resetDeploymentTaskSuccessCelebrationClaims(); const dom = installTestDom(); diff --git a/apps/ui/src/features/deploy/github-deployment-pane.tsx b/apps/ui/src/features/deploy/github-deployment-pane.tsx index 0afe6bec..0ba74752 100644 --- a/apps/ui/src/features/deploy/github-deployment-pane.tsx +++ b/apps/ui/src/features/deploy/github-deployment-pane.tsx @@ -217,6 +217,7 @@ export function GitHubDeploymentPane({ projectName: currentProject.resourceName, projectId: projectIdTrimmed, }), + templateCategories: template.category, templateName: settings.templateName, }, }); diff --git a/apps/ui/src/features/deploy/pipeline.test.ts b/apps/ui/src/features/deploy/pipeline.test.ts index 9585e128..d6b20c4b 100644 --- a/apps/ui/src/features/deploy/pipeline.test.ts +++ b/apps/ui/src/features/deploy/pipeline.test.ts @@ -191,6 +191,7 @@ test("Deployment Target pipeline creates a template Deployment Task", async () = projectName: "existing-project", projectId: "existing-uid", }), + templateCategories: ["tool"], templateName: "memos", }, }); @@ -201,6 +202,7 @@ test("Deployment Target pipeline creates a template Deployment Task", async () = source: { args: { storage: "10" }, kind: "template", + templateCategories: ["tool"], templateName: "memos", }, target: { diff --git a/apps/ui/src/features/deploy/pipeline.ts b/apps/ui/src/features/deploy/pipeline.ts index 96dc3214..31b97c30 100644 --- a/apps/ui/src/features/deploy/pipeline.ts +++ b/apps/ui/src/features/deploy/pipeline.ts @@ -42,6 +42,8 @@ export type DeploymentTargetPipelineRequest = /** Arg keys whose values must never be persisted (ADR 0037). */ sensitiveKeys?: string[]; target: DeploymentTarget; + /** The template's catalog categories, snapshotted into the task source. */ + templateCategories?: string[]; templateName: string; }; @@ -246,6 +248,10 @@ function deploymentTaskForRequest( request.sensitiveKeys.length === 0 ? {} : { sensitiveKeys: request.sensitiveKeys }), + ...(request.templateCategories == null || + request.templateCategories.length === 0 + ? {} + : { templateCategories: request.templateCategories }), templateName, }, }; diff --git a/apps/ui/src/features/deploy/task/managed-timeline.ts b/apps/ui/src/features/deploy/task/managed-timeline.ts index 6a596b10..a9636a9a 100644 --- a/apps/ui/src/features/deploy/task/managed-timeline.ts +++ b/apps/ui/src/features/deploy/task/managed-timeline.ts @@ -7,6 +7,7 @@ import { attachDeploymentTaskSuccess, type DeploymentResultResourceCard, type DeploymentResultResourceRef, + type DeploymentTaskSuccessProduct, type DeploymentTaskTimelineSnapshot, deploymentResultResourceCardId, deploymentTaskSuccessFromTimeline, @@ -85,12 +86,11 @@ export function managedDeploymentTimelineResultCards(input: { /** Writes managed evidence first, then derives the success claim from it. */ export function attachManagedDeploymentTimelineSuccess( timeline: DeploymentTaskTimelineSnapshot, - input: { + input: DeploymentTaskSuccessProduct & { accessEndpoints: readonly ManagedAccessEndpoint[]; namespace: string; /** The Default Open Port's best Public Address, when an AP declares one. */ primaryEntryUrl?: string | null; - productName: string | null; resources: readonly ManagedResourceRef[]; updatedAt: string; } @@ -114,6 +114,8 @@ export function attachManagedDeploymentTimelineSuccess( ); const success = deploymentTaskSuccessFromTimeline(withEvidence, { primaryEntryUrl: input.primaryEntryUrl, + productCategories: input.productCategories, + productId: input.productId, productName: input.productName, }); return success == null diff --git a/apps/ui/src/features/deploy/task/projection.test.ts b/apps/ui/src/features/deploy/task/projection.test.ts index 0330053e..71e50660 100644 --- a/apps/ui/src/features/deploy/task/projection.test.ts +++ b/apps/ui/src/features/deploy/task/projection.test.ts @@ -6,6 +6,7 @@ import { deploymentTaskCanvasTopologyChanged, deploymentTaskCanvasTopologySignature, deploymentTaskProjectionIsVisible, + deploymentTaskSourceProduct, nextDeploymentTaskProjectionVisibilityChangeMs, replaceDeploymentTaskProjections, selectCanvasDeploymentTaskProjections, @@ -751,3 +752,33 @@ test("a failed task's projection carries its Deployment Failure Reason for the d ); assert.equal(running?.failureReason, null); }); + +test("a template source names its product by template name and categories", () => { + assert.deepEqual( + deploymentTaskSourceProduct({ + kind: "template", + templateCategories: ["game"], + templateName: "eaglercraft-server", + }), + { + productCategories: ["game"], + productId: "eaglercraft-server", + productName: "eaglercraft-server", + } + ); + // A template without categories still has an id; nothing is invented. + assert.deepEqual( + deploymentTaskSourceProduct({ kind: "template", templateName: "memos" }), + { productId: "memos", productName: "memos" } + ); +}); + +test("a non-template source has a product name and nothing else", () => { + assert.deepEqual( + deploymentTaskSourceProduct({ + kind: "docker", + settings: { image: "nginx:latest" }, + }), + { productName: "nginx:latest" } + ); +}); diff --git a/apps/ui/src/features/deploy/task/projection.ts b/apps/ui/src/features/deploy/task/projection.ts index c90476ad..aee4de08 100644 --- a/apps/ui/src/features/deploy/task/projection.ts +++ b/apps/ui/src/features/deploy/task/projection.ts @@ -11,6 +11,7 @@ import type { DeployTaskPhase, DeployTaskStatus, } from "./schema"; +import type { DeploymentTaskSuccessProduct } from "./timeline"; export const DEPLOYMENT_TASK_PROJECTION_COMPLETED_GRACE_MS = 60_000; @@ -160,6 +161,27 @@ function truncateSummary(value: string, maxLength: number): string { return `${trimmed.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`; } +/** + * What a Deployment Task Success Record may say about the product it + * verified, read off the task's source: the display name every source has, + * plus the template's catalog name and categories when the source is a + * template. Nothing is invented for the other sources. + */ +export function deploymentTaskSourceProduct( + source: DeploymentTaskSource +): DeploymentTaskSuccessProduct { + const productName = deploymentTaskSourceSummary(source); + if (source.kind !== "template") { + return { productName }; + } + const categories = source.templateCategories ?? []; + return { + ...(categories.length === 0 ? {} : { productCategories: [...categories] }), + productId: source.templateName, + productName, + }; +} + export function deploymentTaskSourceSummary( source: DeploymentTaskSource ): string { diff --git a/apps/ui/src/features/deploy/task/runner.ts b/apps/ui/src/features/deploy/task/runner.ts index d39e9757..7f1b9aca 100644 --- a/apps/ui/src/features/deploy/task/runner.ts +++ b/apps/ui/src/features/deploy/task/runner.ts @@ -132,7 +132,7 @@ import { import { probeManagedPublicUrl } from "./managed-public-probe"; import { attachManagedDeploymentTimelineSuccess } from "./managed-timeline"; import { deployOutputProgressSummary } from "./output-progress"; -import { deploymentTaskSourceSummary } from "./projection"; +import { deploymentTaskSourceProduct } from "./projection"; import { type DeploymentResultApCandidate, deploymentResultApCandidates, @@ -2715,7 +2715,7 @@ async function completeTaskWithArtifact(input: { update: (timeline) => { const success = deploymentTaskSuccessFromTimeline(timeline, { primaryEntryUrl, - productName: deploymentTaskSourceSummary(input.task.source), + ...deploymentTaskSourceProduct(input.task.source), }); return success == null ? timeline @@ -3867,7 +3867,7 @@ async function runManagedDeploymentLifecycleCore(input: { accessEndpoints: completion.accessEndpoints, namespace: input.task.namespace, primaryEntryUrl, - productName: deploymentTaskSourceSummary(input.task.source), + ...deploymentTaskSourceProduct(input.task.source), resources: completion.resources, updatedAt, }); diff --git a/apps/ui/src/features/deploy/task/schema.ts b/apps/ui/src/features/deploy/task/schema.ts index 7dfc1fd0..5005eb55 100644 --- a/apps/ui/src/features/deploy/task/schema.ts +++ b/apps/ui/src/features/deploy/task/schema.ts @@ -276,6 +276,13 @@ export interface DeploymentTaskTemplateSource { * the name list itself is persisted so clones know what must be re-asked. */ sensitiveKeys?: string[]; + /** + * The template's declared categories (its catalog `categories`, e.g. + * `game`, `ai`) as they stood when the task was created. Carried so the + * Deployment Task Success Record can snapshot them without asking the + * catalog again (AIM-354). + */ + templateCategories?: string[]; templateName: string; } diff --git a/apps/ui/src/features/deploy/task/timeline.test.ts b/apps/ui/src/features/deploy/task/timeline.test.ts index 0826634c..d4504cc9 100644 --- a/apps/ui/src/features/deploy/task/timeline.test.ts +++ b/apps/ui/src/features/deploy/task/timeline.test.ts @@ -15,10 +15,12 @@ import { declareTimelineSteps, deploymentTaskSuccessFromResultReadiness, deploymentTaskSuccessFromTimeline, + deploymentTaskSuccessSignature, deploymentTimelineFailureStepId, deploymentTimelineResultReadinessReached, deploymentTimelineStepsForRunner, markTimelineStep, + sanitizeDeploymentTaskSuccess, upsertResultResourceCard, } from "./timeline"; @@ -667,6 +669,33 @@ test("readiness claims exactly the required resources it saw running", () => { ); }); +test("a readiness claim carries the product's id and categories when known", () => { + assert.deepEqual( + deploymentTaskSuccessFromResultReadiness({ + productCategories: ["game"], + productId: "eaglercraft-server", + productName: "EaglerCraft Server", + requiredRunningCards: 1, + }), + { + productCategories: ["game"], + productId: "eaglercraft-server", + productName: "EaglerCraft Server", + verification: { passed: 1, total: 1 }, + } + ); + // Absent facts stay absent: no empty list, no empty id. + assert.deepEqual( + deploymentTaskSuccessFromResultReadiness({ + productCategories: [], + productId: null, + productName: "Site", + requiredRunningCards: 1, + }), + { productName: "Site", verification: { passed: 1, total: 1 } } + ); +}); + test("a readiness claim never drops its verification count", () => { const success = deploymentTaskSuccessFromResultReadiness({ productName: null, @@ -762,10 +791,14 @@ test("the claim published with the probe covers exactly what is on screen", () = .filter((card) => card.required).length; const success = deploymentTaskSuccessFromTimeline(verified, { + productCategories: ["game"], + productId: "eaglercraft-server", productName: "EaglerCraft Server", }); assert.deepEqual(success, { headline: "Deployment completed", + productCategories: ["game"], + productId: "eaglercraft-server", productName: "EaglerCraft Server", verification: { passed: visibleRequiredCards, total: visibleRequiredCards }, }); @@ -1110,3 +1143,70 @@ test("the Default Open Port's address leads the success entries", () => { "https://web.example.sealos.run/", ]); }); + +test("the sanitizer keeps product categories as a short list of trimmed names", () => { + const fallback = { revision: 3, verifiedAt: "2026-06-17T10:00:05.000Z" }; + const sanitized = sanitizeDeploymentTaskSuccess( + { + productCategories: [" game ", 42, "", "tool", "game"], + productId: "eaglercraft-server", + productName: "EaglerCraft Server", + revision: 3, + verifiedAt: fallback.verifiedAt, + }, + fallback + ); + assert.deepEqual(sanitized?.productCategories, ["game", "tool"]); + assert.equal(sanitized?.productId, "eaglercraft-server"); + + // An empty or malformed list leaves the field absent rather than empty. + for (const productCategories of [[], "game", null, [""]]) { + const record = sanitizeDeploymentTaskSuccess( + { productCategories, revision: 3, verifiedAt: fallback.verifiedAt }, + fallback + ); + assert.equal(record?.productCategories, undefined); + } + + // The list is capped, so a runaway producer cannot bloat the snapshot. + const many = sanitizeDeploymentTaskSuccess( + { + productCategories: Array.from({ length: 40 }, (_, i) => `c${i}`), + revision: 3, + verifiedAt: fallback.verifiedAt, + }, + fallback + ); + assert.equal(many?.productCategories?.length, 16); +}); + +test("the sanitizer folds a success text onto one line", () => { + const fallback = { revision: 3, verifiedAt: "2026-06-17T10:00:05.000Z" }; + const sanitized = sanitizeDeploymentTaskSuccess( + { + productName: " Eagler\n\tCraft Server ", + revision: 3, + verifiedAt: fallback.verifiedAt, + }, + fallback + ); + assert.equal(sanitized?.productName, "Eagler Craft Server"); +}); + +test("product categories are part of a record's identity", () => { + const base = { + contractVersion: 2, + productId: "eaglercraft-server", + productName: "EaglerCraft Server", + revision: 3, + verifiedAt: "2026-06-17T10:00:05.000Z", + }; + assert.notEqual( + deploymentTaskSuccessSignature({ ...base, productCategories: ["game"] }), + deploymentTaskSuccessSignature({ ...base, productCategories: ["ai"] }) + ); + assert.equal( + deploymentTaskSuccessSignature({ ...base, productCategories: ["game"] }), + deploymentTaskSuccessSignature({ ...base, productCategories: ["game"] }) + ); +}); diff --git a/apps/ui/src/features/deploy/task/timeline.ts b/apps/ui/src/features/deploy/task/timeline.ts index 78f15eaa..5c79ab7d 100644 --- a/apps/ui/src/features/deploy/task/timeline.ts +++ b/apps/ui/src/features/deploy/task/timeline.ts @@ -162,6 +162,13 @@ export interface DeploymentTaskSuccessSnapshot { /** Contract headline; wins over the UI's default "You can start using it". */ headline?: string; openActionLabel?: string; + /** + * The product's catalog categories (e.g. `game`, `ai`) as snapshotted at + * verification time; only a template deployment declares them. The share + * copy reads these, never the live catalog (AIM-354). + */ + productCategories?: string[]; + /** The product's catalog identity; the template name for a template deployment. */ productId?: string; productName?: string; /** @@ -174,6 +181,17 @@ export interface DeploymentTaskSuccessSnapshot { verifiedAt: string; } +/** + * What a runner knows about the product it verified, read off the task's + * source. Every source has a name; a template also has its catalog name as + * the id and its categories. Empty or null facts are left out of the record. + */ +export interface DeploymentTaskSuccessProduct { + productCategories?: readonly string[] | null; + productId?: string | null; + productName: string | null; +} + /** What a caller may attach; the revision and stamp are owned by the timeline. */ export type DeploymentTaskSuccessAttachment = Omit< DeploymentTaskSuccessSnapshot, @@ -645,19 +663,43 @@ const MAX_SUCCESS_LABEL_LENGTH = 140; const MAX_SUCCESS_DETAIL_LENGTH = 280; const MAX_SUCCESS_URL_LENGTH = 2048; const MAX_SUCCESS_VERIFICATION_TOTAL = 64; +const MAX_SUCCESS_CATEGORIES = 16; +const MAX_SUCCESS_CATEGORY_LENGTH = 64; +const WHITESPACE_RUN_RE = /\s+/g; -/** Trims to a single presentable line, or drops the value when unusable. */ +/** + * Folds a value onto a single presentable line — interior line breaks and + * runs of whitespace become one space — or drops it when unusable. + */ function successText(value: unknown, maxLength: number): string | undefined { if (typeof value !== "string") { return undefined; } - const text = value.trim(); + const text = value.replace(WHITESPACE_RUN_RE, " ").trim(); if (text === "") { return undefined; } return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text; } +/** The declared categories as a short, de-duplicated list of trimmed names. */ +function successCategories(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const categories: string[] = []; + for (const candidate of value) { + const category = successText(candidate, MAX_SUCCESS_CATEGORY_LENGTH); + if (category != null && !categories.includes(category)) { + categories.push(category); + } + if (categories.length >= MAX_SUCCESS_CATEGORIES) { + break; + } + } + return categories.length === 0 ? undefined : categories; +} + /** * Accepts only an address that was actually declared. Deriving a URL — let * alone a protocol such as wss from https — is out of contract (#160). @@ -803,6 +845,7 @@ export function sanitizeDeploymentTaskSuccess( candidate.openActionLabel, MAX_SUCCESS_LABEL_LENGTH ); + const productCategories = successCategories(candidate.productCategories); const productId = successText(candidate.productId, MAX_SUCCESS_LABEL_LENGTH); const productName = successText( candidate.productName, @@ -818,6 +861,7 @@ export function sanitizeDeploymentTaskSuccess( ...(headline == null ? {} : { headline }), ...(guidance == null || guidance.length === 0 ? {} : { guidance }), ...(openActionLabel == null ? {} : { openActionLabel }), + ...(productCategories == null ? {} : { productCategories }), ...(productId == null ? {} : { productId }), ...(productName == null ? {} : { productName }), revision: revision ?? fallback.revision, @@ -845,6 +889,7 @@ export function deploymentTaskSuccessSignature( ]), headline: success.headline ?? "", openActionLabel: success.openActionLabel ?? "", + productCategories: success.productCategories ?? [], productId: success.productId ?? "", productName: success.productName ?? "", verification: success.verification @@ -864,14 +909,20 @@ export function deploymentTaskSuccessSignature( * record and the Timeline keeps reporting progress instead of announcing a * result the user cannot verify (issue #160). */ -export function deploymentTaskSuccessFromResultReadiness(input: { - productName: string | null; - requiredRunningCards: number; -}): DeploymentTaskSuccessAttachment | null { +export function deploymentTaskSuccessFromResultReadiness( + input: DeploymentTaskSuccessProduct & { + requiredRunningCards: number; + } +): DeploymentTaskSuccessAttachment | null { if (input.requiredRunningCards < 1) { return null; } + const productCategories = input.productCategories ?? []; return { + ...(productCategories.length === 0 + ? {} + : { productCategories: [...productCategories] }), + ...(input.productId == null ? {} : { productId: input.productId }), ...(input.productName == null ? {} : { productName: input.productName }), verification: { passed: input.requiredRunningCards, @@ -938,10 +989,9 @@ export function prioritizeSuccessEntries( export function deploymentTaskSuccessFromTimeline( timeline: DeploymentTaskTimelineSnapshot, - input: { + input: DeploymentTaskSuccessProduct & { /** The Default Open Port's best Public Address, when the task has one. */ primaryEntryUrl?: string | null; - productName: string | null; } ): DeploymentTaskSuccessAttachment | null { if (!deploymentTimelineResultReadinessReached(timeline)) { @@ -978,6 +1028,8 @@ export function deploymentTaskSuccessFromTimeline( input.primaryEntryUrl ); const success = deploymentTaskSuccessFromResultReadiness({ + productCategories: input.productCategories, + productId: input.productId, productName: input.productName, requiredRunningCards: runningCards.length, }); diff --git a/apps/ui/src/features/deploy/task/types.test.ts b/apps/ui/src/features/deploy/task/types.test.ts index 53c2f42f..b801f12c 100644 --- a/apps/ui/src/features/deploy/task/types.test.ts +++ b/apps/ui/src/features/deploy/task/types.test.ts @@ -77,3 +77,34 @@ test("deployment creation rejects inconsistent GitHub repository fields", () => assert.equal(parsed.success, false, JSON.stringify(repo)); } }); + +test("a template source keeps the template's categories, trimmed and capped", () => { + const parsed = createDeployTaskInputSchema.parse({ + namespace: "shared-workspace", + runner: { kind: "template" }, + source: { + kind: "template", + templateCategories: [" game ", "tool"], + templateName: "eaglercraft-server", + }, + target: { kind: "newProject" }, + }); + assert.equal(parsed.source.kind, "template"); + if (parsed.source.kind !== "template") { + return; + } + assert.deepEqual(parsed.source.templateCategories, ["game", "tool"]); + + const uncategorised = createDeployTaskInputSchema.parse({ + namespace: "shared-workspace", + runner: { kind: "template" }, + source: { kind: "template", templateName: "memos" }, + target: { kind: "newProject" }, + }); + assert.equal( + uncategorised.source.kind === "template" + ? uncategorised.source.templateCategories + : "wrong kind", + undefined + ); +}); diff --git a/apps/ui/src/features/deploy/task/types.ts b/apps/ui/src/features/deploy/task/types.ts index d7c9fe33..293a5635 100644 --- a/apps/ui/src/features/deploy/task/types.ts +++ b/apps/ui/src/features/deploy/task/types.ts @@ -97,6 +97,10 @@ export const deploymentTaskSourceSchema = z .array(z.string().trim().min(1).max(256)) .max(64) .optional(), + templateCategories: z + .array(z.string().trim().min(1).max(64)) + .max(16) + .optional(), templateName: z.string().trim().min(1).max(256), }), z.object({ diff --git a/apps/ui/src/features/deploy/template-deployment-pane.tsx b/apps/ui/src/features/deploy/template-deployment-pane.tsx index 9060c8bd..d1fa8581 100644 --- a/apps/ui/src/features/deploy/template-deployment-pane.tsx +++ b/apps/ui/src/features/deploy/template-deployment-pane.tsx @@ -69,7 +69,10 @@ export function TemplateDeploymentPane({ const billingNotice = useDeployBillingNotice(); const deploy = useCallback( - async (settings: TemplateDeploymentSettings) => { + async ( + settings: TemplateDeploymentSettings, + templateCategories: string[] | undefined + ) => { setDeploying(true); try { const outcome = await runDeploymentTargetPipeline({ @@ -85,6 +88,7 @@ export function TemplateDeploymentPane({ projectName, projectId, }), + templateCategories, templateName: settings.templateName, }, }); @@ -147,9 +151,9 @@ export function TemplateDeploymentPane({ errorMessage={templateCatalog.error?.message} initialSettings={initialSettings} loading={templateCatalog.isLoading} - onDeploy={(settings) => { + onDeploy={(settings, choice) => { overwriteGate.gate(() => { - deploy(settings).catch(() => undefined); + deploy(settings, choice.category).catch(() => undefined); }); }} templateOptions={templateCatalog.templates} diff --git a/apps/ui/src/features/projects/creation/use-project-creator.test.ts b/apps/ui/src/features/projects/creation/use-project-creator.test.ts index 8d863a87..00e8b421 100644 --- a/apps/ui/src/features/projects/creation/use-project-creator.test.ts +++ b/apps/ui/src/features/projects/creation/use-project-creator.test.ts @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { projectCreatorIntegrationState } from "./use-project-creator"; +import { + newProjectTemplateRequest, + projectCreatorIntegrationState, +} from "./use-project-creator"; test("project creator integrations are disabled while the creation pane is closed", () => { assert.deepEqual( @@ -63,3 +66,36 @@ test("project creator enables the selected optional integration", () => { } ); }); + +test("a new-project template request snapshots the chosen catalog item's categories", () => { + const settings = { + args: { port: "8080", token: "secret" }, + sensitiveKeys: ["token"], + templateName: "eaglercraft-server", + }; + assert.deepEqual( + newProjectTemplateRequest( + settings, + { category: ["game", "tool"] }, + { description: "My server", kind: "newProject" } + ), + { + args: { port: "8080", token: "secret" }, + kind: "template", + sensitiveKeys: ["token"], + target: { description: "My server", kind: "newProject" }, + templateCategories: ["game", "tool"], + templateName: "eaglercraft-server", + } + ); +}); + +test("a new-project template request declares no categories for a catalog item without them", () => { + const request = newProjectTemplateRequest( + { args: {}, sensitiveKeys: [], templateName: "memos" }, + {}, + { kind: "newProject" } + ); + assert.equal(request.templateCategories, undefined); + assert.equal(request.templateName, "memos"); +}); diff --git a/apps/ui/src/features/projects/creation/use-project-creator.ts b/apps/ui/src/features/projects/creation/use-project-creator.ts index 874017d3..3e832f03 100644 --- a/apps/ui/src/features/projects/creation/use-project-creator.ts +++ b/apps/ui/src/features/projects/creation/use-project-creator.ts @@ -17,11 +17,16 @@ import { useGithubAuth } from "@/features/deploy/github/use-github-auth"; import { useGithubRepos } from "@/features/deploy/github/use-github-repos"; import type { GithubDeployerRepo } from "@/features/deploy/github-deployer/github-deployer.types"; import { + type DeploymentTarget, type DeploymentTargetPipelineOutcome, + type DeploymentTargetPipelineRequest, newProjectDeploymentTarget, runDeploymentTargetPipeline, } from "@/features/deploy/pipeline"; -import type { TemplateDeploymentSettings } from "@/features/deploy/template-deployer"; +import type { + TemplateDeploymentChoice, + TemplateDeploymentSettings, +} from "@/features/deploy/template-deployer"; import { useDeploymentTargetAdapters } from "@/features/deploy/use-deployment-target-adapters"; import { useTemplateCatalog } from "@/features/deploy/use-template-catalog"; import { requestAssistantDraftThread } from "@/features/panes/layout-store"; @@ -122,6 +127,28 @@ export interface UseProjectCreatorOptions { ) => void | Promise; } +/** + * The create request a new-project template deploy sends, from either the + * Template method or a GitHub template recommendation. The chosen catalog + * item's categories ride along so the task source — and the Deployment Task + * Success Record written from it — snapshot them (AIM-354), exactly as the + * in-project template panes do; a catalog item without categories declares + * none. + */ +export function newProjectTemplateRequest( + settings: TemplateDeploymentSettings, + template: Pick, + target: DeploymentTarget +): Extract { + return { + args: settings.args, + kind: "template", + sensitiveKeys: settings.sensitiveKeys, + target, + templateCategories: template.category, + templateName: settings.templateName, + }; +} export function useProjectCreator(options?: UseProjectCreatorOptions): { creatorRootProps: CreatorRootPropsForCreationPane; creatorResetKey: number; @@ -342,13 +369,13 @@ export function useProjectCreator(options?: UseProjectCreatorOptions): { const outcome = await trackDeploymentCreateOnSuccess( "template", () => - runDeployment({ - args: settings.args, - kind: "template", - sensitiveKeys: settings.sensitiveKeys, - target: newProjectDeploymentTarget(description), - templateName: settings.templateName, - }), + runDeployment( + newProjectTemplateRequest( + settings, + choice, + newProjectDeploymentTarget(description) + ) + ), { template_name: settings.templateName } ); if (outcome.kind !== "template") { @@ -447,13 +474,13 @@ export function useProjectCreator(options?: UseProjectCreatorOptions): { const outcome = await trackDeploymentCreateOnSuccess( "template", () => - runDeployment({ - args: input.settings.args, - kind: "template", - sensitiveKeys: input.settings.sensitiveKeys, - target: newProjectDeploymentTarget(), - templateName: input.settings.templateName, - }), + runDeployment( + newProjectTemplateRequest( + input.settings, + input.template, + newProjectDeploymentTarget() + ) + ), { template_name: input.settings.templateName } ); if (outcome.kind !== "template") { diff --git a/bun.lock b/bun.lock index a1745f75..f3a0cc4b 100644 --- a/bun.lock +++ b/bun.lock @@ -89,6 +89,7 @@ "nuqs": "^2.8.9", "ogl": "^1.0.11", "pg": "^8.20.0", + "qrcode.react": "^4.2.0", "react": "19.2.6", "react-dom": "19.2.6", "react-zoom-pan-pinch": "^3.7.0", @@ -2097,6 +2098,8 @@ "punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="], + "qs": ["qs@6.5.5", "https://registry.npmmirror.com/qs/-/qs-6.5.5.tgz", {}, "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ=="], "queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],