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) => (
+
+ ))}
+
+ );
+}
+
/**
* 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 (
);
}
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;
+const SVG_RE = /
+
)}
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;
const SVG_RE = / {
"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 = />Share;
const PRIMARY_ACTION_SLOT =
'data-slot="deployment-task-success-primary-action"';
const SUCCESS_SLOT_ALL_RE = /data-slot="deployment-task-success"/g;
-const JUST_LAUNCHED_RE = /Just launched/;
const ADD_SERVER_STEP_RE = /Add the server in Multiplayer\./;
const TRAIL_LIST_RE = /1<\/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({