From c4157b5ef21c1199e260faacb01442c4ba8c72a4 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 8 Sep 2026 14:33:11 +0800 Subject: [PATCH 1/9] feat(deploy): share strip and Next steps trail under the success card (AIM-354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a deployment verifies, the moment a product goes live is the moment people want to show it to someone. The Deployment Task Success Record now carries a quiet share strip under the card — `Share `, a QR popover for a phone (`Open on your phone` + host, dark-on-light in both themes, viewfinder corners), and one-click posts to X, LinkedIn, Facebook and Reddit. It appears only when the record has a primary HTTP(S) entry and always shares that entry's URL exactly as the record snapshotted it; every link opens a new tab with no referrer, so nothing of Brain is shared. The declared first-use steps move out of the card, where they competed with the verified facts, into a `Next steps` trail below the strip: a numbered circle per step, a hairline between them, the label and its optional monospace detail. It renders only when the record declared at least one step, independently of the strip. The section root becomes a wrapper owning card, strip and trail, so the scroll-into-view target and the `deployment-task-success` slot cover the whole conclusion; the Timeline pane still renders one section (ADR-0078). The share module is feature-local and its copy/href builders are pure. No contract, sanitizer, API or runner change. Ships the CONTEXT.md sentences for sharing and `Next steps`, adds the qrcode.react dependency, and removes the deployment-success-share prototype route now that its Merge variant has been promoted. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AwJfKahqCYxMzJXGbF522M --- CONTEXT.md | 4 +- apps/ui/package.json | 1 + .../deployment-task-success-section.tsx | 209 +++++++------ .../deployment-task-success-share.test.tsx | 120 ++++++++ .../deploy/deployment-task-success-share.tsx | 283 ++++++++++++++++++ .../deployment-task-timeline-pane.test.tsx | 230 ++++++++++++++ bun.lock | 3 + 7 files changed, 762 insertions(+), 88 deletions(-) create mode 100644 apps/ui/src/features/deploy/deployment-task-success-share.test.tsx create mode 100644 apps/ui/src/features/deploy/deployment-task-success-share.tsx diff --git a/CONTEXT.md b/CONTEXT.md index 120d9431..ed6a8f4a 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. -_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/deploy/deployment-task-success-section.tsx b/apps/ui/src/features/deploy/deployment-task-success-section.tsx index cc0c42e2..395a987a 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"; @@ -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,94 @@ 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..f386eca0 --- /dev/null +++ b/apps/ui/src/features/deploy/deployment-task-success-share.test.tsx @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { + DEPLOYMENT_TASK_SUCCESS_SHARE_CHANNELS, + DeploymentTaskSuccessQrPanel, + shareHost, + shareText, + shareTitle, +} from "./deployment-task-success-share"; + +const URL_WITH_RESERVED = "https://demo.sealos.run/path?a=1&b=2#top"; +const OPEN_ON_PHONE_RE = /Open on your phone/; +const PANEL_HOST_RE = />meetinghub\.sealos\.run candidate.id === id + ); + assert.ok(found, `channel ${id} is declared`); + return found; +} + +test("share copy reads as an announcement when the product has a name", () => { + assert.equal( + shareText("MeetingHub", "https://meetinghub.sealos.run"), + "Just launched MeetingHub 🚀 https://meetinghub.sealos.run" + ); + assert.equal( + shareTitle("MeetingHub", "https://meetinghub.sealos.run"), + "Just launched MeetingHub" + ); +}); + +test("share copy invents nothing when the product has no name", () => { + assert.equal( + shareText(undefined, "https://meetinghub.sealos.run"), + "https://meetinghub.sealos.run" + ); + assert.equal( + shareTitle(undefined, "https://meetinghub.sealos.run:8443/x"), + "meetinghub.sealos.run:8443" + ); +}); + +test("an unparsable address falls back to the raw address for the title", () => { + assert.equal(shareTitle(undefined, "not a url"), "not a url"); + 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); + assert.equal( + channel("x").href(URL_WITH_RESERVED, "My App"), + `https://x.com/intent/post?text=${encodeURIComponent( + `Just launched My App 🚀 ${URL_WITH_RESERVED}` + )}` + ); + assert.equal( + channel("linkedin").href(URL_WITH_RESERVED, "My App"), + `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}` + ); + assert.equal( + channel("facebook").href(URL_WITH_RESERVED, "My App"), + `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}` + ); + assert.equal( + channel("reddit").href(URL_WITH_RESERVED, "My App"), + `https://www.reddit.com/submit?url=${encodedUrl}&title=${encodeURIComponent( + "Just launched My App" + )}` + ); + // The raw `&` and `#` never appear unencoded in a query value. + for (const entry of DEPLOYMENT_TASK_SUCCESS_SHARE_CHANNELS) { + const href = entry.href(URL_WITH_RESERVED, undefined); + 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`); + } +}); + +test("without a product name Reddit titles the submission with the host", () => { + assert.equal( + channel("reddit").href("https://demo.sealos.run/x", undefined), + `https://www.reddit.com/submit?url=${encodeURIComponent( + "https://demo.sealos.run/x" + )}&title=demo.sealos.run` + ); +}); + +test("the QR panel says what a scan opens and shows the host", () => { + const html = renderToStaticMarkup( + + ); + assert.match(html, OPEN_ON_PHONE_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..90f36c7c --- /dev/null +++ b/apps/ui/src/features/deploy/deployment-task-success-share.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { appIconButtonVariants } from "@workspace/ui/components/app-icon-button"; +import { + Popover, + PopoverContent, + 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). This module is feature-local + * on purpose: it moves to `@workspace/ui` only if a second consumer appears. + */ + +/* ------------------------------------------------------------- pure copy */ + +/** The post body for networks that take free text: the address alone when the product has no name. */ +export function shareText(productName: string | undefined, url: string) { + return productName == null ? url : `Just launched ${productName} 🚀 ${url}`; +} + +/** 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; + } +} + +/** The submission title for networks that take one: the host when the product has no name. */ +export function shareTitle(productName: string | undefined, url: string) { + return productName == null ? shareHost(url) : `Just launched ${productName}`; +} + +/* --------------------------------------------------------------- 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 product address and, when declared, its name. */ + href: (url: string, productName: string | undefined) => string; + Icon: (props: ComponentProps<"svg">) => ReactNode; + id: "facebook" | "linkedin" | "reddit" | "x"; + /** The accessible name of the control, also its title. */ + label: string; +} + +const enc = encodeURIComponent; + +/** The fixed share channels, in the order the strip draws them. */ +export const DEPLOYMENT_TASK_SUCCESS_SHARE_CHANNELS: readonly DeploymentTaskSuccessShareChannel[] = + [ + { + Icon: XIcon, + href: (url, name) => + `https://x.com/intent/post?text=${enc(shareText(name, url))}`, + id: "x", + label: "Post on X", + }, + { + Icon: LinkedInIcon, + href: (url) => + `https://www.linkedin.com/sharing/share-offsite/?url=${enc(url)}`, + id: "linkedin", + label: "Share on LinkedIn", + }, + { + Icon: FacebookIcon, + href: (url) => `https://www.facebook.com/sharer/sharer.php?u=${enc(url)}`, + id: "facebook", + label: "Share on Facebook", + }, + { + Icon: RedditIcon, + href: (url, name) => + `https://www.reddit.com/submit?url=${enc(url)}&title=${enc(shareTitle(name, url))}`, + 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 ( + + + + ); +} + +const SCAN_CORNER = "pointer-events-none absolute size-3 border-blue-400/70"; + +/** Viewfinder brackets around a QR: the four corners read as "scan me". */ +function ScanFrame({ children }: { children: ReactNode }) { + return ( + + + + + + {children} + + ); +} + +const QR_SIZE = 128; + +/** The popover's body: the framed QR, what a scan opens, and the host it opens. */ +export function DeploymentTaskSuccessQrPanel({ url }: { url: string }) { + return ( + <> + + + +
+ + Open on your phone + + + {shareHost(url)} + +
+ + ); +} + +/** The quiet small icon control every share affordance in the strip uses, so the strip matches the card's copy controls. */ +const SHARE_CONTROL_CLASS = cn( + appIconButtonVariants({ size: "sm", variant: "quiet" }), + "inline-flex cursor-pointer items-center justify-center outline-none" +); + +function QrPopover({ url }: { url: string }) { + return ( + + + + + + + + + ); +} + +/* -------------------------------------------------------------- the strip */ + +/** + * `Share ` on the left; on the right the QR trigger, then one icon + * link per channel. 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, + productName, + url, +}: { + className?: string; + productName: string | undefined; + url: string; +}) { + return ( +
+ + {productName == null ? "Share" : `Share ${productName}`} + +
+ + {DEPLOYMENT_TASK_SUCCESS_SHARE_CHANNELS.map( + ({ Icon, href, id, label }) => ( + + + + ) + )} +
+
+ ); +} 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..ebffdc9d 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,236 @@ 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 ServerShare1<\/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 `` tag 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}")[^>]*>`) + ); + 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( + "Just launched EaglerCraft Server" + )}`, + "Post on X": `https://x.com/intent/post?text=${encodeURIComponent( + `Just launched EaglerCraft Server 🚀 ${url}` + )}`, + "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`); + } + // 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); + assert.doesNotMatch(html, JUST_LAUNCHED_RE); + const x = shareLink(html, "Post on X"); + assert.ok(x); + assert.ok( + x.includes( + `href="https://x.com/intent/post?text=${encodeURIComponent(url)}"` + ) + ); + 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=web-app.demo.sealos.run` + )}"` + ) + ); + // 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("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/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=="], From 71a10ad382d7bd59f81f3898dea29ba3c30e3a0d Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 8 Sep 2026 14:38:57 +0800 Subject: [PATCH 2/9] refactor(deploy): draw share controls with the app icon button wrapper Review follow-up for AIM-354. The QR trigger and the four channel links now compose AppIconButton through its render prop, as the copy controls and the Open link already do, instead of reaching for the raw variants. The numbered circle uses the text-[11px] form the card already had, the viewfinder corners are one mapped list, the encoder alias is gone, and `Next steps` is a real heading. The href test now covers a space inside the address itself, not only inside the product name. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AwJfKahqCYxMzJXGbF522M --- .../deployment-task-success-section.tsx | 6 +- .../deployment-task-success-share.test.tsx | 10 +- .../deploy/deployment-task-success-share.tsx | 114 +++++++++--------- 3 files changed, 67 insertions(+), 63 deletions(-) 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 395a987a..a2e7ec17 100644 --- a/apps/ui/src/features/deploy/deployment-task-success-section.tsx +++ b/apps/ui/src/features/deploy/deployment-task-success-section.tsx @@ -201,7 +201,7 @@ function NextStepsTrail({ steps }: { steps: DeploymentTaskSuccessStep[] }) { )} {index + 1} @@ -359,9 +359,9 @@ export const DeploymentTaskSuccessSection = memo( className={cn(RISE_CLASS, "mt-5 text-left delay-[480ms]")} data-slot="deployment-task-success-next-steps" > -

+

Next steps -

+

)} 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 index f386eca0..8081f245 100644 --- a/apps/ui/src/features/deploy/deployment-task-success-share.test.tsx +++ b/apps/ui/src/features/deploy/deployment-task-success-share.test.tsx @@ -10,6 +10,7 @@ import { } 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 { "Just launched My App" )}` ); - // The raw `&` and `#` never appear unencoded in a query value. + // 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(URL_WITH_RESERVED, undefined); + const href = entry.href(URL_WITH_SPACE, "My App"); 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` + ); } }); diff --git a/apps/ui/src/features/deploy/deployment-task-success-share.tsx b/apps/ui/src/features/deploy/deployment-task-success-share.tsx index 90f36c7c..1c10f9c2 100644 --- a/apps/ui/src/features/deploy/deployment-task-success-share.tsx +++ b/apps/ui/src/features/deploy/deployment-task-success-share.tsx @@ -1,6 +1,6 @@ "use client"; -import { appIconButtonVariants } from "@workspace/ui/components/app-icon-button"; +import { AppIconButton } from "@workspace/ui/components/app-icon-button"; import { Popover, PopoverContent, @@ -89,35 +89,34 @@ export interface DeploymentTaskSuccessShareChannel { label: string; } -const enc = encodeURIComponent; - /** The fixed share channels, in the order the strip draws them. */ export const DEPLOYMENT_TASK_SUCCESS_SHARE_CHANNELS: readonly DeploymentTaskSuccessShareChannel[] = [ { Icon: XIcon, href: (url, name) => - `https://x.com/intent/post?text=${enc(shareText(name, url))}`, + `https://x.com/intent/post?text=${encodeURIComponent(shareText(name, url))}`, id: "x", label: "Post on X", }, { Icon: LinkedInIcon, href: (url) => - `https://www.linkedin.com/sharing/share-offsite/?url=${enc(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=${enc(url)}`, + href: (url) => + `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`, id: "facebook", label: "Share on Facebook", }, { Icon: RedditIcon, href: (url, name) => - `https://www.reddit.com/submit?url=${enc(url)}&title=${enc(shareTitle(name, url))}`, + `https://www.reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent(shareTitle(name, url))}`, id: "reddit", label: "Post on Reddit", }, @@ -145,40 +144,28 @@ function QrTile({ size, url }: { size: number; url: string }) { ); } -const SCAN_CORNER = "pointer-events-none absolute size-3 border-blue-400/70"; +/** 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} ); @@ -208,23 +195,23 @@ export function DeploymentTaskSuccessQrPanel({ url }: { url: string }) { ); } -/** The quiet small icon control every share affordance in the strip uses, so the strip matches the card's copy controls. */ -const SHARE_CONTROL_CLASS = cn( - appIconButtonVariants({ size: "sm", variant: "quiet" }), - "inline-flex cursor-pointer items-center justify-center outline-none" -); - function QrPopover({ url }: { url: string }) { return ( - - + render={ + + + + } + /> ` on the left; on the right the QR trigger, then one icon - * link per channel. Every link opens in a new tab and sends no referrer, so - * the Brain tab stays put and shares nothing of Brain. + * 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, @@ -264,17 +252,27 @@ export function DeploymentTaskSuccessShareStrip({ {DEPLOYMENT_TASK_SUCCESS_SHARE_CHANNELS.map( ({ Icon, href, id, label }) => ( -
+ + + } + size="sm" title={label} + variant="quiet" > - - + {null} + ) )} From 10850037288b97753e70994bbdd7d8454ea77659 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 8 Sep 2026 15:34:08 +0800 Subject: [PATCH 3/9] style(deploy): let the success conclusion breathe above the pane's edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The share strip's quiet icon buttons ended flush on the Timeline pane's own padding, so the result read as cramped at the bottom. The section wrapper now carries a little bottom padding of its own, so whichever block ends the conclusion — the card, the strip, or the Next steps trail — sits off the pane border. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AwJfKahqCYxMzJXGbF522M --- apps/ui/src/features/deploy/deployment-task-success-section.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a2e7ec17..6d0ab45a 100644 --- a/apps/ui/src/features/deploy/deployment-task-success-section.tsx +++ b/apps/ui/src/features/deploy/deployment-task-success-section.tsx @@ -277,7 +277,7 @@ export const DeploymentTaskSuccessSection = memo( return (
From 735342cb7c71e16d5ce3e578f958881d23f02d8f Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 8 Sep 2026 16:57:01 +0800 Subject: [PATCH 4/9] feat(deploy): snapshot the template's name and categories into the success record The Deployment Task Success Record could name the product but not say what kind of product it was: a template's catalog categories stopped at the catalog and the deploy form, never reaching the task or the record. The share copy (AIM-354) wants to speak differently to a game server and to an AI app, and reading the live catalog at render time would break the rule that the record is a snapshot. A template-sourced Deployment Task now carries `templateCategories` from the catalog item the user chose, and the runner writes the template name into the record's `productId` (declared in the contract, never filled until now) and the categories into a new optional `productCategories`. The sanitizer trims, de-duplicates and caps the list; the record's signature includes it. Other sources declare neither, and records written before this change simply lack the fields. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AwJfKahqCYxMzJXGbF522M --- CONTEXT.md | 2 +- .../deploy/github-deployment-pane.tsx | 1 + apps/ui/src/features/deploy/pipeline.test.ts | 2 + apps/ui/src/features/deploy/pipeline.ts | 6 ++ .../features/deploy/task/managed-timeline.ts | 6 +- .../features/deploy/task/projection.test.ts | 31 +++++++ .../ui/src/features/deploy/task/projection.ts | 22 +++++ apps/ui/src/features/deploy/task/runner.ts | 6 +- apps/ui/src/features/deploy/task/schema.ts | 7 ++ .../src/features/deploy/task/timeline.test.ts | 87 +++++++++++++++++++ apps/ui/src/features/deploy/task/timeline.ts | 60 +++++++++++-- .../ui/src/features/deploy/task/types.test.ts | 31 +++++++ apps/ui/src/features/deploy/task/types.ts | 4 + .../deploy/template-deployment-pane.tsx | 10 ++- 14 files changed, 260 insertions(+), 15 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index ed6a8f4a..20fb5e22 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -380,7 +380,7 @@ 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. 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. +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, share project, share deployment. 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..25f74dd7 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,57 @@ 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.ok((many?.productCategories?.length ?? 0) < 40); +}); + +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..ed745a5c 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,6 +663,8 @@ 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; /** Trims to a single presentable line, or drops the value when unusable. */ function successText(value: unknown, maxLength: number): string | undefined { @@ -658,6 +678,24 @@ function successText(value: unknown, maxLength: number): string | 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 +841,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 +857,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 +885,7 @@ export function deploymentTaskSuccessSignature( ]), headline: success.headline ?? "", openActionLabel: success.openActionLabel ?? "", + productCategories: success.productCategories ?? [], productId: success.productId ?? "", productName: success.productName ?? "", verification: success.verification @@ -864,14 +905,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 +985,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 +1024,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} From 4db83108ba4e6b5e18b6509b7af16726bb756f59 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 8 Sep 2026 16:59:56 +0800 Subject: [PATCH 5/9] feat(deploy): speak in the user's voice when sharing to X and Reddit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The X post was a bare "Just launched 🚀 ". It now reads like the user announcing their own launch and names Sealos: the generic post, plus a game-server voice, an AI-app voice, and one hard-coded voice for the EaglerCraft server. The voice is chosen from the facts the success record snapshotted — the product id first, then the first category — never from the live catalog, so a record keeps the post it was written with. A record without a product name drops the name rather than inventing one, and no post promises a deployment time. Reddit's link-post title becomes " is live — just shipped with Sealos" ("My app is live …" without a name). LinkedIn and Facebook still share the address alone. The handle and hashtags are module constants; the copy and href builders stay pure and tested. This reverses two decisions recorded on AIM-354 (the 🚀 copy, and no platform attribution) and #336's "no product branch"; the issue is revised to match. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AwJfKahqCYxMzJXGbF522M --- .../deployment-task-success-section.tsx | 8 +- .../deployment-task-success-share.test.tsx | 129 ++++++++++++++---- .../deploy/deployment-task-success-share.tsx | 129 ++++++++++++++---- .../deployment-task-timeline-pane.test.tsx | 82 +++++++++-- 4 files changed, 289 insertions(+), 59 deletions(-) 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 6d0ab45a..be0281a1 100644 --- a/apps/ui/src/features/deploy/deployment-task-success-section.tsx +++ b/apps/ui/src/features/deploy/deployment-task-success-section.tsx @@ -350,8 +350,12 @@ export const DeploymentTaskSuccessSection = memo( {primaryEntry == null ? null : ( )} {guidance.length === 0 ? null : ( 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 index 8081f245..0f823c03 100644 --- a/apps/ui/src/features/deploy/deployment-task-success-share.test.tsx +++ b/apps/ui/src/features/deploy/deployment-task-success-share.test.tsx @@ -4,9 +4,10 @@ import { renderToStaticMarkup } from "react-dom/server"; import { DEPLOYMENT_TASK_SUCCESS_SHARE_CHANNELS, DeploymentTaskSuccessQrPanel, + redditTitle, shareHost, - shareText, - shareTitle, + shareVoice, + xPostText, } from "./deployment-task-success-share"; const URL_WITH_RESERVED = "https://demo.sealos.run/path?a=1&b=2#top"; @@ -25,30 +26,105 @@ function channel(id: string) { return found; } -test("share copy reads as an announcement when the product has a name", () => { +const URL = "https://meetinghub.sealos.run"; + +test("the generic X post names the product and Sealos, one line at a time", () => { assert.equal( - shareText("MeetingHub", "https://meetinghub.sealos.run"), - "Just launched MeetingHub 🚀 https://meetinghub.sealos.run" + xPostText({ productName: "MeetingHub", url: URL }), + [ + "Just deployed MeetingHub with Sealos.", + "From idea to live app.", + `Try it here: ${URL} @Sealos_io`, + "#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.", + "From idea to live app.", + `Try it here: ${URL} @Sealos_io`, + "#Sealos #BuildInPublic", + ].join("\n") ); assert.equal( - shareTitle("MeetingHub", "https://meetinghub.sealos.run"), - "Just launched MeetingHub" + 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("share copy invents nothing when the product has no name", () => { +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( - shareText(undefined, "https://meetinghub.sealos.run"), - "https://meetinghub.sealos.run" + 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( - shareTitle(undefined, "https://meetinghub.sealos.run:8443/x"), - "meetinghub.sealos.run:8443" + 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 unparsable address falls back to the raw address for the title", () => { - assert.equal(shareTitle(undefined, "not a url"), "not a url"); +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"); }); @@ -70,30 +146,31 @@ test("the four channels are declared in order with accessible labels", () => { 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(URL_WITH_RESERVED, "My App"), - `https://x.com/intent/post?text=${encodeURIComponent( - `Just launched My App 🚀 ${URL_WITH_RESERVED}` - )}` + channel("x").href(subject), + `https://x.com/intent/post?text=${encodeURIComponent(xPostText(subject))}` ); + // Line breaks reach X as %0A, so the post keeps its shape. + assert.ok(channel("x").href(subject).includes("%0A")); assert.equal( - channel("linkedin").href(URL_WITH_RESERVED, "My App"), + channel("linkedin").href(subject), `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}` ); assert.equal( - channel("facebook").href(URL_WITH_RESERVED, "My App"), + channel("facebook").href(subject), `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}` ); assert.equal( - channel("reddit").href(URL_WITH_RESERVED, "My App"), + channel("reddit").href(subject), `https://www.reddit.com/submit?url=${encodedUrl}&title=${encodeURIComponent( - "Just launched My App" + "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(URL_WITH_SPACE, "My App"); + 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`); @@ -104,12 +181,12 @@ test("every channel encodes the address so reserved characters survive", () => { } }); -test("without a product name Reddit titles the submission with the host", () => { +test("without a product name Reddit still gets a title", () => { assert.equal( - channel("reddit").href("https://demo.sealos.run/x", undefined), + channel("reddit").href({ url: "https://demo.sealos.run/x" }), `https://www.reddit.com/submit?url=${encodeURIComponent( "https://demo.sealos.run/x" - )}&title=demo.sealos.run` + )}&title=${encodeURIComponent("My app is live — just shipped with Sealos")}` ); }); diff --git a/apps/ui/src/features/deploy/deployment-task-success-share.tsx b/apps/ui/src/features/deploy/deployment-task-success-share.tsx index 1c10f9c2..c1d5b3b0 100644 --- a/apps/ui/src/features/deploy/deployment-task-success-share.tsx +++ b/apps/ui/src/features/deploy/deployment-task-success-share.tsx @@ -17,15 +17,104 @@ import type { ComponentProps, ReactNode } from "react"; * * 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). This module is feature-local - * on purpose: it moves to `@workspace/ui` only if a second consumer appears. + * (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 post body for networks that take free text: the address alone when the product has no name. */ -export function shareText(productName: string | undefined, url: string) { - return productName == null ? url : `Just launched ${productName} 🚀 ${url}`; +/** 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, one line per array entry; the product name is dropped, never invented. */ +export function xPostText(subject: DeploymentTaskShareSubject): string { + const name = subject.productName; + const lines = ((): string[] => { + switch (shareVoice(subject)) { + case "eaglercraft": + return [ + `My own Eaglercraft server is live! ${SEALOS_X_HANDLE}`, + "Deployed with Sealos.", + `Join here: ${subject.url}`, + "#Eaglercraft #Minecraft #Sealos", + ]; + case "game": + return [ + `My own game server is live! ${SEALOS_X_HANDLE}`, + "Deployed with Sealos.", + `Join here: ${subject.url}`, + "#Sealos", + ]; + case "ai": + return [ + 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.", + `Try it here: ${subject.url}`, + "#AI #BuildInPublic #Sealos", + ]; + default: + return [ + name == null + ? "Just deployed with Sealos." + : `Just deployed ${name} with Sealos.`, + "From idea to live app.", + `Try it here: ${subject.url} ${SEALOS_X_HANDLE}`, + "#Sealos #BuildInPublic", + ]; + } + })(); + return lines.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. */ @@ -37,11 +126,6 @@ export function shareHost(url: string) { } } -/** The submission title for networks that take one: the host when the product has no name. */ -export function shareTitle(productName: string | undefined, url: string) { - return productName == null ? shareHost(url) : `Just launched ${productName}`; -} - /* --------------------------------------------------------------- channels */ function XIcon(props: ComponentProps<"svg">) { @@ -81,8 +165,8 @@ function RedditIcon(props: ComponentProps<"svg">) { } export interface DeploymentTaskSuccessShareChannel { - /** The share URL for the product address and, when declared, its name. */ - href: (url: string, productName: string | undefined) => string; + /** 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. */ @@ -94,29 +178,29 @@ export const DEPLOYMENT_TASK_SUCCESS_SHARE_CHANNELS: readonly DeploymentTaskSucc [ { Icon: XIcon, - href: (url, name) => - `https://x.com/intent/post?text=${encodeURIComponent(shareText(name, url))}`, + href: (subject) => + `https://x.com/intent/post?text=${encodeURIComponent(xPostText(subject))}`, id: "x", label: "Post on X", }, { Icon: LinkedInIcon, - href: (url) => + href: ({ url }) => `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`, id: "linkedin", label: "Share on LinkedIn", }, { Icon: FacebookIcon, - href: (url) => + href: ({ url }) => `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`, id: "facebook", label: "Share on Facebook", }, { Icon: RedditIcon, - href: (url, name) => - `https://www.reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent(shareTitle(name, url))}`, + href: ({ productName, url }) => + `https://www.reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent(redditTitle(productName))}`, id: "reddit", label: "Post on Reddit", }, @@ -233,13 +317,12 @@ function QrPopover({ url }: { url: string }) { */ export function DeploymentTaskSuccessShareStrip({ className, - productName, - url, + subject, }: { className?: string; - productName: string | undefined; - url: string; + subject: DeploymentTaskShareSubject; }) { + const { productName, url } = subject; return (
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 ebffdc9d..02faf41d 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 @@ -1126,7 +1126,6 @@ const BARE_SHARE_LABEL_RE = />Share1<\/span>/; @@ -1189,10 +1188,17 @@ test("each share channel posts the snapshotted address in a new tab without a re const encodedUrl = encodeURIComponent(url); const expectedHrefs: Record = { "Post on Reddit": `https://www.reddit.com/submit?url=${encodedUrl}&title=${encodeURIComponent( - "Just launched EaglerCraft Server" + "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 launched EaglerCraft Server 🚀 ${url}` + [ + "Just deployed EaglerCraft Server with Sealos.", + "From idea to live app.", + `Try it here: ${url} @Sealos_io`, + "#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}`, @@ -1236,28 +1242,88 @@ test("a record without a product name shares plainly and invents no name", () => ); assert.match(html, BARE_SHARE_LABEL_RE); - assert.doesNotMatch(html, JUST_LAUNCHED_RE); const x = shareLink(html, "Post on X"); assert.ok(x); assert.ok( x.includes( - `href="https://x.com/intent/post?text=${encodeURIComponent(url)}"` - ) + `href="https://x.com/intent/post?text=${encodeURIComponent( + [ + "Just deployed with Sealos.", + "From idea to live app.", + `Try it here: ${url} @Sealos_io`, + "#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=web-app.demo.sealos.run` + `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({ From bec08cc40c6a56e8c874f2344f1b04cef4abb22c Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 8 Sep 2026 17:08:42 +0800 Subject: [PATCH 6/9] style(deploy): let the X post breathe in three beats Four lines run together read as a block, with the hashtags jammed under a long wrapped link. The post now separates its announcement, its link and its hashtags with blank lines, the way posts on X usually scan, and the generic voice moves @Sealos_io up to the first line so every voice opens the same way and the link stands alone. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AwJfKahqCYxMzJXGbF522M --- .../deployment-task-success-share.test.tsx | 25 ++++-- .../deploy/deployment-task-success-share.tsx | 78 +++++++++++-------- .../deployment-task-timeline-pane.test.tsx | 12 ++- 3 files changed, 73 insertions(+), 42 deletions(-) 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 index 0f823c03..550f3aed 100644 --- a/apps/ui/src/features/deploy/deployment-task-success-share.test.tsx +++ b/apps/ui/src/features/deploy/deployment-task-success-share.test.tsx @@ -28,13 +28,15 @@ function channel(id: string) { const URL = "https://meetinghub.sealos.run"; -test("the generic X post names the product and Sealos, one line at a time", () => { +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.", + "Just deployed MeetingHub with Sealos. @Sealos_io", "From idea to live app.", - `Try it here: ${URL} @Sealos_io`, + "", + `Try it here: ${URL}`, + "", "#Sealos #BuildInPublic", ].join("\n") ); @@ -44,9 +46,11 @@ test("the X post invents no name and no number when the record has none", () => assert.equal( xPostText({ url: URL }), [ - "Just deployed with Sealos.", + "Just deployed with Sealos. @Sealos_io", "From idea to live app.", - `Try it here: ${URL} @Sealos_io`, + "", + `Try it here: ${URL}`, + "", "#Sealos #BuildInPublic", ].join("\n") ); @@ -81,7 +85,9 @@ test("a game server and the EaglerCraft server invite people to join", () => { [ "My own game server is live! @Sealos_io", "Deployed with Sealos.", + "", `Join here: ${URL}`, + "", "#Sealos", ].join("\n") ); @@ -95,7 +101,9 @@ test("a game server and the EaglerCraft server invite people to join", () => { [ "My own Eaglercraft server is live! @Sealos_io", "Deployed with Sealos.", + "", `Join here: ${URL}`, + "", "#Eaglercraft #Minecraft #Sealos", ].join("\n") ); @@ -107,7 +115,9 @@ test("an AI app takes the idea-to-live-app voice", () => { [ "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") ); @@ -151,8 +161,9 @@ test("every channel encodes the address so reserved characters survive", () => { channel("x").href(subject), `https://x.com/intent/post?text=${encodeURIComponent(xPostText(subject))}` ); - // Line breaks reach X as %0A, so the post keeps its shape. - assert.ok(channel("x").href(subject).includes("%0A")); + // 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}` diff --git a/apps/ui/src/features/deploy/deployment-task-success-share.tsx b/apps/ui/src/features/deploy/deployment-task-success-share.tsx index c1d5b3b0..d865dbb2 100644 --- a/apps/ui/src/features/deploy/deployment-task-success-share.tsx +++ b/apps/ui/src/features/deploy/deployment-task-success-share.tsx @@ -68,46 +68,62 @@ export function shareVoice( } } -/** The X post, one line per array entry; the product name is dropped, never invented. */ +/** + * 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 lines = ((): string[] => { + const { announcement, link, tags } = ((): { + announcement: [string, string]; + link: string; + tags: string; + } => { switch (shareVoice(subject)) { case "eaglercraft": - return [ - `My own Eaglercraft server is live! ${SEALOS_X_HANDLE}`, - "Deployed with Sealos.", - `Join here: ${subject.url}`, - "#Eaglercraft #Minecraft #Sealos", - ]; + 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 [ - `My own game server is live! ${SEALOS_X_HANDLE}`, - "Deployed with Sealos.", - `Join here: ${subject.url}`, - "#Sealos", - ]; + return { + announcement: [ + `My own game server is live! ${SEALOS_X_HANDLE}`, + "Deployed with Sealos.", + ], + link: `Join here: ${subject.url}`, + tags: "#Sealos", + }; case "ai": - return [ - 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.", - `Try it here: ${subject.url}`, - "#AI #BuildInPublic #Sealos", - ]; + 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 [ - name == null - ? "Just deployed with Sealos." - : `Just deployed ${name} with Sealos.`, - "From idea to live app.", - `Try it here: ${subject.url} ${SEALOS_X_HANDLE}`, - "#Sealos #BuildInPublic", - ]; + 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 lines.join("\n"); + return [...announcement, "", link, "", tags].join("\n"); } /** The Reddit link-post title; the post's content is the address itself. */ 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 02faf41d..2a8039aa 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 @@ -1194,9 +1194,11 @@ test("each share channel posts the snapshotted address in a new tab without a re // 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.", + "Just deployed EaglerCraft Server with Sealos. @Sealos_io", "From idea to live app.", - `Try it here: ${url} @Sealos_io`, + "", + `Try it here: ${url}`, + "", "#Sealos #BuildInPublic", ].join("\n") )}`, @@ -1248,9 +1250,11 @@ test("a record without a product name shares plainly and invents no name", () => x.includes( `href="https://x.com/intent/post?text=${encodeURIComponent( [ - "Just deployed with Sealos.", + "Just deployed with Sealos. @Sealos_io", "From idea to live app.", - `Try it here: ${url} @Sealos_io`, + "", + `Try it here: ${url}`, + "", "#Sealos #BuildInPublic", ].join("\n") )}"` From 9ace42fd1b150e0e87cbcb5c695a97f304355476 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 8 Sep 2026 17:41:54 +0800 Subject: [PATCH 7/9] fix(deploy): snapshot template categories on every create path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The share voices read `productCategories` off the success record, but only the two in-project panes sent `templateCategories` with the create request. New-project template deploys and the chat `createDeployTask` tool — the first-success paths the share strip exists for — dropped the field, so every template but EaglerCraft fell through to the generic post. Both new-project template paths now build their request through `newProjectTemplateRequest`, which carries the chosen catalog item's categories the way the panes do. The chat tool resolves categories from the catalog by template name at create time rather than trusting the model to copy them; an unknown name or an unreachable catalog leaves the source as declared and never blocks the deploy. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01L75qX7NHHC165cykRCyMGG --- .../chat/tool/chat-deploy-task-tool.test.ts | 87 +++++++++++++++++++ .../chat/tool/chat-deploy-task-tool.ts | 38 +++++++- .../creation/use-project-creator.test.ts | 38 +++++++- .../projects/creation/use-project-creator.ts | 57 ++++++++---- 4 files changed, 201 insertions(+), 19 deletions(-) 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/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") { From ddb89842736043579e5c7cfe5fb1deada2c33164 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 8 Sep 2026 17:41:55 +0800 Subject: [PATCH 8/9] style(deploy): draw share channels the house way and name the QR dialog Channel links compose `AppIconButton` as the rest of the app does: an empty anchor as the render host, the icon as children. The pane test now captures the whole `` element and asserts the icon sits inside it. The QR popover's "Open on your phone" line becomes its `PopoverTitle`, so the dialog Base UI announces carries a name. The new 11px surfaces use the registered `text-2xs` token instead of an arbitrary size. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01L75qX7NHHC165cykRCyMGG --- .../deployment-task-success-section.tsx | 6 ++--- .../deployment-task-success-share.test.tsx | 9 ++++++- .../deploy/deployment-task-success-share.tsx | 24 ++++++++++--------- .../deployment-task-timeline-pane.test.tsx | 10 ++++++-- 4 files changed, 32 insertions(+), 17 deletions(-) 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 be0281a1..11169926 100644 --- a/apps/ui/src/features/deploy/deployment-task-success-section.tsx +++ b/apps/ui/src/features/deploy/deployment-task-success-section.tsx @@ -149,7 +149,7 @@ function SecondaryEntryRow({
{headed && entry.label != null ? ( {entry.label} @@ -201,7 +201,7 @@ function NextStepsTrail({ steps }: { steps: DeploymentTaskSuccessStep[] }) { )} {index + 1} @@ -210,7 +210,7 @@ function NextStepsTrail({ steps }: { steps: DeploymentTaskSuccessStep[] }) { {step.label} {step.detail == null ? null : ( - + {step.detail} )} 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 index 550f3aed..00b2d29c 100644 --- a/apps/ui/src/features/deploy/deployment-task-success-share.test.tsx +++ b/apps/ui/src/features/deploy/deployment-task-success-share.test.tsx @@ -1,5 +1,6 @@ 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, @@ -17,6 +18,8 @@ const PANEL_HOST_RE = />meetinghub\.sealos\.run]*data-slot="popover-title"[^>]*>Open on your phone { 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); diff --git a/apps/ui/src/features/deploy/deployment-task-success-share.tsx b/apps/ui/src/features/deploy/deployment-task-success-share.tsx index d865dbb2..3a2a1c25 100644 --- a/apps/ui/src/features/deploy/deployment-task-success-share.tsx +++ b/apps/ui/src/features/deploy/deployment-task-success-share.tsx @@ -4,6 +4,7 @@ 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"; @@ -273,7 +274,11 @@ function ScanFrame({ children }: { children: ReactNode }) { const QR_SIZE = 128; -/** The popover's body: the framed QR, what a scan opens, and the host it opens. */ +/** + * 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 ( <> @@ -281,11 +286,11 @@ export function DeploymentTaskSuccessQrPanel({ url }: { url: string }) {
- + Open on your phone - + {shareHost(url)} @@ -344,33 +349,30 @@ export function DeploymentTaskSuccessShareStrip({ className={cn("flex items-center justify-between pl-2", className)} data-slot="deployment-task-success-share" > - + {productName == null ? "Share" : `Share ${productName}`}
{DEPLOYMENT_TASK_SUCCESS_SHARE_CHANNELS.map( ({ Icon, href, id, label }) => ( - // The icon sits inside the anchor so the link carries its own - // content; the wrapper's children slot stays empty on purpose. - - + /> } size="sm" title={label} variant="quiet" > - {null} + ) )} 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 2a8039aa..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 @@ -1122,6 +1122,7 @@ 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 = />Share` tag carrying `label`, or null when the strip does not render it. */ +/** 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}")[^>]*>`) + new RegExp(`]*aria-label="${label}")[^>]*>[\\s\\S]*?`) ); return match?.[0] ?? null; } @@ -1219,6 +1220,11 @@ test("each share channel posts the snapshotted address in a new tab without a re `${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 }) => From 55f3ad90d41ac8341ffa30ec17d339e9f1a20586 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 8 Sep 2026 17:41:55 +0800 Subject: [PATCH 9/9] fix(deploy): fold success text onto one line and pin the category cap `successText` promised a single line but only trimmed, so an interior newline in a product name would have broken the X post's beats. It now folds whitespace runs to one space. The sanitizer test asserts the category cap exactly (16) instead of merely "fewer than 40". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01L75qX7NHHC165cykRCyMGG --- apps/ui/src/features/deploy/task/timeline.test.ts | 15 ++++++++++++++- apps/ui/src/features/deploy/task/timeline.ts | 8 ++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/ui/src/features/deploy/task/timeline.test.ts b/apps/ui/src/features/deploy/task/timeline.test.ts index 25f74dd7..d4504cc9 100644 --- a/apps/ui/src/features/deploy/task/timeline.test.ts +++ b/apps/ui/src/features/deploy/task/timeline.test.ts @@ -1177,7 +1177,20 @@ test("the sanitizer keeps product categories as a short list of trimmed names", }, fallback ); - assert.ok((many?.productCategories?.length ?? 0) < 40); + 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", () => { diff --git a/apps/ui/src/features/deploy/task/timeline.ts b/apps/ui/src/features/deploy/task/timeline.ts index ed745a5c..5c79ab7d 100644 --- a/apps/ui/src/features/deploy/task/timeline.ts +++ b/apps/ui/src/features/deploy/task/timeline.ts @@ -665,13 +665,17 @@ 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; }