From e8d579dac0162df15c5c2cee1b370fb76f6c1f3c Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:13:18 +0900 Subject: [PATCH 1/3] feat(i18n): extract en translations for context/lib/misc Extract hardcoded English strings to i18n keys for: - automations context, providers store, shared-bundles - mcp-auth-modal, onboarding-workspace-selector, question-modal Co-Authored-By: Claude Sonnet 4.6 --- .../app/src/app/components/mcp-auth-modal.tsx | 12 +- .../onboarding-workspace-selector.tsx | 133 ++++ .../app/src/app/components/question-modal.tsx | 13 +- apps/app/src/app/context/automations.ts | 9 +- apps/app/src/app/context/providers/store.ts | 66 +- apps/app/src/app/lib/shared-bundles.ts | 638 ++++++++++++++++++ apps/app/src/i18n/locales/en.ts | 72 ++ 7 files changed, 894 insertions(+), 49 deletions(-) create mode 100644 apps/app/src/app/components/onboarding-workspace-selector.tsx create mode 100644 apps/app/src/app/lib/shared-bundles.ts diff --git a/apps/app/src/app/components/mcp-auth-modal.tsx b/apps/app/src/app/components/mcp-auth-modal.tsx index 2ffde2846..37627336a 100644 --- a/apps/app/src/app/components/mcp-auth-modal.tsx +++ b/apps/app/src/app/components/mcp-auth-modal.tsx @@ -694,7 +694,7 @@ export default function McpAuthModal(props: McpAuthModalProps) {
-

Already Connected

+

{translate("mcp.auth.already_connected")}

{translate("mcp.auth.already_connected_description", { server: serverName() })}

@@ -804,7 +804,7 @@ export default function McpAuthModal(props: McpAuthModalProps) {
-
Authorization link
+
{translate("mcp.auth.authorization_link")}
{authorizationUrl()}
@@ -814,7 +814,7 @@ export default function McpAuthModal(props: McpAuthModalProps) { class="text-xs" onClick={handleCopyAuthorizationUrl} > - {authUrlCopied() ? "Copied" : "Copy link"} + {authUrlCopied() ? translate("mcp.auth.copied") : translate("mcp.auth.copy_link")}
-

Opening your browser

+

{translate("mcp.auth.step1_title")}

{translate("mcp.auth.step1_description", { server: serverName() })}

@@ -863,7 +863,7 @@ export default function McpAuthModal(props: McpAuthModalProps) { 2
-

Authorize OpenWork

+

{translate("mcp.auth.step2_title")}

{translate("mcp.auth.step2_description")}

@@ -875,7 +875,7 @@ export default function McpAuthModal(props: McpAuthModalProps) { 3
-

Return here when you're done

+

{translate("mcp.auth.step3_title")}

{translate("mcp.auth.step3_description")}

diff --git a/apps/app/src/app/components/onboarding-workspace-selector.tsx b/apps/app/src/app/components/onboarding-workspace-selector.tsx new file mode 100644 index 000000000..ffb4dcf17 --- /dev/null +++ b/apps/app/src/app/components/onboarding-workspace-selector.tsx @@ -0,0 +1,133 @@ +import { For, Show, createEffect, createSignal } from "solid-js"; + +import { CheckCircle2, FolderPlus, Loader2 } from "lucide-solid"; + +import Button from "./button"; +import { t } from "../../i18n"; + +export default function OnboardingWorkspaceSelector(props: { + defaultPath: string; + onConfirm: (preset: "automation" | "minimal", folder: string | null) => void; + onPickFolder: () => Promise; +}) { + const [preset, setPreset] = createSignal<"automation" | "minimal">("minimal"); + const [selectedFolder, setSelectedFolder] = createSignal(props.defaultPath); + const [pickingFolder, setPickingFolder] = createSignal(false); + + const options = () => [ + { + id: "minimal" as const, + name: t("onboarding.empty_worker"), + desc: t("onboarding.empty_worker_desc"), + }, + ]; + + const canContinue = () => Boolean(selectedFolder().trim()); + + createEffect(() => { + if (!selectedFolder().trim()) { + setSelectedFolder(props.defaultPath); + } + }); + + const handlePickFolder = async () => { + if (pickingFolder()) return; + setPickingFolder(true); + try { + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); + const next = await props.onPickFolder(); + if (next) { + setSelectedFolder(next); + } + } finally { + setPickingFolder(false); + } + }; + + return ( +
+
+
+
+
1
+ {t("dashboard.select_folder")} +
+
+
+
+ + setSelectedFolder(e.currentTarget.value)} + placeholder={props.defaultPath} + /> + +
+
+
+
+ +
+
+
2
+ {t("dashboard.choose_preset")} +
+
+ + {(opt) => ( +
{ + if (!canContinue()) return; + setPreset(opt.id); + }} + class={`p-4 rounded-xl border cursor-pointer transition-all ${ + preset() === opt.id + ? "bg-gray-4 border-gray-6 hover:border-gray-7" + : "bg-gray-1/40 border-gray-6 hover:border-gray-7" + } ${!canContinue() ? "pointer-events-none" : ""}`.trim()} + > +
+
+
+ {opt.name} +
+
{opt.desc}
+
+ + + +
+
+ )} +
+
+
+
+ +
+ ); +} diff --git a/apps/app/src/app/components/question-modal.tsx b/apps/app/src/app/components/question-modal.tsx index 4b3b070bd..a01654e81 100644 --- a/apps/app/src/app/components/question-modal.tsx +++ b/apps/app/src/app/components/question-modal.tsx @@ -4,6 +4,7 @@ import type { QuestionInfo } from "@opencode-ai/sdk/v2/client"; import { Check, ChevronRight, HelpCircle } from "lucide-solid"; import Button from "./button"; +import { t } from "../../i18n"; export type QuestionModalProps = { open: boolean; @@ -138,10 +139,10 @@ export default function QuestionModal(props: QuestionModalProps) {

- {currentQuestion()!.header || "Question"} + {currentQuestion()!.header || t("common.question")}

- Question {currentIndex() + 1} of {props.questions.length} + {t("question_modal.question_counter", undefined, { current: currentIndex() + 1, total: props.questions.length })}
@@ -186,14 +187,14 @@ export default function QuestionModal(props: QuestionModalProps) {
setCustomInput(e.currentTarget.value)} class="w-full px-4 py-3 rounded-xl bg-dls-surface border border-dls-border focus:border-dls-accent focus:ring-4 focus:ring-[rgba(var(--dls-accent-rgb),0.2)] focus:outline-none text-sm text-dls-text placeholder:text-dls-secondary transition-shadow" - placeholder="Type your answer here..." + placeholder={t("question_modal.custom_answer_placeholder")} onKeyDown={(e) => { if (e.key === "Enter") { if (e.isComposing || e.keyCode === 229) return; @@ -209,9 +210,9 @@ export default function QuestionModal(props: QuestionModalProps) {
↑↓ - navigate + {t("common.navigate")} - select + {t("common.select")}
diff --git a/apps/app/src/app/context/automations.ts b/apps/app/src/app/context/automations.ts index 77966544d..cd39e3f7b 100644 --- a/apps/app/src/app/context/automations.ts +++ b/apps/app/src/app/context/automations.ts @@ -5,6 +5,7 @@ import { schedulerDeleteJob, schedulerListJobs } from "../lib/tauri"; import { isTauriRuntime } from "../utils"; import { createWorkspaceContextKey } from "./workspace-context"; import type { OpenworkServerStore } from "../connections/openwork-server-store"; +import { t } from "../../i18n"; export type AutomationsStore = ReturnType; @@ -136,10 +137,10 @@ export function createAutomationsStore(options: { if (scheduledJobsContextKey() !== requestContextKey) return "skipped"; const status = options.openworkServer.openworkServerStatus() === "disconnected" - ? "OpenWork server unavailable. Connect to sync scheduled tasks." + ? t("automations.server_unavailable") : options.openworkServer.openworkServerStatus() === "limited" - ? "OpenWork server needs a token to load scheduled tasks." - : "OpenWork server not ready."; + ? t("automations.server_needs_token") + : t("automations.server_not_ready"); setScheduledJobsStatus(status); return "unavailable"; } @@ -155,7 +156,7 @@ export function createAutomationsStore(options: { } catch (error) { if (scheduledJobsContextKey() !== requestContextKey) return "skipped"; const message = error instanceof Error ? error.message : String(error); - setScheduledJobsStatus(message || "Failed to load scheduled tasks."); + setScheduledJobsStatus(message || t("automations.failed_to_load")); return "error"; } finally { setScheduledJobsBusy(false); diff --git a/apps/app/src/app/context/providers/store.ts b/apps/app/src/app/context/providers/store.ts index f2dae9991..c5e6eea1c 100644 --- a/apps/app/src/app/context/providers/store.ts +++ b/apps/app/src/app/context/providers/store.ts @@ -2,7 +2,7 @@ import { createMemo, createSignal, type Accessor } from "solid-js"; import type { ProviderAuthAuthorization, ProviderListResponse } from "@opencode-ai/sdk/v2/client"; -import { t, currentLocale } from "../../../i18n"; +import { t } from "../../../i18n"; import { unwrap, waitForHealthy } from "../../lib/opencode"; import type { Client, ProviderListItem, WorkspaceDisplay } from "../../types"; import { safeStringify } from "../../utils"; @@ -70,7 +70,7 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { const assertNoClientError = (result: unknown) => { const maybe = result as { error?: unknown } | null | undefined; if (!maybe || maybe.error === undefined) return; - throw new Error(describeProviderError(maybe.error, t("app.error_request_failed", currentLocale()))); + throw new Error(describeProviderError(maybe.error, t("providers.request_failed"))); }; const describeProviderError = (error: unknown, fallback: string) => { @@ -125,9 +125,9 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { const generic = raw && /^unknown\s+error$/i.test(raw); const heading = (() => { - if (status === 401 || status === 403) return t("app.error_auth_failed", currentLocale()); - if (status === 429) return t("app.error_rate_limit", currentLocale()); - if (provider) return `Provider error (${provider})`; + if (status === 401 || status === 403) return t("providers.auth_failed"); + if (status === 429) return t("providers.rate_limit_exceeded"); + if (provider) return t("providers.provider_error", undefined, { provider }); return fallback; })(); @@ -167,7 +167,7 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { if (!Array.isArray(provider.env) || provider.env.length === 0) continue; const existing = merged[id] ?? []; if (existing.some((method) => method.type === "api")) continue; - merged[id] = [...existing, { type: "api", label: "API key" }]; + merged[id] = [...existing, { type: "api", label: t("providers.api_key_label") }]; } for (const [id, providerMethods] of Object.entries(merged)) { const provider = availableProviders.find((item) => item.id === id); @@ -188,7 +188,7 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { const loadProviderAuthMethods = async (workerType: "local" | "remote") => { const c = options.client(); if (!c) { - throw new Error(t("app.error_not_connected", currentLocale())); + throw new Error(t("providers.not_connected")); } const methods = unwrap(await c.provider.auth()); return buildProviderAuthMethods( @@ -205,7 +205,7 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { setProviderAuthError(null); const c = options.client(); if (!c) { - throw new Error(t("app.error_not_connected", currentLocale())); + throw new Error(t("providers.not_connected")); } try { const cachedMethods = providerAuthMethods(); @@ -214,17 +214,17 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { : await loadProviderAuthMethods(providerAuthWorkerType()); const providerIds = Object.keys(authMethods).sort(); if (!providerIds.length) { - throw new Error("No providers available"); + throw new Error(t("providers.no_providers_available")); } const resolved = providerId?.trim() ?? ""; if (!resolved) { - throw new Error("Provider ID is required"); + throw new Error(t("providers.provider_id_required")); } const methods = authMethods[resolved]; if (!methods || !methods.length) { - throw new Error(`Unknown provider: ${resolved}`); + throw new Error(`${t("providers.unknown_provider")}: ${resolved}`); } const oauthIndex = @@ -232,12 +232,12 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { ? methodIndex : methods.find((method) => method.type === "oauth")?.methodIndex ?? -1; if (oauthIndex === -1) { - throw new Error(`No OAuth flow available for ${resolved}. Use an API key instead.`); + throw new Error(`${t("providers.no_oauth_prefix")} ${resolved}. ${t("providers.use_api_key_suffix")}`); } const selectedMethod = methods.find((method) => method.methodIndex === oauthIndex); if (!selectedMethod || selectedMethod.type !== "oauth") { - throw new Error(`Selected auth method is not an OAuth flow for ${resolved}.`); + throw new Error(`${t("providers.not_oauth_flow_prefix")} ${resolved}.`); } const auth = unwrap(await c.provider.oauth.authorize({ providerID: resolved, method: oauthIndex })); @@ -246,7 +246,7 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { authorization: auth, }; } catch (error) { - const message = describeProviderError(error, "Failed to connect provider"); + const message = describeProviderError(error, t("providers.connect_failed")); setProviderAuthError(message); throw error instanceof Error ? error : new Error(message); } @@ -310,7 +310,7 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { setProviderAuthError(null); const c = options.client(); if (!c) { - throw new Error(t("app.error_not_connected", currentLocale())); + throw new Error(t("providers.not_connected")); } const resolved = providerId?.trim(); @@ -319,7 +319,7 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { } if (!Number.isInteger(methodIndex) || methodIndex < 0) { - throw new Error("OAuth method is required"); + throw new Error(t("providers.oauth_method_required")); } const waitForProviderConnection = async (timeoutMs = 15_000, pollMs = 2_000) => { @@ -354,26 +354,26 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { const updated = await refreshProviders({ dispose: true }); const connectedNow = Array.isArray(updated?.connected) && updated.connected.includes(resolved); if (connectedNow) { - return { connected: true, message: `Connected ${resolved}` }; + return { connected: true, message: `${t("status.connected")} ${resolved}` }; } const connected = await waitForProviderConnection(); if (connected) { - return { connected: true, message: `Connected ${resolved}` }; + return { connected: true, message: `${t("status.connected")} ${resolved}` }; } return { connected: false, pending: true }; } catch (error) { if (isPendingOauthError(error)) { const updated = await refreshProviders({ dispose: true }); if (Array.isArray(updated?.connected) && updated.connected.includes(resolved)) { - return { connected: true, message: `Connected ${resolved}` }; + return { connected: true, message: `${t("status.connected")} ${resolved}` }; } const connected = await waitForProviderConnection(); if (connected) { - return { connected: true, message: `Connected ${resolved}` }; + return { connected: true, message: `${t("status.connected")} ${resolved}` }; } return { connected: false, pending: true }; } - const message = describeProviderError(error, "Failed to complete OAuth"); + const message = describeProviderError(error, t("providers.oauth_failed")); setProviderAuthError(message); throw error instanceof Error ? error : new Error(message); } @@ -383,12 +383,12 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { setProviderAuthError(null); const c = options.client(); if (!c) { - throw new Error(t("app.error_not_connected", currentLocale())); + throw new Error(t("providers.not_connected")); } const trimmed = apiKey.trim(); if (!trimmed) { - throw new Error("API key is required"); + throw new Error(t("providers.api_key_required")); } try { @@ -397,9 +397,9 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { auth: { type: "api", key: trimmed }, }); await refreshProviders({ dispose: true }); - return `Connected ${providerId}`; + return `${t("status.connected")} ${providerId}`; } catch (error) { - const message = describeProviderError(error, "Failed to save API key"); + const message = describeProviderError(error, t("providers.save_api_key_failed")); setProviderAuthError(message); throw error instanceof Error ? error : new Error(message); } @@ -409,7 +409,7 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { setProviderAuthError(null); const c = options.client(); if (!c) { - throw new Error(t("app.error_not_connected", currentLocale())); + throw new Error(t("providers.not_connected")); } const resolved = providerId.trim(); @@ -447,7 +447,7 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { return; } - throw new Error("Provider auth removal is not supported by this client."); + throw new Error(t("providers.removal_unsupported")); }; const disableProvider = async () => { @@ -492,18 +492,18 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { } if (!Array.isArray(updated?.connected) || !updated.connected.includes(resolved)) { return disabled - ? `Disconnected ${resolved} and disabled it in OpenCode config.` - : `Disconnected ${resolved}.`; + ? `${t("providers.disconnected_prefix")} ${resolved} ${t("providers.disabled_in_config_suffix")}` + : `${t("providers.disconnected_prefix")} ${resolved}.`; } } if (Array.isArray(updated?.connected) && updated.connected.includes(resolved)) { - return `Removed stored credentials for ${resolved}, but the worker still reports it as connected. Clear any remaining API key or OAuth credentials and restart the worker to fully disconnect.`; + return `Removed stored credentials for ${resolved}${t("providers.still_connected_suffix")}`; } removeProviderFromState(resolved); - return `Disconnected ${resolved}`; + return `${t("providers.disconnected_prefix")} ${resolved}`; } catch (error) { - const message = describeProviderError(error, "Failed to disconnect provider"); + const message = describeProviderError(error, t("providers.disconnect_failed")); setProviderAuthError(message); throw error instanceof Error ? error : new Error(message); } @@ -524,7 +524,7 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { } catch (error) { setProviderAuthPreferredProviderId(null); setProviderAuthReturnFocusTarget("none"); - const message = describeProviderError(error, "Failed to load providers"); + const message = describeProviderError(error, t("providers.load_failed")); setProviderAuthError(message); throw error; } finally { diff --git a/apps/app/src/app/lib/shared-bundles.ts b/apps/app/src/app/lib/shared-bundles.ts new file mode 100644 index 000000000..d334e4e1d --- /dev/null +++ b/apps/app/src/app/lib/shared-bundles.ts @@ -0,0 +1,638 @@ +import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; + +import { DEFAULT_DEN_BASE_URL, normalizeDenBaseUrl } from "./den"; +import { + normalizeOpenworkServerUrl, + type OpenworkServerClient, + type OpenworkWorkspaceExport, +} from "./openwork-server"; +import type { WorkspacePreset } from "../types"; +import { isTauriRuntime, safeStringify } from "../utils"; +import { t } from "../../i18n"; + +export type RemoteWorkspaceDefaults = { + openworkHostUrl?: string | null; + openworkToken?: string | null; + directory?: string | null; + displayName?: string | null; + autoConnect?: boolean; +}; + +type SharedSkillItem = { + name: string; + description?: string; + content: string; + trigger?: string; +}; + +export type SharedSkillBundleV1 = { + schemaVersion: 1; + type: "skill"; + name: string; + description?: string; + trigger?: string; + content: string; +}; + +export type SharedSkillsSetBundleV1 = { + schemaVersion: 1; + type: "skills-set"; + name: string; + description?: string; + skills: SharedSkillItem[]; +}; + +export type SharedWorkspaceProfileBundleV1 = { + schemaVersion: 1; + type: "workspace-profile"; + name: string; + description?: string; + workspace: OpenworkWorkspaceExport; +}; + +export type SharedBundleV1 = + | SharedSkillBundleV1 + | SharedSkillsSetBundleV1 + | SharedWorkspaceProfileBundleV1; + +export type SharedBundleImportIntent = "new_worker" | "import_current"; + +export type SharedBundleDeepLink = { + bundleUrl: string; + intent: SharedBundleImportIntent; + source?: string; + orgId?: string; + label?: string; +}; + +export type DenAuthDeepLink = { + grant: string; + denBaseUrl: string; +}; + +export 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 isSupportedDeepLinkProtocol(protocol: string): boolean { + const normalized = protocol.toLowerCase(); + return ( + normalized === "openwork:" || + normalized === "openwork-dev:" || + normalized === "https:" || + normalized === "http:" + ); +} + +export function describeSharedBundleImport(bundle: SharedBundleV1): { + title: string; + description: string; + items: string[]; +} { + if (bundle.type === "skill") { + return { + title: t("bundle.import_one_skill"), + description: bundle.description?.trim() || t("bundle.skill_description", undefined, { name: bundle.name }), + items: [bundle.name], + }; + } + + if (bundle.type === "skills-set") { + const count = bundle.skills.length; + return { + title: t("bundle.import_n_skills", undefined, { count }), + description: + bundle.description?.trim() || + t("bundle.skills_set_description", undefined, { name: bundle.name || t("bundle.shared_skills_fallback") }), + items: bundle.skills.map((skill) => skill.name), + }; + } + + return { + title: bundle.name?.trim() || t("bundle.open_workspace_template"), + description: + bundle.description?.trim() || + t("bundle.workspace_profile_description", undefined, { name: bundle.name || t("bundle.workspace_template_fallback") }), + items: Array.isArray(bundle.workspace.skills) ? bundle.workspace.skills.map((skill) => skill.name) : [], + }; +} + +function readRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +function readSkillItem(value: unknown): SharedSkillItem | null { + const record = readRecord(value); + if (!record) return null; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const content = typeof record.content === "string" ? record.content : ""; + if (!name || !content) return null; + return { + name, + description: typeof record.description === "string" ? record.description : undefined, + trigger: typeof record.trigger === "string" ? record.trigger : undefined, + content, + }; +} + +function readTemplateFileItem(value: unknown): { path: string; content: string } | null { + const record = readRecord(value); + if (!record) return null; + const path = typeof record.path === "string" ? record.path.trim() : ""; + const content = typeof record.content === "string" ? record.content : ""; + if (!path) return null; + return { path, content }; +} + +function readWorkspacePreset(value: unknown): WorkspacePreset { + const normalized = String(value ?? "").trim().toLowerCase(); + if (normalized === "automation" || normalized === "minimal") { + return normalized; + } + return "starter"; +} + +export function defaultPresetFromTemplateBundle(bundle: SharedWorkspaceProfileBundleV1): WorkspacePreset { + const openwork = bundle.workspace?.openwork; + if (!openwork || typeof openwork !== "object") return "starter"; + const workspace = (openwork as Record).workspace; + if (!workspace || typeof workspace !== "object") return "starter"; + return readWorkspacePreset((workspace as Record).preset); +} + +export function parseSharedBundle(value: unknown): SharedBundleV1 { + const record = readRecord(value); + if (!record) { + throw new Error(t("bundle.invalid_payload")); + } + + const schemaVersion = typeof record.schemaVersion === "number" ? record.schemaVersion : null; + const type = typeof record.type === "string" ? record.type.trim() : ""; + const name = typeof record.name === "string" ? record.name.trim() : ""; + + if (schemaVersion !== 1) { + throw new Error(t("bundle.unsupported_version")); + } + + if (type === "skill") { + const content = typeof record.content === "string" ? record.content : ""; + if (!name || !content) { + throw new Error(t("bundle.invalid_skill_payload")); + } + return { + schemaVersion: 1, + type: "skill", + name, + description: typeof record.description === "string" ? record.description : undefined, + trigger: typeof record.trigger === "string" ? record.trigger : undefined, + content, + }; + } + + if (type === "skills-set") { + const skills = Array.isArray(record.skills) + ? record.skills.map(readSkillItem).filter((item): item is SharedSkillItem => Boolean(item)) + : []; + if (!skills.length) { + throw new Error(t("bundle.no_importable_skills")); + } + return { + schemaVersion: 1, + type: "skills-set", + name: name || t("bundle.shared_skills_fallback"), + description: typeof record.description === "string" ? record.description : undefined, + skills, + }; + } + + if (type === "workspace-profile") { + const workspace = readRecord(record.workspace); + if (!workspace) { + throw new Error(t("bundle.missing_workspace_payload")); + } + const files = Array.isArray(workspace.files) + ? workspace.files.map(readTemplateFileItem).filter((item): item is { path: string; content: string } => Boolean(item)) + : []; + return { + schemaVersion: 1, + type: "workspace-profile", + name: name || t("bundle.shared_workspace_profile_fallback"), + description: typeof record.description === "string" ? record.description : undefined, + workspace: { + ...(workspace as OpenworkWorkspaceExport), + ...(files.length ? { files } : {}), + }, + }; + } + + throw new Error(`Unsupported bundle type: ${type || "unknown"}`); +} + +export async function fetchSharedBundle( + bundleUrl: string, + serverClient?: OpenworkServerClient | null, +): Promise { + let targetUrl: URL; + try { + targetUrl = new URL(bundleUrl); + } catch { + throw new Error(t("bundle.invalid_url")); + } + + if (targetUrl.protocol !== "https:" && targetUrl.protocol !== "http:") { + throw new Error(t("bundle.url_must_be_https")); + } + + const segments = targetUrl.pathname.split("/").filter(Boolean); + if (segments[0] === "b" && segments[1] && segments.length === 2) { + targetUrl.pathname = `/b/${segments[1]}/data`; + targetUrl.searchParams.delete("format"); + } else if (segments[0] === "b" && segments[1] && segments[2] === "data") { + targetUrl.searchParams.delete("format"); + } + + if (!targetUrl.searchParams.has("format")) { + targetUrl.searchParams.set("format", "json"); + } + + if (serverClient) { + return parseSharedBundle(await serverClient.fetchBundle(targetUrl.toString())); + } + + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 15_000); + + try { + let response: Response; + try { + response = isTauriRuntime() + ? await tauriFetch(targetUrl.toString(), { + method: "GET", + headers: { Accept: "application/json" }, + signal: controller.signal, + }) + : await fetch(targetUrl.toString(), { + method: "GET", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + } catch (error) { + const message = error instanceof Error ? error.message : safeStringify(error); + throw new Error(`Failed to load shared bundle from ${targetUrl.toString()}: ${message}`); + } + if (!response.ok) { + const details = (await response.text()).trim(); + const suffix = details ? `: ${details}` : ""; + throw new Error(`Failed to fetch bundle from ${targetUrl.toString()} (${response.status})${suffix}`); + } + return parseSharedBundle(await response.json()); + } finally { + window.clearTimeout(timeout); + } +} + +export function buildImportPayloadFromBundle(bundle: SharedBundleV1): { + payload: Record; + importedSkillsCount: number; +} { + if (bundle.type === "skill") { + return { + payload: { + mode: { skills: "merge" }, + skills: [ + { + name: bundle.name, + description: bundle.description, + trigger: bundle.trigger, + content: bundle.content, + }, + ], + }, + importedSkillsCount: 1, + }; + } + + if (bundle.type === "skills-set") { + return { + payload: { + mode: { skills: "merge" }, + skills: bundle.skills.map((skill) => ({ + name: skill.name, + description: skill.description, + trigger: skill.trigger, + content: skill.content, + })), + }, + importedSkillsCount: bundle.skills.length, + }; + } + + const workspace = bundle.workspace; + const payload: Record = { + mode: { + opencode: "merge", + openwork: "merge", + skills: "merge", + commands: "merge", + }, + }; + if (workspace.opencode && typeof workspace.opencode === "object") payload.opencode = workspace.opencode; + if (workspace.openwork && typeof workspace.openwork === "object") payload.openwork = workspace.openwork; + if (Array.isArray(workspace.skills) && workspace.skills.length) payload.skills = workspace.skills; + if (Array.isArray(workspace.commands) && workspace.commands.length) payload.commands = workspace.commands; + if (Array.isArray(workspace.files) && workspace.files.length) payload.files = workspace.files; + + const importedSkillsCount = Array.isArray(workspace.skills) ? workspace.skills.length : 0; + return { payload, importedSkillsCount }; +} + +export function parseSharedBundleDeepLink(rawUrl: string): SharedBundleDeepLink | null { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + + const protocol = url.protocol.toLowerCase(); + if (!isSupportedDeepLinkProtocol(protocol)) { + return null; + } + + const routeHost = url.hostname.toLowerCase(); + const routePath = url.pathname.replace(/^\/+/, "").toLowerCase(); + const routeSegments = routePath.split("/").filter(Boolean); + const routeTail = routeSegments[routeSegments.length - 1] ?? ""; + const looksLikeImportRoute = + routeHost === "import-bundle" || + routePath === "import-bundle" || + routeTail === "import-bundle"; + + const rawBundleUrl = + url.searchParams.get("ow_bundle") ?? + url.searchParams.get("bundleUrl") ?? + ""; + + if (!looksLikeImportRoute && !rawBundleUrl.trim()) { + return null; + } + + try { + if ((protocol === "https:" || protocol === "http:") && !rawBundleUrl.trim()) { + const host = url.hostname.toLowerCase(); + const path = url.pathname.replace(/^\/+/, ""); + const segments = path.split("/").filter(Boolean); + if ( + (host === "share.openworklabs.com" + || host.endsWith(".openworklabs.com") + || host === "share.openwork.software" + || host.endsWith(".openwork.software")) + && segments[0] === "b" + && segments[1] + ) { + 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() ?? url.searchParams.get("label")?.trim() ?? ""; + return { + bundleUrl: url.toString(), + intent, + source: source || undefined, + orgId: orgId || undefined, + label: label || undefined, + }; + } + } + + const parsedBundleUrl = new URL(rawBundleUrl.trim()); + if (parsedBundleUrl.protocol !== "https:" && parsedBundleUrl.protocol !== "http:") { + return null; + } + 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() ?? url.searchParams.get("label")?.trim() ?? ""; + return { + bundleUrl: parsedBundleUrl.toString(), + intent, + source: source || undefined, + orgId: orgId || undefined, + label: label || undefined, + }; + } catch { + return null; + } +} + +export function stripSharedBundleQuery(rawUrl: string): string | null { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + + let changed = false; + 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; + } + } + + if (!changed) { + return null; + } + + const search = url.searchParams.toString(); + return `${url.pathname}${search ? `?${search}` : ""}${url.hash}`; +} + +export function parseRemoteConnectDeepLink(rawUrl: string): RemoteWorkspaceDefaults | null { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + + const protocol = url.protocol.toLowerCase(); + if (!isSupportedDeepLinkProtocol(protocol)) { + return null; + } + + const routeHost = url.hostname.toLowerCase(); + const routePath = url.pathname.replace(/^\/+/, "").toLowerCase(); + const routeSegments = routePath.split("/").filter(Boolean); + const routeTail = routeSegments[routeSegments.length - 1] ?? ""; + if (routeHost !== "connect-remote" && routePath !== "connect-remote" && routeTail !== "connect-remote") { + return null; + } + + const hostUrlRaw = url.searchParams.get("openworkHostUrl") ?? url.searchParams.get("openworkUrl") ?? ""; + const tokenRaw = url.searchParams.get("openworkToken") ?? url.searchParams.get("accessToken") ?? ""; + const normalizedHostUrl = normalizeOpenworkServerUrl(hostUrlRaw); + const token = tokenRaw.trim(); + if (!normalizedHostUrl || !token) { + return null; + } + + const workerName = url.searchParams.get("workerName")?.trim() ?? ""; + const workerId = url.searchParams.get("workerId")?.trim() ?? ""; + const displayName = workerName || (workerId ? `Worker ${workerId.slice(0, 8)}` : ""); + const autoConnectRaw = + url.searchParams.get("autoConnect") ?? + url.searchParams.get("bypassModal") ?? + url.searchParams.get("bypassAddWorkerModal") ?? + ""; + const autoConnect = ["1", "true", "yes", "on"].includes(autoConnectRaw.trim().toLowerCase()); + + return { + openworkHostUrl: normalizedHostUrl, + openworkToken: token, + directory: null, + displayName: displayName || null, + autoConnect, + }; +} + +export function parseDenAuthDeepLink(rawUrl: string): DenAuthDeepLink | null { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + + const protocol = url.protocol.toLowerCase(); + if (!isSupportedDeepLinkProtocol(protocol)) { + return null; + } + + const routeHost = url.hostname.toLowerCase(); + const routePath = url.pathname.replace(/^\/+/, "").toLowerCase(); + const routeSegments = routePath.split("/").filter(Boolean); + const routeTail = routeSegments[routeSegments.length - 1] ?? ""; + if (routeHost !== "den-auth" && routePath !== "den-auth" && routeTail !== "den-auth") { + return null; + } + + const grant = url.searchParams.get("grant")?.trim() ?? ""; + const denBaseUrl = normalizeDenBaseUrl(url.searchParams.get("denBaseUrl")?.trim() ?? "") ?? DEFAULT_DEN_BASE_URL; + if (!grant) { + return null; + } + + return { grant, denBaseUrl }; +} + +function normalizeDebugDeepLinkInput(rawValue: string): string { + const trimmed = rawValue.trim(); + if (!trimmed) return ""; + + const directMatch = trimmed.match(/(?:openwork-dev|openwork|https?):\/\/[^\s"'<>]+/i); + if (directMatch) return directMatch[0]; + + const bareShareMatch = trimmed.match(/share\.openwork(?:labs\.com|\.software)\/b\/[^\s"'<>]+/i); + if (bareShareMatch) return `https://${bareShareMatch[0]}`; + + return trimmed; +} + +export function parseDebugDeepLinkInput(rawValue: string): + | { kind: "bundle"; link: SharedBundleDeepLink } + | { kind: "remote"; link: RemoteWorkspaceDefaults } + | { kind: "auth"; link: DenAuthDeepLink } + | null { + const normalized = normalizeDebugDeepLinkInput(rawValue); + if (!normalized) return null; + + const denAuthLink = parseDenAuthDeepLink(normalized); + if (denAuthLink) { + return { kind: "auth", link: denAuthLink }; + } + + const sharedBundleLink = parseSharedBundleDeepLink(normalized); + if (sharedBundleLink) { + return { kind: "bundle", link: sharedBundleLink }; + } + + const remoteConnectLink = parseRemoteConnectDeepLink(normalized); + if (remoteConnectLink) { + return { kind: "remote", link: remoteConnectLink }; + } + + const bundleMatch = normalized.match(/ow_bundle=([^&\s]+)/i); + if (bundleMatch?.[1]) { + try { + const bundleUrl = decodeURIComponent(bundleMatch[1]); + const intentMatch = normalized.match(/(?:ow_intent|intent)=([^&\s]+)/i); + const labelMatch = normalized.match(/ow_label=([^&\s]+)/i); + const sourceMatch = normalized.match(/(?:ow_source|source)=([^&\s]+)/i); + return { + kind: "bundle", + link: { + bundleUrl, + intent: normalizeSharedBundleImportIntent(intentMatch?.[1] ? decodeURIComponent(intentMatch[1]) : undefined), + label: labelMatch?.[1] ? decodeURIComponent(labelMatch[1]) : undefined, + source: sourceMatch?.[1] ? decodeURIComponent(sourceMatch[1]) : undefined, + }, + }; + } catch { + // ignore fallback parsing errors + } + } + + const shareIdMatch = normalized.match(/share\.openwork(?:labs\.com|\.software)\/b\/([^\s/?#"'<>]+)/i); + if (shareIdMatch?.[1]) { + return { + kind: "bundle", + link: { + bundleUrl: `https://share.openworklabs.com/b/${shareIdMatch[1]}`, + intent: "new_worker", + }, + }; + } + + return null; +} + +export function stripRemoteConnectQuery(rawUrl: string): string | null { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + + let changed = false; + for (const key of [ + "openworkHostUrl", + "openworkUrl", + "openworkToken", + "accessToken", + "workerId", + "workerName", + "autoConnect", + "bypassModal", + "bypassAddWorkerModal", + "source", + ]) { + if (url.searchParams.has(key)) { + url.searchParams.delete(key); + changed = true; + } + } + + if (!changed) { + return null; + } + + const search = url.searchParams.toString(); + return `${url.pathname}${search ? `?${search}` : ""}${url.hash}`; +} diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index af69984cf..283b45c78 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -1284,4 +1284,76 @@ export default { "session.restart_update_title": "Restart to apply update {version}", "session.downloading_update_title": "Downloading update {version}", "session.update_available_title": "Update available {version}", + + // ==================== Automations context ==================== + "automations.server_unavailable": "OpenWork server unavailable. Connect to sync scheduled tasks.", + "automations.server_needs_token": "OpenWork server needs a token to load scheduled tasks.", + "automations.server_not_ready": "OpenWork server not ready.", + "automations.failed_to_load": "Failed to load scheduled tasks.", + "automations.desktop_required": "Scheduled tasks require the desktop app.", + + // ==================== Question Modal ==================== + "question_modal.question_counter": "Question {current} of {total}", + "question_modal.custom_answer_label": "Or type a custom answer", + "question_modal.custom_answer_placeholder": "Type your answer here...", + + // ==================== Onboarding Workspace Selector ==================== + "onboarding.empty_worker": "Empty worker", + "onboarding.empty_worker_desc": "Start with a blank folder and add what you need.", + + // ==================== Common (additions) ==================== + "common.something_went_wrong": "Something went wrong", + "common.navigate": "navigate", + "common.select": "select", + "common.submit": "Submit", + "common.next": "Next", + "common.question": "Question", + + // ==================== Shared bundle import ==================== + "bundle.import_one_skill": "Import 1 skill", + "bundle.skill_description": "Add `{name}` to an existing worker or create a new one for it.", + "bundle.import_n_skills": "Import {count} skills", + "bundle.skills_set_description": "{name} is ready to import into an existing worker or a new worker.", + "bundle.shared_skills_fallback": "Shared skills", + "bundle.open_workspace_template": "Open workspace template", + "bundle.workspace_profile_description": "{name} is ready to start in a new worker or import into an existing one.", + "bundle.workspace_template_fallback": "This shared workspace template", + "bundle.shared_workspace_profile_fallback": "Shared workspace profile", + "bundle.invalid_payload": "Invalid shared bundle payload.", + "bundle.unsupported_version": "Unsupported bundle schema version.", + "bundle.invalid_skill_payload": "Invalid skill bundle payload.", + "bundle.no_importable_skills": "Skills set bundle has no importable skills.", + "bundle.missing_workspace_payload": "Workspace profile bundle is missing workspace payload.", + "bundle.invalid_url": "Invalid shared bundle URL.", + "bundle.url_must_be_https": "Shared bundle URL must use http(s).", + + // ==================== Providers store ==================== + "providers.api_key_label": "API key", + "providers.not_connected": "Not connected to a server", + "providers.connect_failed": "Failed to connect provider", + "providers.oauth_failed": "Failed to complete OAuth", + "providers.save_api_key_failed": "Failed to save API key", + "providers.disconnect_failed": "Failed to disconnect provider", + "providers.load_failed": "Failed to load providers", + "providers.auth_failed": "Authentication failed", + "providers.rate_limit_exceeded": "Rate limit exceeded", + "providers.provider_error": "Provider error ({provider})", + "providers.request_failed": "Request failed", + "providers.api_key_required": "API key is required", + "providers.no_providers_available": "No providers available", + "providers.provider_id_required": "Provider ID is required", + "providers.unknown_provider": "Unknown provider", + "providers.no_oauth_prefix": "No OAuth flow available for", + "providers.use_api_key_suffix": "Use an API key instead.", + "providers.not_oauth_flow_prefix": "Selected auth method is not an OAuth flow for", + "providers.oauth_method_required": "OAuth method is required", + "providers.removal_unsupported": "Provider auth removal is not supported by this client.", + "providers.disconnected_prefix": "Disconnected", + "providers.disabled_in_config_suffix": "and disabled it in OpenCode config.", + "providers.still_connected_suffix": ", but the worker still reports it as connected. Clear any remaining API key or OAuth credentials and restart the worker to fully disconnect.", + + // ==================== MCP Auth Modal (additions) ==================== + "mcp.auth.authorization_link": "Authorization link", + "mcp.auth.copied": "Copied", + "mcp.auth.copy_link": "Copy link", } as const; From 13271ef7d3c3172699077e167ceb5f78d88726b0 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:16:51 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(i18n):=20second=20pass=20=E2=80=94=20e?= =?UTF-8?q?xtract=20remaining=20hardcoded=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix missed strings not covered by reference diff: - automations: schedule_required, prompt_required, prompt_empty, server_unavailable in deleteScheduledJob, failed_to_load in local block, provider_id_required (2 missed instances in store.ts) - mcp-auth-modal: request_timed_out - question-modal: Submit/Next button labels (common.submit, common.next) Co-Authored-By: Claude Sonnet 4.6 --- apps/app/src/app/components/mcp-auth-modal.tsx | 2 +- apps/app/src/app/components/question-modal.tsx | 2 +- apps/app/src/app/context/automations.ts | 12 ++++++------ apps/app/src/app/context/providers/store.ts | 4 ++-- apps/app/src/i18n/locales/en.ts | 4 ++++ 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/app/src/app/components/mcp-auth-modal.tsx b/apps/app/src/app/components/mcp-auth-modal.tsx index 37627336a..8d0b04a1b 100644 --- a/apps/app/src/app/components/mcp-auth-modal.tsx +++ b/apps/app/src/app/components/mcp-auth-modal.tsx @@ -168,7 +168,7 @@ export default function McpAuthModal(props: McpAuthModalProps) { statusPoll = window.setInterval(async () => { if (Date.now() - startedAt >= MCP_AUTH_TIMEOUT_MS) { stopStatusPolling(); - setError("Request timed out."); + setError(translate("mcp.auth.request_timed_out")); return; } diff --git a/apps/app/src/app/components/question-modal.tsx b/apps/app/src/app/components/question-modal.tsx index a01654e81..94448f56b 100644 --- a/apps/app/src/app/components/question-modal.tsx +++ b/apps/app/src/app/components/question-modal.tsx @@ -218,7 +218,7 @@ export default function QuestionModal(props: QuestionModalProps) {
-
-
-
-
- -
-
-
2
- {t("dashboard.choose_preset")} -
-
- - {(opt) => ( -
{ - if (!canContinue()) return; - setPreset(opt.id); - }} - class={`p-4 rounded-xl border cursor-pointer transition-all ${ - preset() === opt.id - ? "bg-gray-4 border-gray-6 hover:border-gray-7" - : "bg-gray-1/40 border-gray-6 hover:border-gray-7" - } ${!canContinue() ? "pointer-events-none" : ""}`.trim()} - > -
-
-
- {opt.name} -
-
{opt.desc}
-
- - - -
-
- )} -
-
-
- - - - ); -} diff --git a/apps/app/src/app/lib/shared-bundles.ts b/apps/app/src/app/lib/shared-bundles.ts deleted file mode 100644 index d334e4e1d..000000000 --- a/apps/app/src/app/lib/shared-bundles.ts +++ /dev/null @@ -1,638 +0,0 @@ -import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; - -import { DEFAULT_DEN_BASE_URL, normalizeDenBaseUrl } from "./den"; -import { - normalizeOpenworkServerUrl, - type OpenworkServerClient, - type OpenworkWorkspaceExport, -} from "./openwork-server"; -import type { WorkspacePreset } from "../types"; -import { isTauriRuntime, safeStringify } from "../utils"; -import { t } from "../../i18n"; - -export type RemoteWorkspaceDefaults = { - openworkHostUrl?: string | null; - openworkToken?: string | null; - directory?: string | null; - displayName?: string | null; - autoConnect?: boolean; -}; - -type SharedSkillItem = { - name: string; - description?: string; - content: string; - trigger?: string; -}; - -export type SharedSkillBundleV1 = { - schemaVersion: 1; - type: "skill"; - name: string; - description?: string; - trigger?: string; - content: string; -}; - -export type SharedSkillsSetBundleV1 = { - schemaVersion: 1; - type: "skills-set"; - name: string; - description?: string; - skills: SharedSkillItem[]; -}; - -export type SharedWorkspaceProfileBundleV1 = { - schemaVersion: 1; - type: "workspace-profile"; - name: string; - description?: string; - workspace: OpenworkWorkspaceExport; -}; - -export type SharedBundleV1 = - | SharedSkillBundleV1 - | SharedSkillsSetBundleV1 - | SharedWorkspaceProfileBundleV1; - -export type SharedBundleImportIntent = "new_worker" | "import_current"; - -export type SharedBundleDeepLink = { - bundleUrl: string; - intent: SharedBundleImportIntent; - source?: string; - orgId?: string; - label?: string; -}; - -export type DenAuthDeepLink = { - grant: string; - denBaseUrl: string; -}; - -export 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 isSupportedDeepLinkProtocol(protocol: string): boolean { - const normalized = protocol.toLowerCase(); - return ( - normalized === "openwork:" || - normalized === "openwork-dev:" || - normalized === "https:" || - normalized === "http:" - ); -} - -export function describeSharedBundleImport(bundle: SharedBundleV1): { - title: string; - description: string; - items: string[]; -} { - if (bundle.type === "skill") { - return { - title: t("bundle.import_one_skill"), - description: bundle.description?.trim() || t("bundle.skill_description", undefined, { name: bundle.name }), - items: [bundle.name], - }; - } - - if (bundle.type === "skills-set") { - const count = bundle.skills.length; - return { - title: t("bundle.import_n_skills", undefined, { count }), - description: - bundle.description?.trim() || - t("bundle.skills_set_description", undefined, { name: bundle.name || t("bundle.shared_skills_fallback") }), - items: bundle.skills.map((skill) => skill.name), - }; - } - - return { - title: bundle.name?.trim() || t("bundle.open_workspace_template"), - description: - bundle.description?.trim() || - t("bundle.workspace_profile_description", undefined, { name: bundle.name || t("bundle.workspace_template_fallback") }), - items: Array.isArray(bundle.workspace.skills) ? bundle.workspace.skills.map((skill) => skill.name) : [], - }; -} - -function readRecord(value: unknown): Record | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - return value as Record; -} - -function readSkillItem(value: unknown): SharedSkillItem | null { - const record = readRecord(value); - if (!record) return null; - const name = typeof record.name === "string" ? record.name.trim() : ""; - const content = typeof record.content === "string" ? record.content : ""; - if (!name || !content) return null; - return { - name, - description: typeof record.description === "string" ? record.description : undefined, - trigger: typeof record.trigger === "string" ? record.trigger : undefined, - content, - }; -} - -function readTemplateFileItem(value: unknown): { path: string; content: string } | null { - const record = readRecord(value); - if (!record) return null; - const path = typeof record.path === "string" ? record.path.trim() : ""; - const content = typeof record.content === "string" ? record.content : ""; - if (!path) return null; - return { path, content }; -} - -function readWorkspacePreset(value: unknown): WorkspacePreset { - const normalized = String(value ?? "").trim().toLowerCase(); - if (normalized === "automation" || normalized === "minimal") { - return normalized; - } - return "starter"; -} - -export function defaultPresetFromTemplateBundle(bundle: SharedWorkspaceProfileBundleV1): WorkspacePreset { - const openwork = bundle.workspace?.openwork; - if (!openwork || typeof openwork !== "object") return "starter"; - const workspace = (openwork as Record).workspace; - if (!workspace || typeof workspace !== "object") return "starter"; - return readWorkspacePreset((workspace as Record).preset); -} - -export function parseSharedBundle(value: unknown): SharedBundleV1 { - const record = readRecord(value); - if (!record) { - throw new Error(t("bundle.invalid_payload")); - } - - const schemaVersion = typeof record.schemaVersion === "number" ? record.schemaVersion : null; - const type = typeof record.type === "string" ? record.type.trim() : ""; - const name = typeof record.name === "string" ? record.name.trim() : ""; - - if (schemaVersion !== 1) { - throw new Error(t("bundle.unsupported_version")); - } - - if (type === "skill") { - const content = typeof record.content === "string" ? record.content : ""; - if (!name || !content) { - throw new Error(t("bundle.invalid_skill_payload")); - } - return { - schemaVersion: 1, - type: "skill", - name, - description: typeof record.description === "string" ? record.description : undefined, - trigger: typeof record.trigger === "string" ? record.trigger : undefined, - content, - }; - } - - if (type === "skills-set") { - const skills = Array.isArray(record.skills) - ? record.skills.map(readSkillItem).filter((item): item is SharedSkillItem => Boolean(item)) - : []; - if (!skills.length) { - throw new Error(t("bundle.no_importable_skills")); - } - return { - schemaVersion: 1, - type: "skills-set", - name: name || t("bundle.shared_skills_fallback"), - description: typeof record.description === "string" ? record.description : undefined, - skills, - }; - } - - if (type === "workspace-profile") { - const workspace = readRecord(record.workspace); - if (!workspace) { - throw new Error(t("bundle.missing_workspace_payload")); - } - const files = Array.isArray(workspace.files) - ? workspace.files.map(readTemplateFileItem).filter((item): item is { path: string; content: string } => Boolean(item)) - : []; - return { - schemaVersion: 1, - type: "workspace-profile", - name: name || t("bundle.shared_workspace_profile_fallback"), - description: typeof record.description === "string" ? record.description : undefined, - workspace: { - ...(workspace as OpenworkWorkspaceExport), - ...(files.length ? { files } : {}), - }, - }; - } - - throw new Error(`Unsupported bundle type: ${type || "unknown"}`); -} - -export async function fetchSharedBundle( - bundleUrl: string, - serverClient?: OpenworkServerClient | null, -): Promise { - let targetUrl: URL; - try { - targetUrl = new URL(bundleUrl); - } catch { - throw new Error(t("bundle.invalid_url")); - } - - if (targetUrl.protocol !== "https:" && targetUrl.protocol !== "http:") { - throw new Error(t("bundle.url_must_be_https")); - } - - const segments = targetUrl.pathname.split("/").filter(Boolean); - if (segments[0] === "b" && segments[1] && segments.length === 2) { - targetUrl.pathname = `/b/${segments[1]}/data`; - targetUrl.searchParams.delete("format"); - } else if (segments[0] === "b" && segments[1] && segments[2] === "data") { - targetUrl.searchParams.delete("format"); - } - - if (!targetUrl.searchParams.has("format")) { - targetUrl.searchParams.set("format", "json"); - } - - if (serverClient) { - return parseSharedBundle(await serverClient.fetchBundle(targetUrl.toString())); - } - - const controller = new AbortController(); - const timeout = window.setTimeout(() => controller.abort(), 15_000); - - try { - let response: Response; - try { - response = isTauriRuntime() - ? await tauriFetch(targetUrl.toString(), { - method: "GET", - headers: { Accept: "application/json" }, - signal: controller.signal, - }) - : await fetch(targetUrl.toString(), { - method: "GET", - headers: { Accept: "application/json" }, - signal: controller.signal, - }); - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - throw new Error(`Failed to load shared bundle from ${targetUrl.toString()}: ${message}`); - } - if (!response.ok) { - const details = (await response.text()).trim(); - const suffix = details ? `: ${details}` : ""; - throw new Error(`Failed to fetch bundle from ${targetUrl.toString()} (${response.status})${suffix}`); - } - return parseSharedBundle(await response.json()); - } finally { - window.clearTimeout(timeout); - } -} - -export function buildImportPayloadFromBundle(bundle: SharedBundleV1): { - payload: Record; - importedSkillsCount: number; -} { - if (bundle.type === "skill") { - return { - payload: { - mode: { skills: "merge" }, - skills: [ - { - name: bundle.name, - description: bundle.description, - trigger: bundle.trigger, - content: bundle.content, - }, - ], - }, - importedSkillsCount: 1, - }; - } - - if (bundle.type === "skills-set") { - return { - payload: { - mode: { skills: "merge" }, - skills: bundle.skills.map((skill) => ({ - name: skill.name, - description: skill.description, - trigger: skill.trigger, - content: skill.content, - })), - }, - importedSkillsCount: bundle.skills.length, - }; - } - - const workspace = bundle.workspace; - const payload: Record = { - mode: { - opencode: "merge", - openwork: "merge", - skills: "merge", - commands: "merge", - }, - }; - if (workspace.opencode && typeof workspace.opencode === "object") payload.opencode = workspace.opencode; - if (workspace.openwork && typeof workspace.openwork === "object") payload.openwork = workspace.openwork; - if (Array.isArray(workspace.skills) && workspace.skills.length) payload.skills = workspace.skills; - if (Array.isArray(workspace.commands) && workspace.commands.length) payload.commands = workspace.commands; - if (Array.isArray(workspace.files) && workspace.files.length) payload.files = workspace.files; - - const importedSkillsCount = Array.isArray(workspace.skills) ? workspace.skills.length : 0; - return { payload, importedSkillsCount }; -} - -export function parseSharedBundleDeepLink(rawUrl: string): SharedBundleDeepLink | null { - let url: URL; - try { - url = new URL(rawUrl); - } catch { - return null; - } - - const protocol = url.protocol.toLowerCase(); - if (!isSupportedDeepLinkProtocol(protocol)) { - return null; - } - - const routeHost = url.hostname.toLowerCase(); - const routePath = url.pathname.replace(/^\/+/, "").toLowerCase(); - const routeSegments = routePath.split("/").filter(Boolean); - const routeTail = routeSegments[routeSegments.length - 1] ?? ""; - const looksLikeImportRoute = - routeHost === "import-bundle" || - routePath === "import-bundle" || - routeTail === "import-bundle"; - - const rawBundleUrl = - url.searchParams.get("ow_bundle") ?? - url.searchParams.get("bundleUrl") ?? - ""; - - if (!looksLikeImportRoute && !rawBundleUrl.trim()) { - return null; - } - - try { - if ((protocol === "https:" || protocol === "http:") && !rawBundleUrl.trim()) { - const host = url.hostname.toLowerCase(); - const path = url.pathname.replace(/^\/+/, ""); - const segments = path.split("/").filter(Boolean); - if ( - (host === "share.openworklabs.com" - || host.endsWith(".openworklabs.com") - || host === "share.openwork.software" - || host.endsWith(".openwork.software")) - && segments[0] === "b" - && segments[1] - ) { - 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() ?? url.searchParams.get("label")?.trim() ?? ""; - return { - bundleUrl: url.toString(), - intent, - source: source || undefined, - orgId: orgId || undefined, - label: label || undefined, - }; - } - } - - const parsedBundleUrl = new URL(rawBundleUrl.trim()); - if (parsedBundleUrl.protocol !== "https:" && parsedBundleUrl.protocol !== "http:") { - return null; - } - 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() ?? url.searchParams.get("label")?.trim() ?? ""; - return { - bundleUrl: parsedBundleUrl.toString(), - intent, - source: source || undefined, - orgId: orgId || undefined, - label: label || undefined, - }; - } catch { - return null; - } -} - -export function stripSharedBundleQuery(rawUrl: string): string | null { - let url: URL; - try { - url = new URL(rawUrl); - } catch { - return null; - } - - let changed = false; - 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; - } - } - - if (!changed) { - return null; - } - - const search = url.searchParams.toString(); - return `${url.pathname}${search ? `?${search}` : ""}${url.hash}`; -} - -export function parseRemoteConnectDeepLink(rawUrl: string): RemoteWorkspaceDefaults | null { - let url: URL; - try { - url = new URL(rawUrl); - } catch { - return null; - } - - const protocol = url.protocol.toLowerCase(); - if (!isSupportedDeepLinkProtocol(protocol)) { - return null; - } - - const routeHost = url.hostname.toLowerCase(); - const routePath = url.pathname.replace(/^\/+/, "").toLowerCase(); - const routeSegments = routePath.split("/").filter(Boolean); - const routeTail = routeSegments[routeSegments.length - 1] ?? ""; - if (routeHost !== "connect-remote" && routePath !== "connect-remote" && routeTail !== "connect-remote") { - return null; - } - - const hostUrlRaw = url.searchParams.get("openworkHostUrl") ?? url.searchParams.get("openworkUrl") ?? ""; - const tokenRaw = url.searchParams.get("openworkToken") ?? url.searchParams.get("accessToken") ?? ""; - const normalizedHostUrl = normalizeOpenworkServerUrl(hostUrlRaw); - const token = tokenRaw.trim(); - if (!normalizedHostUrl || !token) { - return null; - } - - const workerName = url.searchParams.get("workerName")?.trim() ?? ""; - const workerId = url.searchParams.get("workerId")?.trim() ?? ""; - const displayName = workerName || (workerId ? `Worker ${workerId.slice(0, 8)}` : ""); - const autoConnectRaw = - url.searchParams.get("autoConnect") ?? - url.searchParams.get("bypassModal") ?? - url.searchParams.get("bypassAddWorkerModal") ?? - ""; - const autoConnect = ["1", "true", "yes", "on"].includes(autoConnectRaw.trim().toLowerCase()); - - return { - openworkHostUrl: normalizedHostUrl, - openworkToken: token, - directory: null, - displayName: displayName || null, - autoConnect, - }; -} - -export function parseDenAuthDeepLink(rawUrl: string): DenAuthDeepLink | null { - let url: URL; - try { - url = new URL(rawUrl); - } catch { - return null; - } - - const protocol = url.protocol.toLowerCase(); - if (!isSupportedDeepLinkProtocol(protocol)) { - return null; - } - - const routeHost = url.hostname.toLowerCase(); - const routePath = url.pathname.replace(/^\/+/, "").toLowerCase(); - const routeSegments = routePath.split("/").filter(Boolean); - const routeTail = routeSegments[routeSegments.length - 1] ?? ""; - if (routeHost !== "den-auth" && routePath !== "den-auth" && routeTail !== "den-auth") { - return null; - } - - const grant = url.searchParams.get("grant")?.trim() ?? ""; - const denBaseUrl = normalizeDenBaseUrl(url.searchParams.get("denBaseUrl")?.trim() ?? "") ?? DEFAULT_DEN_BASE_URL; - if (!grant) { - return null; - } - - return { grant, denBaseUrl }; -} - -function normalizeDebugDeepLinkInput(rawValue: string): string { - const trimmed = rawValue.trim(); - if (!trimmed) return ""; - - const directMatch = trimmed.match(/(?:openwork-dev|openwork|https?):\/\/[^\s"'<>]+/i); - if (directMatch) return directMatch[0]; - - const bareShareMatch = trimmed.match(/share\.openwork(?:labs\.com|\.software)\/b\/[^\s"'<>]+/i); - if (bareShareMatch) return `https://${bareShareMatch[0]}`; - - return trimmed; -} - -export function parseDebugDeepLinkInput(rawValue: string): - | { kind: "bundle"; link: SharedBundleDeepLink } - | { kind: "remote"; link: RemoteWorkspaceDefaults } - | { kind: "auth"; link: DenAuthDeepLink } - | null { - const normalized = normalizeDebugDeepLinkInput(rawValue); - if (!normalized) return null; - - const denAuthLink = parseDenAuthDeepLink(normalized); - if (denAuthLink) { - return { kind: "auth", link: denAuthLink }; - } - - const sharedBundleLink = parseSharedBundleDeepLink(normalized); - if (sharedBundleLink) { - return { kind: "bundle", link: sharedBundleLink }; - } - - const remoteConnectLink = parseRemoteConnectDeepLink(normalized); - if (remoteConnectLink) { - return { kind: "remote", link: remoteConnectLink }; - } - - const bundleMatch = normalized.match(/ow_bundle=([^&\s]+)/i); - if (bundleMatch?.[1]) { - try { - const bundleUrl = decodeURIComponent(bundleMatch[1]); - const intentMatch = normalized.match(/(?:ow_intent|intent)=([^&\s]+)/i); - const labelMatch = normalized.match(/ow_label=([^&\s]+)/i); - const sourceMatch = normalized.match(/(?:ow_source|source)=([^&\s]+)/i); - return { - kind: "bundle", - link: { - bundleUrl, - intent: normalizeSharedBundleImportIntent(intentMatch?.[1] ? decodeURIComponent(intentMatch[1]) : undefined), - label: labelMatch?.[1] ? decodeURIComponent(labelMatch[1]) : undefined, - source: sourceMatch?.[1] ? decodeURIComponent(sourceMatch[1]) : undefined, - }, - }; - } catch { - // ignore fallback parsing errors - } - } - - const shareIdMatch = normalized.match(/share\.openwork(?:labs\.com|\.software)\/b\/([^\s/?#"'<>]+)/i); - if (shareIdMatch?.[1]) { - return { - kind: "bundle", - link: { - bundleUrl: `https://share.openworklabs.com/b/${shareIdMatch[1]}`, - intent: "new_worker", - }, - }; - } - - return null; -} - -export function stripRemoteConnectQuery(rawUrl: string): string | null { - let url: URL; - try { - url = new URL(rawUrl); - } catch { - return null; - } - - let changed = false; - for (const key of [ - "openworkHostUrl", - "openworkUrl", - "openworkToken", - "accessToken", - "workerId", - "workerName", - "autoConnect", - "bypassModal", - "bypassAddWorkerModal", - "source", - ]) { - if (url.searchParams.has(key)) { - url.searchParams.delete(key); - changed = true; - } - } - - if (!changed) { - return null; - } - - const search = url.searchParams.toString(); - return `${url.pathname}${search ? `?${search}` : ""}${url.hash}`; -} diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 526b20e7b..4e3a815d5 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -1300,36 +1300,13 @@ export default { "question_modal.custom_answer_label": "Or type a custom answer", "question_modal.custom_answer_placeholder": "Type your answer here...", - // ==================== Onboarding Workspace Selector ==================== - "onboarding.empty_worker": "Empty worker", - "onboarding.empty_worker_desc": "Start with a blank folder and add what you need.", - // ==================== Common (additions) ==================== - "common.something_went_wrong": "Something went wrong", "common.navigate": "navigate", "common.select": "select", "common.submit": "Submit", "common.next": "Next", "common.question": "Question", - // ==================== Shared bundle import ==================== - "bundle.import_one_skill": "Import 1 skill", - "bundle.skill_description": "Add `{name}` to an existing worker or create a new one for it.", - "bundle.import_n_skills": "Import {count} skills", - "bundle.skills_set_description": "{name} is ready to import into an existing worker or a new worker.", - "bundle.shared_skills_fallback": "Shared skills", - "bundle.open_workspace_template": "Open workspace template", - "bundle.workspace_profile_description": "{name} is ready to start in a new worker or import into an existing one.", - "bundle.workspace_template_fallback": "This shared workspace template", - "bundle.shared_workspace_profile_fallback": "Shared workspace profile", - "bundle.invalid_payload": "Invalid shared bundle payload.", - "bundle.unsupported_version": "Unsupported bundle schema version.", - "bundle.invalid_skill_payload": "Invalid skill bundle payload.", - "bundle.no_importable_skills": "Skills set bundle has no importable skills.", - "bundle.missing_workspace_payload": "Workspace profile bundle is missing workspace payload.", - "bundle.invalid_url": "Invalid shared bundle URL.", - "bundle.url_must_be_https": "Shared bundle URL must use http(s).", - // ==================== Providers store ==================== "providers.api_key_label": "API key", "providers.not_connected": "Not connected to a server",