mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
feat(app,share): deep-link bundle shares into new-worker imports (#664)
* docs(share): add free-first and org hub sharing redesign flows * feat(app,share): open bundle links into new-worker imports
This commit is contained in:
BIN
evidence/share-flow-redesign/01-free-default-share.png
Normal file
BIN
evidence/share-flow-redesign/01-free-default-share.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
evidence/share-flow-redesign/02-open-in-app-new-worker.png
Normal file
BIN
evidence/share-flow-redesign/02-open-in-app-new-worker.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
evidence/share-flow-redesign/03-org-library-overlay.png
Normal file
BIN
evidence/share-flow-redesign/03-org-library-overlay.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
BIN
evidence/share-flow-redesign/04-remix-and-versioning.png
Normal file
BIN
evidence/share-flow-redesign/04-remix-and-versioning.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
96
evidence/share-flow-redesign/overview.html
Normal file
96
evidence/share-flow-redesign/overview.html
Normal file
@@ -0,0 +1,96 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OpenWork Share Flow Redesign</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: only light;
|
||||
--bg: #f7f5f0;
|
||||
--card: #ffffff;
|
||||
--line: #d7d1c5;
|
||||
--ink: #1f1b16;
|
||||
--muted: #6b6153;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background: radial-gradient(circle at 5% -5%, #efe9dc 0, transparent 30%), var(--bg);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>OpenWork Share Flow Redesign</h1>
|
||||
<p>
|
||||
Free/default sharing remains first-class. Organization is an optional hub for indexing and governed discovery of the same links.
|
||||
</p>
|
||||
<section class="grid">
|
||||
<article class="card">
|
||||
<h2>1) Free default share</h2>
|
||||
<img src="01-free-default-share.png" alt="Free default share flow" />
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>2) Open in app creates new worker</h2>
|
||||
<img src="02-open-in-app-new-worker.png" alt="Open in app new worker flow" />
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>3) Org hub overlays the same links</h2>
|
||||
<img src="03-org-library-overlay.png" alt="Organization library overlay flow" />
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>4) Remix and versioning loop</h2>
|
||||
<img src="04-remix-and-versioning.png" alt="Remix and versioning flow" />
|
||||
</article>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
BIN
evidence/share-flow-redesign/share-flow-overview.mp4
Normal file
BIN
evidence/share-flow-redesign/share-flow-overview.mp4
Normal file
Binary file not shown.
BIN
evidence/share-flow-redesign/share-flow-overview.png
Normal file
BIN
evidence/share-flow-redesign/share-flow-overview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 445 KiB |
BIN
evidence/share-flow-redesign/share-flow-walkthrough.mp4
Normal file
BIN
evidence/share-flow-redesign/share-flow-walkthrough.mp4
Normal file
Binary file not shown.
BIN
evidence/share-service-open-in-app-new-worker-local.png
Normal file
BIN
evidence/share-service-open-in-app-new-worker-local.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 237 KiB |
@@ -208,10 +208,24 @@ type SharedBundleV1 =
|
||||
| SharedSkillsSetBundleV1
|
||||
| SharedWorkspaceProfileBundleV1;
|
||||
|
||||
type SharedBundleImportIntent = "new_worker" | "import_current";
|
||||
|
||||
type SharedBundleDeepLink = {
|
||||
bundleUrl: string;
|
||||
intent: SharedBundleImportIntent;
|
||||
source?: string;
|
||||
orgId?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
function normalizeSharedBundleImportIntent(value: string | null | undefined): SharedBundleImportIntent {
|
||||
const normalized = (value ?? "").trim().toLowerCase();
|
||||
if (normalized === "new_worker" || normalized === "new-worker" || normalized === "newworker") {
|
||||
return "new_worker";
|
||||
}
|
||||
return "import_current";
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
@@ -419,7 +433,17 @@ function parseSharedBundleDeepLink(rawUrl: string): SharedBundleDeepLink | null
|
||||
if (parsedBundleUrl.protocol !== "https:" && parsedBundleUrl.protocol !== "http:") {
|
||||
return null;
|
||||
}
|
||||
return { bundleUrl: parsedBundleUrl.toString() };
|
||||
const intent = normalizeSharedBundleImportIntent(url.searchParams.get("ow_intent") ?? url.searchParams.get("intent"));
|
||||
const source = url.searchParams.get("ow_source")?.trim() ?? url.searchParams.get("source")?.trim() ?? "";
|
||||
const orgId = url.searchParams.get("ow_org")?.trim() ?? "";
|
||||
const label = url.searchParams.get("ow_label")?.trim() ?? "";
|
||||
return {
|
||||
bundleUrl: parsedBundleUrl.toString(),
|
||||
intent,
|
||||
source: source || undefined,
|
||||
orgId: orgId || undefined,
|
||||
label: label || undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -434,7 +458,7 @@ function stripSharedBundleQuery(rawUrl: string): string | null {
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
for (const key of ["ow_bundle", "bundleUrl", "source"]) {
|
||||
for (const key of ["ow_bundle", "bundleUrl", "ow_intent", "intent", "ow_source", "source", "ow_org", "ow_label"]) {
|
||||
if (url.searchParams.has(key)) {
|
||||
url.searchParams.delete(key);
|
||||
changed = true;
|
||||
@@ -726,7 +750,13 @@ export default function App() {
|
||||
}
|
||||
|
||||
if (bundleInvite?.bundleUrl) {
|
||||
setPendingSharedBundleUrl(bundleInvite.bundleUrl);
|
||||
setPendingSharedBundleInvite({
|
||||
bundleUrl: bundleInvite.bundleUrl,
|
||||
intent: normalizeSharedBundleImportIntent(bundleInvite.intent),
|
||||
source: bundleInvite.source,
|
||||
orgId: bundleInvite.orgId,
|
||||
label: bundleInvite.label,
|
||||
});
|
||||
setSharedBundleNoticeShown(false);
|
||||
}
|
||||
|
||||
@@ -2689,21 +2719,84 @@ export default function App() {
|
||||
setOpenworkServerWorkspaceId(null);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const bundleUrl = pendingSharedBundleUrl();
|
||||
if (!bundleUrl || booting()) {
|
||||
return;
|
||||
const resolveSharedBundleWorkerTarget = () => {
|
||||
const pref = startupPreference();
|
||||
const hostInfo = openworkServerHostInfo();
|
||||
const settings = openworkServerSettings();
|
||||
|
||||
const localHostUrl = normalizeOpenworkServerUrl(hostInfo?.baseUrl ?? "") ?? "";
|
||||
const localToken = hostInfo?.clientToken?.trim() ?? "";
|
||||
const serverHostUrl = normalizeOpenworkServerUrl(settings.urlOverride ?? "") ?? "";
|
||||
const serverToken = settings.token?.trim() ?? "";
|
||||
|
||||
if (pref === "server") {
|
||||
return {
|
||||
hostUrl: serverHostUrl || localHostUrl,
|
||||
token: serverToken || localToken,
|
||||
};
|
||||
}
|
||||
|
||||
const client = openworkServerClient();
|
||||
const workspaceId = openworkServerWorkspaceId();
|
||||
const connected = openworkServerStatus() === "connected";
|
||||
if (pref === "local") {
|
||||
return {
|
||||
hostUrl: localHostUrl || serverHostUrl,
|
||||
token: localToken || serverToken,
|
||||
};
|
||||
}
|
||||
|
||||
if (!client || !workspaceId || !connected) {
|
||||
if (!sharedBundleNoticeShown()) {
|
||||
setSharedBundleNoticeShown(true);
|
||||
setError("Share link detected. Connect to a writable OpenWork worker to import this bundle.");
|
||||
if (localHostUrl) {
|
||||
return {
|
||||
hostUrl: localHostUrl,
|
||||
token: localToken || serverToken,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hostUrl: serverHostUrl,
|
||||
token: serverToken || localToken,
|
||||
};
|
||||
};
|
||||
|
||||
const waitForSharedBundleImportTarget = async (timeoutMs = 20_000) => {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const client = openworkServerClient();
|
||||
const workspaceId = openworkServerWorkspaceId();
|
||||
if (client && workspaceId && openworkServerStatus() === "connected") {
|
||||
return { client, workspaceId };
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, 200);
|
||||
});
|
||||
}
|
||||
throw new Error("OpenWork worker is not ready yet.");
|
||||
};
|
||||
|
||||
const createWorkerForSharedBundle = async (request: SharedBundleDeepLink, bundle: SharedBundleV1) => {
|
||||
const target = resolveSharedBundleWorkerTarget();
|
||||
const hostUrl = target.hostUrl.trim();
|
||||
const token = target.token.trim();
|
||||
if (!hostUrl || !token) {
|
||||
throw new Error("Share link detected. Configure an OpenWork worker host and token, then open the link again.");
|
||||
}
|
||||
|
||||
const label = (request.label?.trim() || bundle.name?.trim() || "Shared setup").slice(0, 80);
|
||||
const ok = await workspaceStore.createRemoteWorkspaceFlow({
|
||||
openworkHostUrl: hostUrl,
|
||||
openworkToken: token,
|
||||
directory: null,
|
||||
displayName: label,
|
||||
manageBusy: false,
|
||||
closeModal: false,
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
throw new Error("Failed to create a worker from this share link.");
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const request = pendingSharedBundleInvite();
|
||||
if (!request || booting()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2711,12 +2804,44 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.intent === "import_current") {
|
||||
const client = openworkServerClient();
|
||||
const workspaceId = openworkServerWorkspaceId();
|
||||
const connected = openworkServerStatus() === "connected";
|
||||
if (!client || !workspaceId || !connected) {
|
||||
if (!sharedBundleNoticeShown()) {
|
||||
setSharedBundleNoticeShown(true);
|
||||
setError("Share link detected. Connect to a writable OpenWork worker to import this bundle.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const target = resolveSharedBundleWorkerTarget();
|
||||
if (!target.hostUrl.trim() || !target.token.trim()) {
|
||||
if (!sharedBundleNoticeShown()) {
|
||||
setSharedBundleNoticeShown(true);
|
||||
setError("Share link detected. Configure an OpenWork host and token to create a new worker.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setSharedBundleImportBusy(true);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const bundle = await fetchSharedBundle(bundleUrl);
|
||||
const bundle = await fetchSharedBundle(request.bundleUrl);
|
||||
if (cancelled) return;
|
||||
|
||||
if (request.intent === "new_worker") {
|
||||
await createWorkerForSharedBundle(request, bundle);
|
||||
if (cancelled) return;
|
||||
}
|
||||
|
||||
const { client, workspaceId } = await waitForSharedBundleImportTarget();
|
||||
if (cancelled) return;
|
||||
|
||||
const { payload, importedSkillsCount } = buildImportPayloadFromBundle(bundle);
|
||||
await client.importWorkspace(workspaceId, payload);
|
||||
await refreshSkills({ force: true });
|
||||
@@ -2733,7 +2858,7 @@ export default function App() {
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setSharedBundleImportBusy(false);
|
||||
setPendingSharedBundleUrl(null);
|
||||
setPendingSharedBundleInvite(null);
|
||||
setSharedBundleNoticeShown(false);
|
||||
}
|
||||
}
|
||||
@@ -2882,7 +3007,7 @@ export default function App() {
|
||||
const [editRemoteWorkspaceError, setEditRemoteWorkspaceError] = createSignal<string | null>(null);
|
||||
const [deepLinkRemoteWorkspaceDefaults, setDeepLinkRemoteWorkspaceDefaults] = createSignal<RemoteWorkspaceDefaults | null>(null);
|
||||
const [pendingRemoteConnectDeepLink, setPendingRemoteConnectDeepLink] = createSignal<RemoteWorkspaceDefaults | null>(null);
|
||||
const [pendingSharedBundleUrl, setPendingSharedBundleUrl] = createSignal<string | null>(null);
|
||||
const [pendingSharedBundleInvite, setPendingSharedBundleInvite] = createSignal<SharedBundleDeepLink | null>(null);
|
||||
const [sharedBundleImportBusy, setSharedBundleImportBusy] = createSignal(false);
|
||||
const [sharedBundleNoticeShown, setSharedBundleNoticeShown] = createSignal(false);
|
||||
const [renameWorkspaceOpen, setRenameWorkspaceOpen] = createSignal(false);
|
||||
@@ -2904,7 +3029,7 @@ export default function App() {
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
setPendingSharedBundleUrl(parsed.bundleUrl);
|
||||
setPendingSharedBundleInvite(parsed);
|
||||
setSharedBundleNoticeShown(false);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -510,6 +510,10 @@ const OPENWORK_INVITE_PARAM_URL = "ow_url";
|
||||
const OPENWORK_INVITE_PARAM_TOKEN = "ow_token";
|
||||
const OPENWORK_INVITE_PARAM_STARTUP = "ow_startup";
|
||||
const OPENWORK_INVITE_PARAM_BUNDLE = "ow_bundle";
|
||||
const OPENWORK_INVITE_PARAM_BUNDLE_INTENT = "ow_intent";
|
||||
const OPENWORK_INVITE_PARAM_BUNDLE_SOURCE = "ow_source";
|
||||
const OPENWORK_INVITE_PARAM_BUNDLE_ORG = "ow_org";
|
||||
const OPENWORK_INVITE_PARAM_BUNDLE_LABEL = "ow_label";
|
||||
|
||||
export type OpenworkConnectInvite = {
|
||||
url: string;
|
||||
@@ -517,10 +521,24 @@ export type OpenworkConnectInvite = {
|
||||
startup?: "server";
|
||||
};
|
||||
|
||||
export type OpenworkBundleInviteIntent = "new_worker" | "import_current";
|
||||
|
||||
export type OpenworkBundleInvite = {
|
||||
bundleUrl: string;
|
||||
intent: OpenworkBundleInviteIntent;
|
||||
source?: string;
|
||||
orgId?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
function normalizeOpenworkBundleInviteIntent(value: string | null | undefined): OpenworkBundleInviteIntent {
|
||||
const normalized = (value ?? "").trim().toLowerCase();
|
||||
if (normalized === "new_worker" || normalized === "new-worker" || normalized === "newworker") {
|
||||
return "new_worker";
|
||||
}
|
||||
return "import_current";
|
||||
}
|
||||
|
||||
export function buildOpenworkConnectInviteUrl(input: {
|
||||
workspaceUrl: string;
|
||||
token?: string | null;
|
||||
@@ -583,6 +601,10 @@ export function readOpenworkConnectInviteFromSearch(input: string | URLSearchPar
|
||||
export function buildOpenworkBundleInviteUrl(input: {
|
||||
bundleUrl: string;
|
||||
appUrl?: string | null;
|
||||
intent?: OpenworkBundleInviteIntent;
|
||||
source?: string | null;
|
||||
orgId?: string | null;
|
||||
label?: string | null;
|
||||
}) {
|
||||
const rawBundleUrl = input.bundleUrl?.trim() ?? "";
|
||||
if (!rawBundleUrl) return "";
|
||||
@@ -599,12 +621,48 @@ export function buildOpenworkBundleInviteUrl(input: {
|
||||
try {
|
||||
const url = new URL(base);
|
||||
const search = new URLSearchParams(url.search);
|
||||
const intent = normalizeOpenworkBundleInviteIntent(input.intent);
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE, bundleUrl);
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE_INTENT, intent);
|
||||
|
||||
const source = input.source?.trim() ?? "";
|
||||
if (source) {
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE_SOURCE, source);
|
||||
}
|
||||
|
||||
const orgId = input.orgId?.trim() ?? "";
|
||||
if (orgId) {
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE_ORG, orgId);
|
||||
}
|
||||
|
||||
const label = input.label?.trim() ?? "";
|
||||
if (label) {
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE_LABEL, label);
|
||||
}
|
||||
|
||||
url.search = search.toString();
|
||||
return url.toString();
|
||||
} catch {
|
||||
const search = new URLSearchParams();
|
||||
const intent = normalizeOpenworkBundleInviteIntent(input.intent);
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE, bundleUrl);
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE_INTENT, intent);
|
||||
|
||||
const source = input.source?.trim() ?? "";
|
||||
if (source) {
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE_SOURCE, source);
|
||||
}
|
||||
|
||||
const orgId = input.orgId?.trim() ?? "";
|
||||
if (orgId) {
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE_ORG, orgId);
|
||||
}
|
||||
|
||||
const label = input.label?.trim() ?? "";
|
||||
if (label) {
|
||||
search.set(OPENWORK_INVITE_PARAM_BUNDLE_LABEL, label);
|
||||
}
|
||||
|
||||
return `${DEFAULT_OPENWORK_CONNECT_APP_URL}?${search.toString()}`;
|
||||
}
|
||||
}
|
||||
@@ -629,8 +687,17 @@ export function readOpenworkBundleInviteFromSearch(input: string | URLSearchPara
|
||||
return null;
|
||||
}
|
||||
|
||||
const intent = normalizeOpenworkBundleInviteIntent(search.get(OPENWORK_INVITE_PARAM_BUNDLE_INTENT));
|
||||
const source = search.get(OPENWORK_INVITE_PARAM_BUNDLE_SOURCE)?.trim() ?? "";
|
||||
const orgId = search.get(OPENWORK_INVITE_PARAM_BUNDLE_ORG)?.trim() ?? "";
|
||||
const label = search.get(OPENWORK_INVITE_PARAM_BUNDLE_LABEL)?.trim() ?? "";
|
||||
|
||||
return {
|
||||
bundleUrl,
|
||||
intent,
|
||||
source: source || undefined,
|
||||
orgId: orgId || undefined,
|
||||
label: label || undefined,
|
||||
} satisfies OpenworkBundleInvite;
|
||||
}
|
||||
|
||||
@@ -650,6 +717,10 @@ export function stripOpenworkBundleInviteFromUrl(input: string) {
|
||||
try {
|
||||
const url = new URL(input);
|
||||
url.searchParams.delete(OPENWORK_INVITE_PARAM_BUNDLE);
|
||||
url.searchParams.delete(OPENWORK_INVITE_PARAM_BUNDLE_INTENT);
|
||||
url.searchParams.delete(OPENWORK_INVITE_PARAM_BUNDLE_SOURCE);
|
||||
url.searchParams.delete(OPENWORK_INVITE_PARAM_BUNDLE_ORG);
|
||||
url.searchParams.delete(OPENWORK_INVITE_PARAM_BUNDLE_LABEL);
|
||||
return url.toString();
|
||||
} catch {
|
||||
return input;
|
||||
|
||||
12
pr/diagrams/share-flow-redesign/01-free-default-share.mmd
Normal file
12
pr/diagrams/share-flow-redesign/01-free-default-share.mmd
Normal file
@@ -0,0 +1,12 @@
|
||||
flowchart TD
|
||||
A[OpenWork app\nWorkspace configured] --> B[Share modal]
|
||||
B --> C[Create share link]
|
||||
C --> D[Copy link and send]
|
||||
D --> E[Recipient opens URL]
|
||||
E --> F[Share service page]
|
||||
F --> G[Open in OpenWork]
|
||||
G --> H[Deep link with\now_intent=new_worker]
|
||||
H --> I[OpenWork app intercepts]
|
||||
I --> J[Create new worker prompt]
|
||||
J --> K[Provision worker + import bundle]
|
||||
K --> L[Open new session]
|
||||
@@ -0,0 +1,13 @@
|
||||
flowchart TD
|
||||
A[Share service page loaded] --> B{Open in OpenWork clicked}
|
||||
B --> C[Build deep link:\now_bundle + ow_intent=new_worker]
|
||||
C --> D[OS hands URL to OpenWork]
|
||||
D --> E[Parse bundle + intent]
|
||||
E --> F[Wizard: New worker from shared setup]
|
||||
F --> G{Worker target}
|
||||
G -->|Local host| H[Create local worker entry]
|
||||
G -->|Remote host| I[Create remote worker entry]
|
||||
H --> J[Import bundle]
|
||||
I --> J
|
||||
J --> K[Set new worker active]
|
||||
K --> L[Open chat/session]
|
||||
12
pr/diagrams/share-flow-redesign/03-org-library-overlay.mmd
Normal file
12
pr/diagrams/share-flow-redesign/03-org-library-overlay.mmd
Normal file
@@ -0,0 +1,12 @@
|
||||
flowchart TD
|
||||
A[OpenWork app share modal] --> B{Add to organization library}
|
||||
B -->|No| C[Direct link sharing only\nfree/default]
|
||||
B -->|Yes| D[Publish metadata + tags\nto org hub]
|
||||
D --> E[Org hub card points to same bundle]
|
||||
E --> F[Teammate opens org hub]
|
||||
F --> G[Open card details]
|
||||
G --> H[Open in OpenWork]
|
||||
H --> I[Deep link with org context\now_source=org_hub]
|
||||
I --> J[OpenWork creates new worker\nand imports bundle]
|
||||
C --> K[Recipient can still open\nshare URL directly]
|
||||
K --> J
|
||||
10
pr/diagrams/share-flow-redesign/04-remix-and-versioning.mmd
Normal file
10
pr/diagrams/share-flow-redesign/04-remix-and-versioning.mmd
Normal file
@@ -0,0 +1,10 @@
|
||||
flowchart TD
|
||||
A[New worker created from shared bundle] --> B[User edits skills/config/commands]
|
||||
B --> C{Share changes}
|
||||
C -->|Fork as new setup| D[Publish new bundle ID]
|
||||
C -->|Update org template| E[Publish next version\nsame template identity]
|
||||
D --> F[New standalone share link]
|
||||
E --> G[Org card now resolves\nto latest approved version]
|
||||
F --> H[Others open as new worker]
|
||||
G --> H
|
||||
H --> I[More remixes and team reuse]
|
||||
79
pr/share-flow-redesign.md
Normal file
79
pr/share-flow-redesign.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Seamless Share Flow Redesign (Free + Org)
|
||||
|
||||
## Product direction
|
||||
|
||||
- Free/default sharing remains the baseline experience.
|
||||
- Organization is an optional index and permission layer on top of the exact same share links.
|
||||
- One bundle format works everywhere (direct link, share service page, org hub card).
|
||||
|
||||
## Core UX rules
|
||||
|
||||
1. Every shared link must be usable directly, even without org membership.
|
||||
2. `Open in OpenWork` from the share service should default to creating a new worker.
|
||||
3. Importing into the current worker remains available as a secondary action.
|
||||
4. Organization does not replace sharing, it organizes and governs the same assets.
|
||||
|
||||
## Deep-link contract
|
||||
|
||||
Proposed URL pattern used by share page and org hub:
|
||||
|
||||
```
|
||||
openwork://import-bundle?ow_bundle=<encoded-share-url>&ow_intent=new_worker&ow_source=<share_service|org_hub>&ow_org=<optional-org-id>&ow_label=<optional-template-name>
|
||||
```
|
||||
|
||||
Supported intents:
|
||||
|
||||
- `ow_intent=new_worker` (default): create a fresh worker and import bundle into it.
|
||||
- `ow_intent=import_current`: import bundle into currently active worker.
|
||||
|
||||
## UI wiring
|
||||
|
||||
### OpenWork app: Share modal
|
||||
|
||||
- Primary action: `Create share link`
|
||||
- Visibility chip:
|
||||
- `Anyone with link` (default, free-friendly)
|
||||
- `Add to organization` (optional)
|
||||
- Destination controls:
|
||||
- `Open in OpenWork -> New worker` (default recipient behavior)
|
||||
- `Import into current worker` (secondary)
|
||||
|
||||
### Share service page
|
||||
|
||||
- Primary CTA: `Open in OpenWork (new worker)`
|
||||
- Secondary CTA: `Import into current worker`
|
||||
- Optional CTA: `Add to organization library` (visible only for signed-in org users)
|
||||
|
||||
### Organization hub (web app)
|
||||
|
||||
- Library is a central index of the same share artifacts:
|
||||
- Search by team, tags, owner, recency.
|
||||
- Open card -> same share detail model + Open in OpenWork deep link.
|
||||
- Org ACL controls discovery and publishing, not bundle compatibility.
|
||||
|
||||
## Flowchart set
|
||||
|
||||
- `pr/diagrams/share-flow-redesign/01-free-default-share.mmd`
|
||||
- `pr/diagrams/share-flow-redesign/02-open-in-app-new-worker.mmd`
|
||||
- `pr/diagrams/share-flow-redesign/03-org-library-overlay.mmd`
|
||||
- `pr/diagrams/share-flow-redesign/04-remix-and-versioning.mmd`
|
||||
|
||||
Rendered PNGs:
|
||||
|
||||
- `evidence/share-flow-redesign/01-free-default-share.png`
|
||||
- `evidence/share-flow-redesign/02-open-in-app-new-worker.png`
|
||||
- `evidence/share-flow-redesign/03-org-library-overlay.png`
|
||||
- `evidence/share-flow-redesign/04-remix-and-versioning.png`
|
||||
- `evidence/share-flow-redesign/share-flow-overview.png` (single sheet with all four flows)
|
||||
|
||||
Video walkthrough:
|
||||
|
||||
- `evidence/share-flow-redesign/share-flow-walkthrough.mp4`
|
||||
|
||||
## Acceptance checklist
|
||||
|
||||
- Direct link share works with no org context.
|
||||
- Share page opens deep link with `ow_intent=new_worker` by default.
|
||||
- OpenWork app deep-link handler can branch by intent.
|
||||
- New worker provisioning path reuses existing remote/local worker creation flow.
|
||||
- Org hub card launch and direct share URL launch land in equivalent app import UX.
|
||||
@@ -13,7 +13,11 @@ It is designed to be deployed on Vercel and backed by Vercel Blob.
|
||||
|
||||
- `GET /b/:id`
|
||||
- Returns an HTML share page by default for browser requests.
|
||||
- Includes an **Open in app** action that sends users to OpenWork app with `ow_bundle` query param.
|
||||
- Includes an **Open in app** action that opens `openwork://import-bundle` with:
|
||||
- `ow_bundle=<share-url>`
|
||||
- `ow_intent=new_worker` (default import target)
|
||||
- `ow_source=share_service`
|
||||
- Also includes a web fallback action that opens `PUBLIC_OPENWORK_APP_URL` with the same query params.
|
||||
- Returns raw JSON for API/programmatic requests:
|
||||
- send `Accept: application/json`, or
|
||||
- append `?format=json`.
|
||||
|
||||
@@ -54,14 +54,34 @@ function normalizeAppUrl(input) {
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function buildOpenInAppUrl(shareUrl) {
|
||||
function buildOpenInAppUrls(shareUrl, options = {}) {
|
||||
const query = new URLSearchParams();
|
||||
query.set("ow_bundle", shareUrl);
|
||||
query.set("ow_intent", "new_worker");
|
||||
query.set("ow_source", "share_service");
|
||||
|
||||
const label = String(options.label ?? "").trim();
|
||||
if (label) {
|
||||
query.set("ow_label", label.slice(0, 120));
|
||||
}
|
||||
|
||||
const openInAppDeepLink = `openwork://import-bundle?${query.toString()}`;
|
||||
|
||||
const appUrl = normalizeAppUrl(OPENWORK_APP_URL) || "https://app.openwork.software";
|
||||
try {
|
||||
const url = new URL(appUrl);
|
||||
url.searchParams.set("ow_bundle", shareUrl);
|
||||
return url.toString();
|
||||
for (const [key, value] of query.entries()) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return {
|
||||
openInAppDeepLink,
|
||||
openInWebAppUrl: url.toString(),
|
||||
};
|
||||
} catch {
|
||||
return `${"https://app.openwork.software"}?ow_bundle=${encodeURIComponent(shareUrl)}`;
|
||||
return {
|
||||
openInAppDeepLink,
|
||||
openInWebAppUrl: `${"https://app.openwork.software"}?${query.toString()}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +186,9 @@ export function wantsDownload(req) {
|
||||
export function renderBundlePage({ id, rawJson, req }) {
|
||||
const bundle = parseBundle(rawJson);
|
||||
const urls = buildBundleUrls(req, id);
|
||||
const openInAppUrl = buildOpenInAppUrl(urls.shareUrl);
|
||||
const { openInAppDeepLink, openInWebAppUrl } = buildOpenInAppUrls(urls.shareUrl, {
|
||||
label: bundle.name || "Shared setup",
|
||||
});
|
||||
const prettyBundleJson = prettyJson(rawJson);
|
||||
const schemaVersion = bundle.schemaVersion == null ? "unknown" : String(bundle.schemaVersion);
|
||||
const typeLabel = humanizeType(bundle.type);
|
||||
@@ -180,11 +202,11 @@ export function renderBundlePage({ id, rawJson, req }) {
|
||||
const skillsSetCount = listCount(bundle.skills);
|
||||
const installHint =
|
||||
bundle.type === "skill"
|
||||
? "Open OpenWork and import from this link to install this skill."
|
||||
? "Use Open in app to create a new worker and install this skill."
|
||||
: bundle.type === "skills-set"
|
||||
? "Use Open in app to import this full skills set in one step."
|
||||
? "Use Open in app to create a new worker and import this full skills set."
|
||||
: bundle.type === "workspace-profile"
|
||||
? "Use Open in app to import this full workspace profile (config, MCP, commands, and skills)."
|
||||
? "Use Open in app to create a new worker from this full workspace profile (config, MCP, commands, and skills)."
|
||||
: "Use the JSON endpoint if you want to import this bundle programmatically.";
|
||||
const contentLabel = bundle.type === "skill" && bundle.content.trim() ? "Skill content" : "Bundle payload";
|
||||
const contentPreview =
|
||||
@@ -211,7 +233,7 @@ export function renderBundlePage({ id, rawJson, req }) {
|
||||
<meta name="openwork:bundle-id" content="${escapeHtml(id)}" />
|
||||
<meta name="openwork:bundle-type" content="${escapeHtml(bundle.type || "unknown")}" />
|
||||
<meta name="openwork:schema-version" content="${escapeHtml(schemaVersion)}" />
|
||||
<meta name="openwork:open-in-app-url" content="${escapeHtml(openInAppUrl)}" />
|
||||
<meta name="openwork:open-in-app-url" content="${escapeHtml(openInAppDeepLink)}" />
|
||||
<link rel="alternate" type="application/json" href="${escapeHtml(urls.jsonUrl)}" />
|
||||
<style>
|
||||
:root {
|
||||
@@ -449,7 +471,8 @@ export function renderBundlePage({ id, rawJson, req }) {
|
||||
<h1>${escapeHtml(title)}</h1>
|
||||
<p>${escapeHtml(description)}</p>
|
||||
<div class="actions">
|
||||
<a class="action-link primary" href="${escapeHtml(openInAppUrl)}" target="_blank" rel="noreferrer">Open in app</a>
|
||||
<a class="action-link primary" href="${escapeHtml(openInAppDeepLink)}">Open in app (new worker)</a>
|
||||
<a class="action-link secondary" href="${escapeHtml(openInWebAppUrl)}" target="_blank" rel="noreferrer">Open in web app</a>
|
||||
<button type="button" class="secondary" id="copy-link-button">Copy share link</button>
|
||||
<button type="button" class="secondary" id="copy-json-button">Copy bundle JSON</button>
|
||||
<a class="action-link secondary" href="${escapeHtml(urls.jsonUrl)}" target="_blank" rel="noreferrer">View raw JSON</a>
|
||||
|
||||
@@ -59,7 +59,10 @@ test("renderBundlePage includes machine-readable metadata and escaped json scrip
|
||||
assert.match(html, /data-openwork-bundle-type="skill"/);
|
||||
assert.match(html, /meta name="openwork:bundle-id" content="01TEST"/);
|
||||
assert.match(html, /\?format=json/);
|
||||
assert.match(html, /openwork:\/\/import-bundle\?/);
|
||||
assert.match(html, /ow_bundle=https%3A%2F%2Fshare\.openwork\.software%2Fb%2F01TEST/);
|
||||
assert.match(html, /ow_intent=new_worker/);
|
||||
assert.match(html, /ow_source=share_service/);
|
||||
assert.match(html, /id="openwork-bundle-json" type="application\/json"/);
|
||||
assert.match(html, /demo \\u003c\/script\\u003e skill/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user