@agent-native/dispatch 0.8.27 → 0.8.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"thread-link-preview.js","sourceRoot":"","sources":["../../../src/server/lib/thread-link-preview.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ChatThread } from \"@agent-native/core/server\";\nimport { getRequestContext, getThread } from \"@agent-native/core/server\";\n\nexport interface ThreadLinkPreview {\n title: string;\n description: string;\n imageUrl: string | null;\n}\n\nconst IMAGE_URL_KEYS = new Set([\n \"previewUrl\",\n \"thumbnailUrl\",\n \"imageUrl\",\n \"image\",\n \"downloadUrl\",\n]);\n\nconst GENERATION_TOOL_NAMES = new Set([\n \"generate-image\",\n \"generate-image-batch\",\n \"refine-image\",\n \"rerun-generation-run\",\n]);\n\nfunction safeJsonParse(value: string): unknown {\n try {\n return JSON.parse(value);\n } catch {\n return null;\n }\n}\n\nfunction cleanUrlCandidate(value: string): string {\n return value\n .trim()\n .replace(/[),.;\\]}]+$/g, \"\")\n .replace(/^[\"'(<]+/g, \"\");\n}\n\nfunction isAbsoluteHttpUrl(value: string): boolean {\n try {\n const url = new URL(value);\n return url.protocol === \"https:\" || url.protocol === \"http:\";\n } catch {\n return false;\n }\n}\n\nfunction isImageLikeUrl(value: string): boolean {\n try {\n const url = new URL(value);\n return (\n /\\.(?:png|jpe?g|webp|gif|avif)(?:$|[?#])/i.test(url.pathname) ||\n /\\/api\\/assets\\/[^/]+\\/content(?:$|[?#])/i.test(url.pathname)\n );\n } catch {\n return false;\n }\n}\n\nfunction validPreviewImageUrl(value: unknown, key?: string): string | null {\n if (typeof value !== \"string\") return null;\n const candidate = cleanUrlCandidate(value);\n if (!isAbsoluteHttpUrl(candidate)) return null;\n if (key && IMAGE_URL_KEYS.has(key)) return candidate;\n return isImageLikeUrl(candidate) ? candidate : null;\n}\n\nfunction imageUrlFromStructuredValue(value: unknown): string | null {\n if (!value || typeof value !== \"object\") return null;\n if (Array.isArray(value)) {\n for (let i = value.length - 1; i >= 0; i--) {\n const found = imageUrlFromStructuredValue(value[i]);\n if (found) return found;\n }\n return null;\n }\n\n const record = value as Record<string, unknown>;\n for (const key of IMAGE_URL_KEYS) {\n const found = validPreviewImageUrl(record[key], key);\n if (found) return found;\n }\n for (const [key, child] of Object.entries(record).reverse()) {\n const direct = validPreviewImageUrl(child, key);\n if (direct) return direct;\n if (child && typeof child === \"object\") {\n const nested = imageUrlFromStructuredValue(child);\n if (nested) return nested;\n }\n }\n return null;\n}\n\nfunction imageUrlFromText(value: string): string | null {\n const matches = value.match(/https?:\\/\\/[^\\s<>\"']+/g);\n if (!matches) return null;\n for (let i = matches.length - 1; i >= 0; i--) {\n const candidate = validPreviewImageUrl(matches[i]);\n if (candidate) return candidate;\n }\n return null;\n}\n\nexport function extractThreadPreviewImageUrl(\n threadData: string,\n): string | null {\n const parsed = safeJsonParse(threadData);\n if (!parsed || typeof parsed !== \"object\") return null;\n const messages = (parsed as { messages?: unknown }).messages;\n if (!Array.isArray(messages)) return null;\n\n for (\n let messageIndex = messages.length - 1;\n messageIndex >= 0;\n messageIndex--\n ) {\n const entry = messages[messageIndex] as any;\n const message = entry?.message ?? entry;\n const content = message?.content;\n if (!Array.isArray(content)) continue;\n\n for (let partIndex = content.length - 1; partIndex >= 0; partIndex--) {\n const part = content[partIndex] as Record<string, unknown>;\n const result = typeof part.result === \"string\" ? part.result : \"\";\n if (!result.trim()) continue;\n\n const toolName = typeof part.toolName === \"string\" ? part.toolName : \"\";\n const parsedResult = safeJsonParse(result);\n if (parsedResult && GENERATION_TOOL_NAMES.has(toolName)) {\n const structured = imageUrlFromStructuredValue(parsedResult);\n if (structured) return structured;\n }\n\n const fromText = imageUrlFromText(result);\n if (fromText) return fromText;\n }\n }\n return null;\n}\n\nfunction previewDescription(thread: ChatThread): string {\n const preview = thread.preview.trim();\n if (preview) return preview.slice(0, 180);\n return \"Open this Agent-Native thread in Dispatch.\";\n}\n\nexport async function loadThreadLinkPreview(\n threadId: string | null | undefined,\n): Promise<ThreadLinkPreview | null> {\n const id = threadId?.trim();\n if (!id) return null;\n const viewerEmail = getRequestContext()?.userEmail?.trim();\n if (!viewerEmail) return null;\n const thread = await getThread(id).catch(() => null);\n if (!thread) return null;\n if (thread.ownerEmail !== viewerEmail) return null;\n const title = thread.title.trim() || \"Agent-Native thread\";\n return {\n title,\n description: previewDescription(thread),\n imageUrl: extractThreadPreviewImageUrl(thread.threadData),\n };\n}\n\nexport function buildThreadLinkPreviewMeta(preview: ThreadLinkPreview | null) {\n const title = preview?.title ? `${preview.title} - Dispatch` : \"Dispatch\";\n const description =\n preview?.description ||\n \"Open this Agent-Native thread in the Dispatch workspace.\";\n const image = preview?.imageUrl ?? null;\n return [\n { title },\n { name: \"description\", content: description },\n { property: \"og:title\", content: title },\n { property: \"og:description\", content: description },\n { property: \"og:type\", content: \"website\" },\n ...(image ? [{ property: \"og:image\", content: image }] : []),\n {\n name: \"twitter:card\",\n content: image ? \"summary_large_image\" : \"summary\",\n },\n { name: \"twitter:title\", content: title },\n { name: \"twitter:description\", content: description },\n ...(image ? [{ name: \"twitter:image\", content: image }] : []),\n ];\n}\n"]}
1
+ {"version":3,"file":"thread-link-preview.js","sourceRoot":"","sources":["../../../src/server/lib/thread-link-preview.ts"],"names":[],"mappings":"AAQA,MAAM,cAAc,GAAG,IAAI,GAAG,CAAC;IAC7B,YAAY;IACZ,cAAc;IACd,UAAU;IACV,OAAO;IACP,aAAa;CACd,CAAC,CAAC;AAEH,MAAM,qBAAqB,GAAG,IAAI,GAAG,CAAC;IACpC,gBAAgB;IAChB,sBAAsB;IACtB,cAAc;IACd,sBAAsB;CACvB,CAAC,CAAC;AAEH,SAAS,aAAa,CAAC,KAAa;IAClC,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IAC3B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,SAAS,iBAAiB,CAAC,KAAa;IACtC,OAAO,KAAK;SACT,IAAI,EAAE;SACN,OAAO,CAAC,cAAc,EAAE,EAAE,CAAC;SAC3B,OAAO,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC;AAC9B,CAAC;AAED,SAAS,iBAAiB,CAAC,KAAa;IACtC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC;QAC3B,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO,CAAC;IAC/D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,SAAS,cAAc,CAAC,KAAa;IACnC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC;QAC3B,OAAO,CACL,0CAA0C,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC;YAC7D,0CAA0C,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,CAC9D,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,SAAS,oBAAoB,CAAC,KAAc,EAAE,GAAY;IACxD,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC3C,MAAM,SAAS,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAC;IAC3C,IAAI,CAAC,iBAAiB,CAAC,SAAS,CAAC;QAAE,OAAO,IAAI,CAAC;IAC/C,IAAI,GAAG,IAAI,cAAc,CAAC,GAAG,CAAC,GAAG,CAAC;QAAE,OAAO,SAAS,CAAC;IACrD,OAAO,cAAc,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC;AACtD,CAAC;AAED,SAAS,2BAA2B,CAAC,KAAc;IACjD,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IACrD,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACzB,KAAK,IAAI,CAAC,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;YAC3C,MAAM,KAAK,GAAG,2BAA2B,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;YACpD,IAAI,KAAK;gBAAE,OAAO,KAAK,CAAC;QAC1B,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,MAAM,GAAG,KAAgC,CAAC;IAChD,KAAK,MAAM,GAAG,IAAI,cAAc,EAAE,CAAC;QACjC,MAAM,KAAK,GAAG,oBAAoB,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;QACrD,IAAI,KAAK;YAAE,OAAO,KAAK,CAAC;IAC1B,CAAC;IACD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC;QAC5D,MAAM,MAAM,GAAG,oBAAoB,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QAChD,IAAI,MAAM;YAAE,OAAO,MAAM,CAAC;QAC1B,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YACvC,MAAM,MAAM,GAAG,2BAA2B,CAAC,KAAK,CAAC,CAAC;YAClD,IAAI,MAAM;gBAAE,OAAO,MAAM,CAAC;QAC5B,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,gBAAgB,CAAC,KAAa;IACrC,MAAM,OAAO,GAAG,KAAK,CAAC,KAAK,CAAC,wBAAwB,CAAC,CAAC;IACtD,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAC;IAC1B,KAAK,IAAI,CAAC,GAAG,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QAC7C,MAAM,SAAS,GAAG,oBAAoB,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;QACnD,IAAI,SAAS;YAAE,OAAO,SAAS,CAAC;IAClC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,4BAA4B,CAC1C,UAAkB;IAElB,MAAM,MAAM,GAAG,aAAa,CAAC,UAAU,CAAC,CAAC;IACzC,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IACvD,MAAM,QAAQ,GAAI,MAAiC,CAAC,QAAQ,CAAC;IAC7D,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC;QAAE,OAAO,IAAI,CAAC;IAE1C,KACE,IAAI,YAAY,GAAG,QAAQ,CAAC,MAAM,GAAG,CAAC,EACtC,YAAY,IAAI,CAAC,EACjB,YAAY,EAAE,EACd,CAAC;QACD,MAAM,KAAK,GAAG,QAAQ,CAAC,YAAY,CAAQ,CAAC;QAC5C,MAAM,OAAO,GAAG,KAAK,EAAE,OAAO,IAAI,KAAK,CAAC;QACxC,MAAM,OAAO,GAAG,OAAO,EAAE,OAAO,CAAC;QACjC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;YAAE,SAAS;QAEtC,KAAK,IAAI,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,SAAS,IAAI,CAAC,EAAE,SAAS,EAAE,EAAE,CAAC;YACrE,MAAM,IAAI,GAAG,OAAO,CAAC,SAAS,CAA4B,CAAC;YAC3D,MAAM,MAAM,GAAG,OAAO,IAAI,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC;YAClE,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE;gBAAE,SAAS;YAE7B,MAAM,QAAQ,GAAG,OAAO,IAAI,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;YACxE,MAAM,YAAY,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC;YAC3C,IAAI,YAAY,IAAI,qBAAqB,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC;gBACxD,MAAM,UAAU,GAAG,2BAA2B,CAAC,YAAY,CAAC,CAAC;gBAC7D,IAAI,UAAU;oBAAE,OAAO,UAAU,CAAC;YACpC,CAAC;YAED,MAAM,QAAQ,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAAC;YAC1C,IAAI,QAAQ;gBAAE,OAAO,QAAQ,CAAC;QAChC,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,kBAAkB,CAAC,MAAkB;IAC5C,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;IACtC,IAAI,OAAO;QAAE,OAAO,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IAC1C,OAAO,4CAA4C,CAAC;AACtD,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,QAAmC;IAEnC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IACtC,MAAM,EAAE,GAAG,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC5B,IAAI,CAAC,EAAE;QAAE,OAAO,IAAI,CAAC;IACrB,MAAM,EAAE,iBAAiB,EAAE,SAAS,EAAE,GACpC,MAAM,MAAM,CAAC,2BAA2B,CAAC,CAAC;IAC5C,MAAM,WAAW,GAAG,iBAAiB,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;IAC3D,IAAI,CAAC,WAAW;QAAE,OAAO,IAAI,CAAC;IAC9B,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;IACrD,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IACzB,IAAI,MAAM,CAAC,UAAU,KAAK,WAAW;QAAE,OAAO,IAAI,CAAC;IACnD,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,qBAAqB,CAAC;IAC3D,OAAO;QACL,KAAK;QACL,WAAW,EAAE,kBAAkB,CAAC,MAAM,CAAC;QACvC,QAAQ,EAAE,4BAA4B,CAAC,MAAM,CAAC,UAAU,CAAC;KAC1D,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,0BAA0B,CAAC,OAAiC;IAC1E,MAAM,KAAK,GAAG,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,KAAK,aAAa,CAAC,CAAC,CAAC,UAAU,CAAC;IAC1E,MAAM,WAAW,GACf,OAAO,EAAE,WAAW;QACpB,0DAA0D,CAAC;IAC7D,MAAM,KAAK,GAAG,OAAO,EAAE,QAAQ,IAAI,IAAI,CAAC;IACxC,OAAO;QACL,EAAE,KAAK,EAAE;QACT,EAAE,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,WAAW,EAAE;QAC7C,EAAE,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,KAAK,EAAE;QACxC,EAAE,QAAQ,EAAE,gBAAgB,EAAE,OAAO,EAAE,WAAW,EAAE;QACpD,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE;QAC3C,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC5D;YACE,IAAI,EAAE,cAAc;YACpB,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,SAAS;SACnD;QACD,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,KAAK,EAAE;QACzC,EAAE,IAAI,EAAE,qBAAqB,EAAE,OAAO,EAAE,WAAW,EAAE;QACrD,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;KAC9D,CAAC;AACJ,CAAC","sourcesContent":["import type { ChatThread } from \"@agent-native/core/server\";\n\nexport interface ThreadLinkPreview {\n title: string;\n description: string;\n imageUrl: string | null;\n}\n\nconst IMAGE_URL_KEYS = new Set([\n \"previewUrl\",\n \"thumbnailUrl\",\n \"imageUrl\",\n \"image\",\n \"downloadUrl\",\n]);\n\nconst GENERATION_TOOL_NAMES = new Set([\n \"generate-image\",\n \"generate-image-batch\",\n \"refine-image\",\n \"rerun-generation-run\",\n]);\n\nfunction safeJsonParse(value: string): unknown {\n try {\n return JSON.parse(value);\n } catch {\n return null;\n }\n}\n\nfunction cleanUrlCandidate(value: string): string {\n return value\n .trim()\n .replace(/[),.;\\]}]+$/g, \"\")\n .replace(/^[\"'(<]+/g, \"\");\n}\n\nfunction isAbsoluteHttpUrl(value: string): boolean {\n try {\n const url = new URL(value);\n return url.protocol === \"https:\" || url.protocol === \"http:\";\n } catch {\n return false;\n }\n}\n\nfunction isImageLikeUrl(value: string): boolean {\n try {\n const url = new URL(value);\n return (\n /\\.(?:png|jpe?g|webp|gif|avif)(?:$|[?#])/i.test(url.pathname) ||\n /\\/api\\/assets\\/[^/]+\\/content(?:$|[?#])/i.test(url.pathname)\n );\n } catch {\n return false;\n }\n}\n\nfunction validPreviewImageUrl(value: unknown, key?: string): string | null {\n if (typeof value !== \"string\") return null;\n const candidate = cleanUrlCandidate(value);\n if (!isAbsoluteHttpUrl(candidate)) return null;\n if (key && IMAGE_URL_KEYS.has(key)) return candidate;\n return isImageLikeUrl(candidate) ? candidate : null;\n}\n\nfunction imageUrlFromStructuredValue(value: unknown): string | null {\n if (!value || typeof value !== \"object\") return null;\n if (Array.isArray(value)) {\n for (let i = value.length - 1; i >= 0; i--) {\n const found = imageUrlFromStructuredValue(value[i]);\n if (found) return found;\n }\n return null;\n }\n\n const record = value as Record<string, unknown>;\n for (const key of IMAGE_URL_KEYS) {\n const found = validPreviewImageUrl(record[key], key);\n if (found) return found;\n }\n for (const [key, child] of Object.entries(record).reverse()) {\n const direct = validPreviewImageUrl(child, key);\n if (direct) return direct;\n if (child && typeof child === \"object\") {\n const nested = imageUrlFromStructuredValue(child);\n if (nested) return nested;\n }\n }\n return null;\n}\n\nfunction imageUrlFromText(value: string): string | null {\n const matches = value.match(/https?:\\/\\/[^\\s<>\"']+/g);\n if (!matches) return null;\n for (let i = matches.length - 1; i >= 0; i--) {\n const candidate = validPreviewImageUrl(matches[i]);\n if (candidate) return candidate;\n }\n return null;\n}\n\nexport function extractThreadPreviewImageUrl(\n threadData: string,\n): string | null {\n const parsed = safeJsonParse(threadData);\n if (!parsed || typeof parsed !== \"object\") return null;\n const messages = (parsed as { messages?: unknown }).messages;\n if (!Array.isArray(messages)) return null;\n\n for (\n let messageIndex = messages.length - 1;\n messageIndex >= 0;\n messageIndex--\n ) {\n const entry = messages[messageIndex] as any;\n const message = entry?.message ?? entry;\n const content = message?.content;\n if (!Array.isArray(content)) continue;\n\n for (let partIndex = content.length - 1; partIndex >= 0; partIndex--) {\n const part = content[partIndex] as Record<string, unknown>;\n const result = typeof part.result === \"string\" ? part.result : \"\";\n if (!result.trim()) continue;\n\n const toolName = typeof part.toolName === \"string\" ? part.toolName : \"\";\n const parsedResult = safeJsonParse(result);\n if (parsedResult && GENERATION_TOOL_NAMES.has(toolName)) {\n const structured = imageUrlFromStructuredValue(parsedResult);\n if (structured) return structured;\n }\n\n const fromText = imageUrlFromText(result);\n if (fromText) return fromText;\n }\n }\n return null;\n}\n\nfunction previewDescription(thread: ChatThread): string {\n const preview = thread.preview.trim();\n if (preview) return preview.slice(0, 180);\n return \"Open this Agent-Native thread in Dispatch.\";\n}\n\nexport async function loadThreadLinkPreview(\n threadId: string | null | undefined,\n): Promise<ThreadLinkPreview | null> {\n if (!import.meta.env.SSR) return null;\n const id = threadId?.trim();\n if (!id) return null;\n const { getRequestContext, getThread } =\n await import(\"@agent-native/core/server\");\n const viewerEmail = getRequestContext()?.userEmail?.trim();\n if (!viewerEmail) return null;\n const thread = await getThread(id).catch(() => null);\n if (!thread) return null;\n if (thread.ownerEmail !== viewerEmail) return null;\n const title = thread.title.trim() || \"Agent-Native thread\";\n return {\n title,\n description: previewDescription(thread),\n imageUrl: extractThreadPreviewImageUrl(thread.threadData),\n };\n}\n\nexport function buildThreadLinkPreviewMeta(preview: ThreadLinkPreview | null) {\n const title = preview?.title ? `${preview.title} - Dispatch` : \"Dispatch\";\n const description =\n preview?.description ||\n \"Open this Agent-Native thread in the Dispatch workspace.\";\n const image = preview?.imageUrl ?? null;\n return [\n { title },\n { name: \"description\", content: description },\n { property: \"og:title\", content: title },\n { property: \"og:description\", content: description },\n { property: \"og:type\", content: \"website\" },\n ...(image ? [{ property: \"og:image\", content: image }] : []),\n {\n name: \"twitter:card\",\n content: image ? \"summary_large_image\" : \"summary\",\n },\n { name: \"twitter:title\", content: title },\n { name: \"twitter:description\", content: description },\n ...(image ? [{ name: \"twitter:image\", content: image }] : []),\n ];\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/dispatch",
3
- "version": "0.8.27",
3
+ "version": "0.8.29",
4
4
  "type": "module",
5
5
  "description": "Dispatch — workspace control plane for agent-native apps. Vault, integrations, destinations, scheduled jobs, and cross-app delegation, shipped as a single drop-in package.",
6
6
  "license": "MIT",
@@ -1,80 +1,40 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
-
3
- const loadWorkspaceAppsManifestMock = vi.hoisted(() => vi.fn());
4
- const getBuiltinAgentsMock = vi.hoisted(() => vi.fn());
5
-
6
- vi.mock("@agent-native/core/server/agent-discovery", () => ({
7
- loadWorkspaceAppsManifest: loadWorkspaceAppsManifestMock,
8
- getBuiltinAgents: getBuiltinAgentsMock,
9
- }));
10
-
1
+ import { describe, expect, it } from "vitest";
11
2
  import { resolveCatchAllTarget } from "./catch-all-target.js";
12
3
 
13
- beforeEach(() => {
14
- vi.clearAllMocks();
15
- });
16
-
17
- afterEach(() => {
18
- vi.clearAllMocks();
19
- });
20
-
21
4
  describe("resolveCatchAllTarget", () => {
22
5
  it("prefers the workspace manifest entry when one matches", () => {
23
- loadWorkspaceAppsManifestMock.mockReturnValue([
24
- { id: "todo", name: "Todo", path: "/todo" },
25
- ]);
26
- getBuiltinAgentsMock.mockReturnValue([
27
- {
28
- id: "todo",
29
- name: "Todo",
30
- description: "",
31
- url: "https://todo.example.com",
32
- color: "#000",
33
- },
34
- ]);
35
-
36
- expect(resolveCatchAllTarget("todo")).toBe("/todo");
6
+ expect(
7
+ resolveCatchAllTarget("todo", {
8
+ workspaceApps: [{ id: "todo", path: "/todo" }],
9
+ builtinAgents: [{ id: "todo", url: "https://todo.example.com" }],
10
+ }),
11
+ ).toBe("/todo");
37
12
  });
38
13
 
39
14
  it("falls back to the built-in template URL when no workspace manifest exists", () => {
40
- loadWorkspaceAppsManifestMock.mockReturnValue(null);
41
- getBuiltinAgentsMock.mockReturnValue([
42
- {
43
- id: "forms",
44
- name: "Forms",
45
- description: "",
46
- url: "http://localhost:8084",
47
- color: "#06B6D4",
48
- },
49
- ]);
50
-
51
- expect(resolveCatchAllTarget("forms")).toBe("http://localhost:8084");
15
+ expect(
16
+ resolveCatchAllTarget("forms", {
17
+ workspaceApps: null,
18
+ builtinAgents: [{ id: "forms", url: "http://localhost:8084" }],
19
+ }),
20
+ ).toBe("http://localhost:8084");
52
21
  });
53
22
 
54
23
  it("falls back to the built-in template URL when the workspace manifest does not include the app", () => {
55
- loadWorkspaceAppsManifestMock.mockReturnValue([
56
- { id: "dispatch", name: "Dispatch", path: "/dispatch" },
57
- ]);
58
- getBuiltinAgentsMock.mockReturnValue([
59
- {
60
- id: "forms",
61
- name: "Forms",
62
- description: "",
63
- url: "http://localhost:8084",
64
- color: "#06B6D4",
65
- },
66
- ]);
67
-
68
- expect(resolveCatchAllTarget("forms")).toBe("http://localhost:8084");
24
+ expect(
25
+ resolveCatchAllTarget("forms", {
26
+ workspaceApps: [{ id: "dispatch", path: "/dispatch" }],
27
+ builtinAgents: [{ id: "forms", url: "http://localhost:8084" }],
28
+ }),
29
+ ).toBe("http://localhost:8084");
69
30
  });
70
31
 
71
32
  it("normalizes a manifest entry without a leading slash", () => {
72
- loadWorkspaceAppsManifestMock.mockReturnValue([
73
- { id: "todo", name: "Todo", path: "todo" },
74
- ]);
75
- getBuiltinAgentsMock.mockReturnValue([]);
76
-
77
- expect(resolveCatchAllTarget("todo")).toBe("/todo");
33
+ expect(
34
+ resolveCatchAllTarget("todo", {
35
+ workspaceApps: [{ id: "todo", path: "todo" }],
36
+ }),
37
+ ).toBe("/todo");
78
38
  });
79
39
 
80
40
  it("uses app.path when id !== path (not /${appId})", () => {
@@ -83,29 +43,28 @@ describe("resolveCatchAllTarget", () => {
83
43
  // silently rewritten to `/forms` (the appId) and routed to the wrong
84
44
  // app. The normalizer now keeps the manifest path and only prepends
85
45
  // the missing slash.
86
- loadWorkspaceAppsManifestMock.mockReturnValue([
87
- { id: "forms", name: "Forms", path: "my-forms" },
88
- ]);
89
- getBuiltinAgentsMock.mockReturnValue([]);
90
-
91
- expect(resolveCatchAllTarget("forms")).toBe("/my-forms");
46
+ expect(
47
+ resolveCatchAllTarget("forms", {
48
+ workspaceApps: [{ id: "forms", path: "my-forms" }],
49
+ }),
50
+ ).toBe("/my-forms");
92
51
  });
93
52
 
94
53
  it("prefers app.url when the manifest entry has an externally-hosted URL", () => {
95
54
  // Workspaces can point at remote deploys. The catch-all should bounce
96
55
  // to the absolute URL instead of mounting a local path that doesn't
97
56
  // exist inside the gateway.
98
- loadWorkspaceAppsManifestMock.mockReturnValue([
99
- {
100
- id: "forms",
101
- name: "Forms",
102
- path: "/forms",
103
- url: "https://forms.example.com",
104
- },
105
- ]);
106
- getBuiltinAgentsMock.mockReturnValue([]);
107
-
108
- expect(resolveCatchAllTarget("forms")).toBe("https://forms.example.com");
57
+ expect(
58
+ resolveCatchAllTarget("forms", {
59
+ workspaceApps: [
60
+ {
61
+ id: "forms",
62
+ path: "/forms",
63
+ url: "https://forms.example.com",
64
+ },
65
+ ],
66
+ }),
67
+ ).toBe("https://forms.example.com");
109
68
  });
110
69
 
111
70
  it("ignores app.url that isn't an absolute http(s) URL and falls back to path", () => {
@@ -114,73 +73,54 @@ describe("resolveCatchAllTarget", () => {
114
73
  // this, the catch-all would `throw redirect("forms.example.com")`
115
74
  // and the browser would treat the value as a relative path inside the
116
75
  // gateway, producing a broken redirect.
117
- loadWorkspaceAppsManifestMock.mockReturnValue([
118
- {
119
- id: "forms",
120
- name: "Forms",
121
- path: "/forms",
122
- url: "forms.example.com",
123
- },
124
- ]);
125
- getBuiltinAgentsMock.mockReturnValue([]);
126
-
127
- expect(resolveCatchAllTarget("forms")).toBe("/forms");
76
+ expect(
77
+ resolveCatchAllTarget("forms", {
78
+ workspaceApps: [
79
+ { id: "forms", path: "/forms", url: "forms.example.com" },
80
+ ],
81
+ }),
82
+ ).toBe("/forms");
128
83
  });
129
84
 
130
85
  it("rejects non-http(s) URL schemes (e.g. javascript:) and falls back to path", () => {
131
86
  // Defense in depth — a hostile manifest entry can't produce a
132
87
  // `javascript:` redirect target. Validation enforces http(s) only.
133
- loadWorkspaceAppsManifestMock.mockReturnValue([
134
- {
135
- id: "forms",
136
- name: "Forms",
137
- path: "/forms",
138
- url: "javascript:alert(1)",
139
- },
140
- ]);
141
- getBuiltinAgentsMock.mockReturnValue([]);
142
-
143
- expect(resolveCatchAllTarget("forms")).toBe("/forms");
88
+ expect(
89
+ resolveCatchAllTarget("forms", {
90
+ workspaceApps: [
91
+ { id: "forms", path: "/forms", url: "javascript:alert(1)" },
92
+ ],
93
+ }),
94
+ ).toBe("/forms");
144
95
  });
145
96
 
146
97
  it("strips a trailing slash from app.url", () => {
147
- loadWorkspaceAppsManifestMock.mockReturnValue([
148
- {
149
- id: "forms",
150
- name: "Forms",
151
- path: "/forms",
152
- url: "https://forms.example.com/",
153
- },
154
- ]);
155
- getBuiltinAgentsMock.mockReturnValue([]);
156
-
157
- expect(resolveCatchAllTarget("forms")).toBe("https://forms.example.com");
98
+ expect(
99
+ resolveCatchAllTarget("forms", {
100
+ workspaceApps: [
101
+ { id: "forms", path: "/forms", url: "https://forms.example.com/" },
102
+ ],
103
+ }),
104
+ ).toBe("https://forms.example.com");
158
105
  });
159
106
 
160
107
  it("ignores an empty/whitespace app.url and falls back to path", () => {
161
- loadWorkspaceAppsManifestMock.mockReturnValue([
162
- {
163
- id: "forms",
164
- name: "Forms",
165
- path: "/forms",
166
- url: " ",
167
- },
168
- ]);
169
- getBuiltinAgentsMock.mockReturnValue([]);
170
-
171
- expect(resolveCatchAllTarget("forms")).toBe("/forms");
108
+ expect(
109
+ resolveCatchAllTarget("forms", {
110
+ workspaceApps: [{ id: "forms", path: "/forms", url: " " }],
111
+ }),
112
+ ).toBe("/forms");
172
113
  });
173
114
 
174
115
  it("collapses leading slashes/backslashes in app.path so `/\\evil.example` can't redirect off-origin", () => {
175
116
  // Browsers normalize backslashes to forward slashes during URL
176
117
  // parsing, so `throw redirect("/\\evil.example")` would resolve to
177
118
  // `https://evil.example`. The regex covers both slash types.
178
- loadWorkspaceAppsManifestMock.mockReturnValue([
179
- { id: "forms", name: "Forms", path: "/\\evil.example" },
180
- ]);
181
- getBuiltinAgentsMock.mockReturnValue([]);
182
-
183
- expect(resolveCatchAllTarget("forms")).toBe("/evil.example");
119
+ expect(
120
+ resolveCatchAllTarget("forms", {
121
+ workspaceApps: [{ id: "forms", path: "/\\evil.example" }],
122
+ }),
123
+ ).toBe("/evil.example");
184
124
  });
185
125
 
186
126
  it("collapses leading double slashes in app.path so `//evil.example` can't redirect off-origin", () => {
@@ -190,29 +130,26 @@ describe("resolveCatchAllTarget", () => {
190
130
  // to `https://evil.example` — the same phishing vector the `app.url`
191
131
  // validator closes. Collapse the leading slashes so the redirect
192
132
  // stays on the gateway.
193
- loadWorkspaceAppsManifestMock.mockReturnValue([
194
- { id: "forms", name: "Forms", path: "//evil.example" },
195
- ]);
196
- getBuiltinAgentsMock.mockReturnValue([]);
197
-
198
- expect(resolveCatchAllTarget("forms")).toBe("/evil.example");
133
+ expect(
134
+ resolveCatchAllTarget("forms", {
135
+ workspaceApps: [{ id: "forms", path: "//evil.example" }],
136
+ }),
137
+ ).toBe("/evil.example");
199
138
  });
200
139
 
201
140
  it("falls back to /${appId} when the manifest entry has neither path nor url", () => {
202
- loadWorkspaceAppsManifestMock.mockReturnValue([
203
- { id: "forms", name: "Forms", path: "" },
204
- ]);
205
- getBuiltinAgentsMock.mockReturnValue([]);
206
-
207
- expect(resolveCatchAllTarget("forms")).toBe("/forms");
141
+ expect(
142
+ resolveCatchAllTarget("forms", {
143
+ workspaceApps: [{ id: "forms", path: "" }],
144
+ }),
145
+ ).toBe("/forms");
208
146
  });
209
147
 
210
148
  it("returns null when nothing matches", () => {
211
- loadWorkspaceAppsManifestMock.mockReturnValue([
212
- { id: "dispatch", name: "Dispatch", path: "/dispatch" },
213
- ]);
214
- getBuiltinAgentsMock.mockReturnValue([]);
215
-
216
- expect(resolveCatchAllTarget("unknown-app")).toBeNull();
149
+ expect(
150
+ resolveCatchAllTarget("unknown-app", {
151
+ workspaceApps: [{ id: "dispatch", path: "/dispatch" }],
152
+ }),
153
+ ).toBeNull();
217
154
  });
218
155
  });
@@ -1,7 +1,18 @@
1
- import {
2
- getBuiltinAgents,
3
- loadWorkspaceAppsManifest,
4
- } from "@agent-native/core/server/agent-discovery";
1
+ interface WorkspaceAppManifestEntry {
2
+ id?: string;
3
+ path?: unknown;
4
+ url?: unknown;
5
+ }
6
+
7
+ interface BuiltinAgentEntry {
8
+ id: string;
9
+ url?: string | null;
10
+ }
11
+
12
+ interface ResolveCatchAllTargetOptions {
13
+ workspaceApps?: WorkspaceAppManifestEntry[] | null;
14
+ builtinAgents?: BuiltinAgentEntry[] | null;
15
+ }
5
16
 
6
17
  /**
7
18
  * Resolve where `/dispatch/<appId>` should bounce to when it doesn't match
@@ -15,7 +26,7 @@ import {
15
26
  * present.
16
27
  * - Otherwise the `app.path` mounted under the workspace gateway is
17
28
  * used. Path is normalized to a leading slash if missing
18
- * (e.g. manifest entry `path: "my-forms"` `/my-forms`), so an app
29
+ * (e.g. manifest entry `path: "my-forms"` -> `/my-forms`), so an app
19
30
  * whose mounted path differs from its id ends up at the right place
20
31
  * instead of being silently rewritten to `/${appId}`.
21
32
  * - Bare entry with no path / url falls back to `/${appId}`.
@@ -50,8 +61,11 @@ function validatedAbsoluteUrl(value: unknown): string | undefined {
50
61
  }
51
62
  }
52
63
 
53
- export function resolveCatchAllTarget(appId: string): string | null {
54
- const apps = loadWorkspaceAppsManifest();
64
+ export function resolveCatchAllTarget(
65
+ appId: string,
66
+ options: ResolveCatchAllTargetOptions = {},
67
+ ): string | null {
68
+ const apps = options.workspaceApps;
55
69
  if (apps) {
56
70
  const app = apps.find((entry) => entry?.id === appId);
57
71
  if (app) {
@@ -81,7 +95,7 @@ export function resolveCatchAllTarget(appId: string): string | null {
81
95
  // `\/evil.example` — same idea, leading-backslash variant.
82
96
  //
83
97
  // The manifest parser only checks `startsWith("/")` for the first
84
- // case, and even that allows `//evil…`. Defend in depth here by
98
+ // case, and even that allows `//evil...`. Defend in depth here by
85
99
  // collapsing any run of leading slashes-or-backslashes to one
86
100
  // forward slash. Same phishing vector that `validatedAbsoluteUrl`
87
101
  // closes for `app.url`.
@@ -92,8 +106,20 @@ export function resolveCatchAllTarget(appId: string): string | null {
92
106
  return `/${appId}`;
93
107
  }
94
108
  }
95
- const builtin = getBuiltinAgents("dispatch").find(
109
+ const builtin = (options.builtinAgents ?? []).find(
96
110
  (agent) => agent.id === appId,
97
111
  );
98
112
  return builtin?.url ?? null;
99
113
  }
114
+
115
+ export async function resolveServerCatchAllTarget(
116
+ appId: string,
117
+ ): Promise<string | null> {
118
+ if (!import.meta.env.SSR) return null;
119
+ const { getBuiltinAgents, loadWorkspaceAppsManifest } =
120
+ await import("@agent-native/core/server/agent-discovery");
121
+ return resolveCatchAllTarget(appId, {
122
+ workspaceApps: loadWorkspaceAppsManifest(),
123
+ builtinAgents: getBuiltinAgents("dispatch"),
124
+ });
125
+ }
@@ -17,7 +17,7 @@ import { DispatchShell } from "@/components/dispatch-shell";
17
17
  import { Spinner } from "@/components/ui/spinner";
18
18
  import { Badge } from "@/components/ui/badge";
19
19
  import { Button } from "@/components/ui/button";
20
- import { resolveCatchAllTarget } from "@/lib/catch-all-target";
20
+ import { resolveServerCatchAllTarget } from "@/lib/catch-all-target";
21
21
  import {
22
22
  workspaceAppHref,
23
23
  type WorkspaceAppSummary,
@@ -68,12 +68,12 @@ function dispatchSelfRedirect(appId: string | undefined): string | null {
68
68
  return null;
69
69
  }
70
70
 
71
- export function loader({ params }: LoaderFunctionArgs) {
71
+ export async function loader({ params }: LoaderFunctionArgs) {
72
72
  const appId = params.appId;
73
73
  if (!appId) return null;
74
74
  const selfTarget = dispatchSelfRedirect(appId);
75
75
  if (selfTarget) throw redirect(selfTarget);
76
- const target = resolveCatchAllTarget(appId);
76
+ const target = await resolveServerCatchAllTarget(appId);
77
77
  if (target) throw redirect(target);
78
78
  return null;
79
79
  }
@@ -8,6 +8,7 @@ import {
8
8
  IconChartBar,
9
9
  IconChevronDown,
10
10
  IconClipboardList,
11
+ IconContract,
11
12
  IconEyeOff,
12
13
  IconFileText,
13
14
  IconLoader2,
@@ -64,6 +65,7 @@ const TEMPLATE_ICONS: Record<string, typeof IconMail> = {
64
65
  Photo: IconPhoto,
65
66
  ChartBar: IconChartBar,
66
67
  ClipboardList: IconClipboardList,
68
+ Contract: IconContract,
67
69
  Brush: IconBrush,
68
70
  Video: IconVideo,
69
71
  };
@@ -1329,6 +1329,15 @@ const ADDABLE_TEMPLATES: AvailableWorkspaceTemplate[] = [
1329
1329
  colorRgb: "6 182 212",
1330
1330
  core: true,
1331
1331
  },
1332
+ {
1333
+ name: "contracts",
1334
+ label: "Contracts",
1335
+ hint: "Review assumptions, feedback, and proof for coding-agent work",
1336
+ icon: "Contract",
1337
+ color: "#4F46E5",
1338
+ colorRgb: "79 70 229",
1339
+ core: false,
1340
+ },
1332
1341
  {
1333
1342
  name: "design",
1334
1343
  label: "Design",
@@ -1,5 +1,4 @@
1
1
  import type { ChatThread } from "@agent-native/core/server";
2
- import { getRequestContext, getThread } from "@agent-native/core/server";
3
2
 
4
3
  export interface ThreadLinkPreview {
5
4
  title: string;
@@ -148,8 +147,11 @@ function previewDescription(thread: ChatThread): string {
148
147
  export async function loadThreadLinkPreview(
149
148
  threadId: string | null | undefined,
150
149
  ): Promise<ThreadLinkPreview | null> {
150
+ if (!import.meta.env.SSR) return null;
151
151
  const id = threadId?.trim();
152
152
  if (!id) return null;
153
+ const { getRequestContext, getThread } =
154
+ await import("@agent-native/core/server");
153
155
  const viewerEmail = getRequestContext()?.userEmail?.trim();
154
156
  if (!viewerEmail) return null;
155
157
  const thread = await getThread(id).catch(() => null);