Refactor onboarding for startup preferences (#400)

* refactor(app): align onboarding with startup preferences

* chore: refresh pnpm lockfile

* feat(web): proxy OpenCode through OpenWork
This commit is contained in:
ben
2026-02-02 15:48:37 -08:00
committed by GitHub
parent 678bf888a8
commit 2695ea631a
22 changed files with 694 additions and 945 deletions

View File

@@ -84,6 +84,7 @@ setItems((current) => current.filter((x) => x.id !== id));
- Could two async actions overlap and fight over one boolean?
- Is any UI state duplicated (can be derived instead)?
- Do event handlers read signals after an `await` where values might have changed?
- If you refactor props/types, did you update all intermediate component signatures and call sites?
## References

View File

@@ -34,6 +34,7 @@ Read INFRASTRUCTURE.md
## Repository Guidance
* Write new PRDs under `packages/app/pr/<prd-name>.md` (see `.opencode/skills/prd-conventions/SKILL.md`).
* If other workspace instructions mention a different PRD location, default to this repo's guidance for OpenWork work.
* Use `VISION.md`, `PRINCIPLES.md`, `PRODUCT.md`, `ARCHITECTURE.md`, and `INFRASTRUCTURE.md` to understand the "why" and requirements so you can guide your decisions.
## Local Structure

View File

@@ -55,7 +55,7 @@ import type {
Client,
DashboardTab,
MessageWithParts,
Mode,
StartupPreference,
EngineRuntime,
ModelOption,
ModelRef,
@@ -83,7 +83,7 @@ import type {
ScheduledJob,
} from "./types";
import {
clearModePreference,
clearStartupPreference,
deriveArtifacts,
deriveWorkingFiles,
formatBytes,
@@ -102,10 +102,9 @@ import {
lastUserModelFromMessages,
// normalizeDirectoryPath,
parseModelRef,
readModePreference,
readStartupPreference,
safeStringify,
summarizeStep,
writeModePreference,
addOpencodeCacheHint,
} from "./utils";
import {
@@ -222,10 +221,10 @@ export default function App() {
navigate(`/session/${trimmed}`, options);
};
const [mode, setMode] = createSignal<Mode | null>(null);
const [startupPreference, setStartupPreference] = createSignal<StartupPreference | null>(null);
const [onboardingStep, setOnboardingStep] =
createSignal<OnboardingStep>("mode");
const [rememberModeChoice, setRememberModeChoice] = createSignal(false);
createSignal<OnboardingStep>("welcome");
const [rememberStartupChoice, setRememberStartupChoice] = createSignal(false);
const [themeMode, setThemeMode] = createSignal<ThemeMode>(getInitialThemeMode());
const [engineSource, setEngineSource] = createSignal<"path" | "sidecar">(
@@ -251,35 +250,44 @@ export default function App() {
const [openworkAuditStatus, setOpenworkAuditStatus] = createSignal<"idle" | "loading" | "error">("idle");
const [openworkAuditError, setOpenworkAuditError] = createSignal<string | null>(null);
const [devtoolsWorkspaceId, setDevtoolsWorkspaceId] = createSignal<string | null>(null);
const [hostOpenworkCapabilities, setHostOpenworkCapabilities] = createSignal<OpenworkServerCapabilities | null>(null);
const openworkServerBaseUrl = createMemo(() => {
const pref = startupPreference();
const hostInfo = openworkServerHostInfo();
const settingsUrl = normalizeOpenworkServerUrl(openworkServerSettings().urlOverride ?? "") ?? "";
if (pref === "local") return hostInfo?.baseUrl ?? "";
if (pref === "server") return settingsUrl;
return hostInfo?.baseUrl ?? settingsUrl;
});
const openworkServerAuth = createMemo(() => {
const pref = startupPreference();
const hostInfo = openworkServerHostInfo();
const settingsToken = openworkServerSettings().token?.trim() ?? "";
const clientToken = hostInfo?.clientToken?.trim() ?? "";
const hostToken = hostInfo?.hostToken?.trim() ?? "";
if (pref === "local") {
return { token: clientToken || undefined, hostToken: hostToken || undefined };
}
if (pref === "server") {
return { token: settingsToken || undefined, hostToken: undefined };
}
if (hostInfo?.baseUrl) {
return { token: clientToken || undefined, hostToken: hostToken || undefined };
}
return { token: settingsToken || undefined, hostToken: undefined };
});
const openworkServerClient = createMemo(() => {
const currentMode = mode();
if (currentMode === "client") {
const url = openworkServerUrl().trim();
if (!url) return null;
const token = openworkServerSettings().token;
return createOpenworkServerClient({ baseUrl: url, token });
}
if (currentMode === "host") {
const info = openworkServerHostInfo();
if (!info?.baseUrl) return null;
return createOpenworkServerClient({
baseUrl: info.baseUrl,
token: info.clientToken ?? undefined,
hostToken: info.hostToken ?? undefined,
});
}
return null;
const baseUrl = openworkServerBaseUrl().trim();
if (!baseUrl) return null;
const auth = openworkServerAuth();
return createOpenworkServerClient({ baseUrl, token: auth.token, hostToken: auth.hostToken });
});
const devtoolsOpenworkClient = createMemo(() => {
if (mode() === "client") return openworkServerClient();
if (mode() !== "host") return null;
const info = openworkServerHostInfo();
if (!info?.baseUrl || !info?.clientToken) return null;
return createOpenworkServerClient({ baseUrl: info.baseUrl, token: info.clientToken });
});
const devtoolsOpenworkClient = createMemo(() => openworkServerClient());
createEffect(() => {
if (typeof window === "undefined") return;
@@ -287,22 +295,24 @@ export default function App() {
});
createEffect(() => {
if (mode() === "client") {
const override = normalizeOpenworkServerUrl(openworkServerSettings().urlOverride ?? "");
setOpenworkServerUrl(override ?? "");
const pref = startupPreference();
const info = openworkServerHostInfo();
const hostUrl = info?.connectUrl ?? info?.lanUrl ?? info?.mdnsUrl ?? info?.baseUrl ?? "";
const settingsUrl = normalizeOpenworkServerUrl(openworkServerSettings().urlOverride ?? "") ?? "";
if (pref === "local") {
setOpenworkServerUrl(hostUrl);
return;
}
if (mode() === "host") {
const info = openworkServerHostInfo();
const resolved = info?.connectUrl ?? info?.lanUrl ?? info?.mdnsUrl ?? info?.baseUrl ?? "";
setOpenworkServerUrl(resolved ?? "");
if (pref === "server") {
setOpenworkServerUrl(settingsUrl);
return;
}
setOpenworkServerUrl("");
setOpenworkServerUrl(hostUrl || settingsUrl);
});
const checkOpenworkServer = async (url: string, token?: string) => {
const client = createOpenworkServerClient({ baseUrl: url, token });
const checkOpenworkServer = async (url: string, token?: string, hostToken?: string) => {
const client = createOpenworkServerClient({ baseUrl: url, token, hostToken });
try {
await client.health();
} catch (error) {
@@ -329,9 +339,10 @@ export default function App() {
createEffect(() => {
if (typeof window === "undefined") return;
if (mode() !== "client") return;
const url = openworkServerUrl().trim();
const token = openworkServerSettings().token;
const url = openworkServerBaseUrl().trim();
const auth = openworkServerAuth();
const token = auth.token;
const hostToken = auth.hostToken;
if (!url) {
setOpenworkServerStatus("disconnected");
@@ -347,7 +358,7 @@ export default function App() {
if (busy) return;
busy = true;
try {
const result = await checkOpenworkServer(url, token);
const result = await checkOpenworkServer(url, token, hostToken);
if (!active) return;
setOpenworkServerStatus(result.status);
setOpenworkServerCapabilities(result.capabilities);
@@ -366,59 +377,8 @@ export default function App() {
});
});
createEffect(() => {
if (mode() !== "host") return;
const info = openworkServerHostInfo();
const running = info?.running ?? false;
setOpenworkServerStatus(running ? "connected" : "disconnected");
setOpenworkServerCapabilities(null);
setOpenworkServerCheckedAt(Date.now());
});
createEffect(() => {
if (typeof window === "undefined") return;
if (mode() !== "host") {
setHostOpenworkCapabilities(null);
return;
}
const client = openworkServerClient();
if (!client || openworkServerStatus() !== "connected") {
setHostOpenworkCapabilities(null);
return;
}
let active = true;
let busy = false;
const run = async () => {
if (busy) return;
busy = true;
try {
const caps = await client.capabilities();
if (active) setHostOpenworkCapabilities(caps);
} catch {
if (active) setHostOpenworkCapabilities(null);
} finally {
busy = false;
}
};
run();
const interval = window.setInterval(run, 10_000);
onCleanup(() => {
active = false;
window.clearInterval(interval);
});
});
createEffect(() => {
if (!isTauriRuntime()) return;
if (mode() !== "host") {
setOpenworkServerHostInfo(null);
return;
}
let active = true;
const run = async () => {
@@ -1080,7 +1040,6 @@ export default function App() {
const extensionsStore = createExtensionsStore({
client,
mode,
projectDir: () => workspaceProjectDir(),
activeWorkspaceRoot: () => workspaceStore.activeWorkspaceRoot(),
workspaceType: () => workspaceStore.activeWorkspaceDisplay().workspaceType,
@@ -1282,11 +1241,12 @@ export default function App() {
let loadCommandsRef: (options?: { workspaceRoot?: string; quiet?: boolean }) => Promise<void> = async () => {};
const workspaceStore = createWorkspaceStore({
mode,
setMode,
startupPreference,
setStartupPreference,
onboardingStep,
setOnboardingStep,
rememberModeChoice,
rememberStartupChoice,
setRememberStartupChoice,
baseUrl,
setBaseUrl,
clientDirectory,
@@ -1330,8 +1290,14 @@ export default function App() {
});
createEffect(() => {
if (mode() !== "client") return;
const active = workspaceStore.activeWorkspaceDisplay();
const client = openworkServerClient();
if (!client || openworkServerStatus() !== "connected") {
setOpenworkServerWorkspaceId(null);
return;
}
if (active.workspaceType === "remote" && active.remoteType === "openwork") {
const storedId = active.openworkWorkspaceId ?? null;
if (storedId) {
@@ -1339,14 +1305,7 @@ export default function App() {
return;
}
const client = openworkServerClient();
if (!client || openworkServerStatus() !== "connected") {
setOpenworkServerWorkspaceId(null);
return;
}
let cancelled = false;
const resolveWorkspace = async () => {
try {
const response = await client.listWorkspaces();
@@ -1359,58 +1318,51 @@ export default function App() {
};
void resolveWorkspace();
onCleanup(() => {
cancelled = true;
});
return;
}
setOpenworkServerWorkspaceId(null);
});
createEffect(() => {
if (mode() !== "host") return;
const root = normalizeDirectoryPath(workspaceStore.activeWorkspaceRoot().trim());
const client = openworkServerClient();
if (!root || !client || openworkServerStatus() !== "connected") {
setOpenworkServerWorkspaceId(null);
return;
}
let cancelled = false;
const resolveWorkspace = async () => {
try {
const response = await client.listWorkspaces();
if (cancelled) return;
const items = Array.isArray(response.items) ? response.items : [];
const match = items.find((entry) => normalizeDirectoryPath(entry.path) === root);
setOpenworkServerWorkspaceId(response.activeId ?? match?.id ?? null);
} catch {
if (!cancelled) setOpenworkServerWorkspaceId(null);
if (active.workspaceType === "local") {
const root = normalizeDirectoryPath(workspaceStore.activeWorkspaceRoot().trim());
if (!root) {
setOpenworkServerWorkspaceId(null);
return;
}
};
void resolveWorkspace();
let cancelled = false;
const resolveWorkspace = async () => {
try {
const response = await client.listWorkspaces();
if (cancelled) return;
const items = Array.isArray(response.items) ? response.items : [];
const match = items.find((entry) => normalizeDirectoryPath(entry.path) === root);
setOpenworkServerWorkspaceId(response.activeId ?? match?.id ?? null);
} catch {
if (!cancelled) setOpenworkServerWorkspaceId(null);
}
};
onCleanup(() => {
cancelled = true;
});
void resolveWorkspace();
onCleanup(() => {
cancelled = true;
});
return;
}
setOpenworkServerWorkspaceId(null);
});
createEffect(() => {
if (!developerMode()) {
setDevtoolsWorkspaceId(null);
setHostOpenworkCapabilities(null);
return;
}
const client = devtoolsOpenworkClient();
if (!client) {
setDevtoolsWorkspaceId(null);
setHostOpenworkCapabilities(null);
return;
}
@@ -1419,15 +1371,6 @@ export default function App() {
const run = async () => {
try {
if (mode() === "host") {
try {
const caps = await client.capabilities();
if (active) setHostOpenworkCapabilities(caps);
} catch {
if (active) setHostOpenworkCapabilities(null);
}
}
const response = await client.listWorkspaces();
if (!active) return;
const items = Array.isArray(response.items) ? response.items : [];
@@ -1512,9 +1455,7 @@ export default function App() {
const openworkServerReady = createMemo(() => openworkServerStatus() === "connected");
const openworkServerWorkspaceReady = createMemo(() => Boolean(openworkServerWorkspaceId()));
const resolvedOpenworkCapabilities = createMemo(() =>
mode() === "host" ? hostOpenworkCapabilities() : openworkServerCapabilities(),
);
const resolvedOpenworkCapabilities = createMemo(() => openworkServerCapabilities());
const openworkServerCanWriteSkills = createMemo(
() =>
openworkServerReady() &&
@@ -1527,9 +1468,7 @@ export default function App() {
openworkServerWorkspaceReady() &&
(resolvedOpenworkCapabilities()?.plugins?.write ?? false),
);
const devtoolsCapabilities = createMemo(() =>
mode() === "host" ? hostOpenworkCapabilities() : openworkServerCapabilities(),
);
const devtoolsCapabilities = createMemo(() => openworkServerCapabilities());
const resolvedDevtoolsWorkspaceId = createMemo(() => devtoolsWorkspaceId() ?? openworkServerWorkspaceId());
function updateOpenworkServerSettings(next: OpenworkServerSettings) {
@@ -1550,11 +1489,24 @@ export default function App() {
setOpenworkServerCheckedAt(Date.now());
return false;
}
const result = await checkOpenworkServer(derived, next.token);
const result = await checkOpenworkServer(derived, next.token, openworkServerAuth().hostToken);
setOpenworkServerStatus(result.status);
setOpenworkServerCapabilities(result.capabilities);
setOpenworkServerCheckedAt(Date.now());
return result.status === "connected" || result.status === "limited";
const ok = result.status === "connected" || result.status === "limited";
if (ok && !isTauriRuntime()) {
const active = workspaceStore.activeWorkspaceDisplay();
const shouldAttach = !client() || active.workspaceType !== "remote" || active.remoteType !== "openwork";
if (shouldAttach) {
await workspaceStore
.createRemoteWorkspaceFlow({
openworkHostUrl: derived,
openworkToken: next.token ?? null,
})
.catch(() => undefined);
}
}
return ok;
};
const commandState = createCommandState({
@@ -1972,12 +1924,12 @@ export default function App() {
};
const canReloadViaOpenworkServer = createMemo(() => Boolean(resolveOpenworkReloadTarget()));
const canReloadLocalEngine = () =>
isTauriRuntime() && workspaceStore.activeWorkspaceDisplay().workspaceType === "local";
const canReloadWorkspace = createMemo(() => {
if (canReloadViaOpenworkServer()) return true;
if (mode() !== "host") return false;
if (!isTauriRuntime()) return false;
return true;
return canReloadLocalEngine();
});
const reloadWorkspaceEngineFromUi = async () => {
@@ -1997,7 +1949,7 @@ export default function App() {
return true;
}
}
if (mode() === "host" && isTauriRuntime()) {
if (canReloadLocalEngine()) {
return workspaceStore.reloadWorkspaceEngine();
}
throw new Error("OpenWork server reload endpoint not found. Update the host to enable reloads.");
@@ -2005,7 +1957,7 @@ export default function App() {
throw error;
}
}
if (mode() !== "host" || !isTauriRuntime()) {
if (!canReloadLocalEngine()) {
throw new Error("Reload is unavailable for this workspace.");
}
return workspaceStore.reloadWorkspaceEngine();
@@ -2013,7 +1965,6 @@ export default function App() {
const systemState = createSystemState({
client,
mode,
sessions,
sessionStatusById,
refreshPlugins,
@@ -2663,27 +2614,9 @@ export default function App() {
}
function clearOpenworkLocalStorage() {
if (typeof window === "undefined") return;
try {
const keys = Object.keys(window.localStorage);
for (const key of keys) {
if (key.startsWith("openwork.")) {
window.localStorage.removeItem(key);
}
}
// Legacy compatibility key
window.localStorage.removeItem("openwork_mode_pref");
} catch {
// ignore
}
}
async function connectNotion() {
if (mode() !== "host") {
setNotionError("Notion connections are only available in Host mode.");
if (workspaceStore.activeWorkspaceDisplay().workspaceType !== "local") {
setNotionError("Notion connections are only available for local workspaces.");
return;
}
@@ -2770,7 +2703,7 @@ export default function App() {
async function refreshMcpServers() {
const projectDir = workspaceProjectDir().trim();
const isRemoteWorkspace = workspaceStore.activeWorkspaceDisplay().workspaceType === "remote";
const isHostMode = mode() === "host";
const isLocalWorkspace = !isRemoteWorkspace;
const openworkClient = openworkServerClient();
const openworkWorkspaceId = openworkServerWorkspaceId();
const openworkCapabilities = resolvedOpenworkCapabilities();
@@ -2821,7 +2754,7 @@ export default function App() {
return;
}
if (isHostMode && canUseOpenworkServer) {
if (isLocalWorkspace && canUseOpenworkServer) {
try {
setMcpStatus(null);
const response = await openworkClient.listMcp(openworkWorkspaceId);
@@ -2856,7 +2789,7 @@ export default function App() {
}
if (!isTauriRuntime()) {
setMcpStatus("MCP configuration is only available in Host mode.");
setMcpStatus("MCP configuration is only available for local workspaces.");
setMcpServers([]);
setMcpStatuses({});
return;
@@ -2906,19 +2839,9 @@ export default function App() {
async function connectMcp(entry: (typeof MCP_QUICK_CONNECT)[number]) {
console.log("[connectMcp] called with entry:", entry);
if (mode() !== "host") {
console.log("[connectMcp] ❌ mode is not host, mode=", mode());
setMcpStatus(t("mcp.host_mode_only", currentLocale()));
return;
}
const isRemoteWorkspace = workspaceStore.activeWorkspaceDisplay().workspaceType === "remote";
const projectDir = workspaceProjectDir().trim();
console.log("[connectMcp] projectDir:", projectDir);
if (!projectDir) {
console.log("[connectMcp] ❌ no projectDir");
setMcpStatus(t("mcp.pick_workspace_first", currentLocale()));
return;
}
const openworkClient = openworkServerClient();
const openworkWorkspaceId = openworkServerWorkspaceId();
@@ -2929,6 +2852,12 @@ export default function App() {
openworkWorkspaceId &&
openworkCapabilities?.mcp?.write;
if (isRemoteWorkspace && !canUseOpenworkServer) {
console.log("[connectMcp] ❌ openwork server unavailable");
setMcpStatus("OpenWork server unavailable. MCP config is read-only.");
return;
}
if (!canUseOpenworkServer && !isTauriRuntime()) {
console.log("[connectMcp] ❌ not Tauri runtime");
setMcpStatus(t("mcp.desktop_required", currentLocale()));
@@ -2936,6 +2865,12 @@ export default function App() {
}
console.log("[connectMcp] ✓ runtime ready");
if (!isRemoteWorkspace && !projectDir) {
console.log("[connectMcp] ❌ no projectDir");
setMcpStatus(t("mcp.pick_workspace_first", currentLocale()));
return;
}
const activeClient = client();
console.log("[connectMcp] activeClient:", activeClient ? "exists" : "null");
if (!activeClient) {
@@ -3196,9 +3131,10 @@ export default function App() {
onMount(async () => {
const modePref = readModePreference();
if (modePref) {
setRememberModeChoice(true);
const startupPref = readStartupPreference();
if (startupPref) {
setRememberStartupChoice(true);
setStartupPreference(startupPref);
}
const unsubscribeTheme = subscribeToSystemTheme((isDark) => {
@@ -3566,7 +3502,7 @@ export default function App() {
createEffect(() => {
if (!isTauriRuntime()) return;
if (onboardingStep() !== "host") return;
if (onboardingStep() !== "local") return;
void workspaceStore.refreshEngineDoctor();
});
@@ -3797,11 +3733,10 @@ export default function App() {
});
const onboardingProps = () => ({
mode: mode(),
startupPreference: startupPreference(),
onboardingStep: onboardingStep(),
rememberModeChoice: rememberModeChoice(),
rememberStartupChoice: rememberStartupChoice(),
busy: busy(),
baseUrl: baseUrl(),
clientDirectory: clientDirectory(),
openworkHostUrl: openworkServerSettings().urlOverride ?? "",
openworkToken: openworkServerSettings().token ?? "",
@@ -3824,7 +3759,6 @@ export default function App() {
engineInstallLogs: engineInstallLogs(),
error: error(),
isWindows: isWindowsPlatform(),
onBaseUrlChange: setBaseUrl,
onClientDirectoryChange: setClientDirectory,
onOpenworkHostUrlChange: (value: string) =>
updateOpenworkServerSettings({
@@ -3836,17 +3770,8 @@ export default function App() {
...openworkServerSettings(),
token: value,
}),
onModeSelect: (nextMode: Mode) => {
if (nextMode === "host" && rememberModeChoice()) {
writeModePreference("host");
}
if (nextMode === "client" && rememberModeChoice()) {
writeModePreference("client");
}
setMode(nextMode);
setOnboardingStep(nextMode === "host" ? "host" : "client");
},
onRememberModeToggle: () => setRememberModeChoice((v) => !v),
onSelectStartup: workspaceStore.onSelectStartup,
onRememberStartupToggle: workspaceStore.onRememberStartupToggle,
onStartHost: workspaceStore.onStartHost,
onCreateWorkspace: workspaceStore.createWorkspaceFlow,
onPickWorkspaceFolder: workspaceStore.pickWorkspaceFolder,
@@ -3854,7 +3779,7 @@ export default function App() {
importingWorkspaceConfig: workspaceStore.importingWorkspaceConfig(),
onAttachHost: workspaceStore.onAttachHost,
onConnectClient: workspaceStore.onConnectClient,
onBackToMode: workspaceStore.onBackToMode,
onBackToWelcome: workspaceStore.onBackToWelcome,
onSetAuthorizedDir: workspaceStore.setNewAuthorizedDir,
onAddAuthorizedDir: workspaceStore.addAuthorizedDir,
onAddAuthorizedDirFromPicker: () =>
@@ -3926,7 +3851,7 @@ export default function App() {
submitProviderApiKey,
view: currentView(),
setView,
mode: mode(),
startupPreference: startupPreference(),
baseUrl: baseUrl(),
clientConnected: Boolean(client()),
busy: busy(),
@@ -4065,7 +3990,11 @@ export default function App() {
stopHost,
openResetModal,
resetModalBusy: resetModalBusy(),
onResetStartupPreference: () => clearModePreference(),
onResetStartupPreference: () => {
clearStartupPreference();
setStartupPreference(null);
setRememberStartupChoice(false);
},
themeMode: themeMode(),
setThemeMode,
pendingPermissions: pendingPermissions(),
@@ -4140,7 +4069,6 @@ export default function App() {
activeWorkspaceRoot: workspaceStore.activeWorkspaceRoot().trim(),
setWorkspaceSearch: workspaceStore.setWorkspaceSearch,
setWorkspacePickerOpen: workspaceStore.setWorkspacePickerOpen,
mode: mode(),
clientConnected: Boolean(client()),
openworkServerStatus: openworkServerStatus(),
stopHost,

View File

@@ -121,7 +121,7 @@ export function createCommandState(options: {
if (isRemoteWorkspace) {
if (draft.scope !== "workspace") {
options.setError("Global commands are only available in Host mode.");
options.setError("Global commands are only available for local workspaces.");
return;
}
if (!openworkClient || !openworkWorkspaceId || !openworkCapabilities?.commands?.write) {
@@ -242,7 +242,7 @@ export function createCommandState(options: {
if (isRemoteWorkspace) {
if (command.scope !== "workspace") {
options.setError("Global commands are only available in Host mode.");
options.setError("Global commands are only available for local workspaces.");
return;
}
if (!openworkClient || !openworkWorkspaceId || !openworkCapabilities?.commands?.write) {

View File

@@ -12,7 +12,6 @@ export default function CreateRemoteWorkspaceModal(props: {
onConfirm: (input: {
openworkHostUrl?: string | null;
openworkToken?: string | null;
opencodeBaseUrl?: string | null;
directory?: string | null;
displayName?: string | null;
}) => void;
@@ -26,11 +25,9 @@ export default function CreateRemoteWorkspaceModal(props: {
let inputRef: HTMLInputElement | undefined;
const translate = (key: string) => t(key, currentLocale());
const [remoteType, setRemoteType] = createSignal<"openwork" | "opencode">("opencode");
const [openworkHostUrl, setOpenworkHostUrl] = createSignal("");
const [openworkToken, setOpenworkToken] = createSignal("");
const [openworkTokenVisible, setOpenworkTokenVisible] = createSignal(false);
const [opencodeBaseUrl, setOpencodeBaseUrl] = createSignal("");
const [directory, setDirectory] = createSignal("");
const [displayName, setDisplayName] = createSignal("");
@@ -43,8 +40,7 @@ export default function CreateRemoteWorkspaceModal(props: {
const canSubmit = createMemo(() => {
if (submitting()) return false;
if (remoteType() === "openwork") return openworkHostUrl().trim().length > 0;
return opencodeBaseUrl().trim().length > 0;
return openworkHostUrl().trim().length > 0;
});
createEffect(() => {
@@ -55,11 +51,9 @@ export default function CreateRemoteWorkspaceModal(props: {
createEffect(() => {
if (!props.open) return;
setRemoteType("opencode");
setOpenworkHostUrl("");
setOpenworkToken("");
setOpenworkTokenVisible(false);
setOpencodeBaseUrl("");
setDirectory("");
setDisplayName("");
});
@@ -94,78 +88,38 @@ export default function CreateRemoteWorkspaceModal(props: {
</div>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-2 rounded-xl border border-gray-6 bg-gray-1/60 p-1">
<button
type="button"
onClick={() => setRemoteType("openwork")}
class={`text-xs px-3 py-2 rounded-lg transition-colors ${
remoteType() === "openwork"
? "bg-gray-12/10 text-gray-12"
: "text-gray-10 hover:text-gray-12"
}`}
>
{translate("dashboard.remote_mode_openwork_alpha")}
</button>
<button
type="button"
onClick={() => setRemoteType("opencode")}
class={`text-xs px-3 py-2 rounded-lg transition-colors ${
remoteType() === "opencode"
? "bg-gray-12/10 text-gray-12"
: "text-gray-10 hover:text-gray-12"
}`}
>
{translate("dashboard.remote_mode_direct")}
</button>
</div>
<TextInput
ref={inputRef}
label={translate("dashboard.openwork_host_label")}
placeholder={translate("dashboard.openwork_host_placeholder")}
value={openworkHostUrl()}
onInput={(event) => setOpenworkHostUrl(event.currentTarget.value)}
hint={translate("dashboard.openwork_host_hint")}
disabled={submitting()}
/>
<Show when={remoteType() === "openwork"}>
<div class="space-y-4">
<TextInput
ref={inputRef}
label={translate("dashboard.openwork_host_label")}
placeholder={translate("dashboard.openwork_host_placeholder")}
value={openworkHostUrl()}
onInput={(event) => setOpenworkHostUrl(event.currentTarget.value)}
hint={translate("dashboard.openwork_host_hint")}
<label class="block">
<div class="mb-1 text-xs font-medium text-gray-11">{translate("dashboard.openwork_host_token_label")}</div>
<div class="flex items-center gap-2">
<input
type={openworkTokenVisible() ? "text" : "password"}
value={openworkToken()}
onInput={(event) => setOpenworkToken(event.currentTarget.value)}
placeholder={translate("dashboard.openwork_host_token_placeholder")}
disabled={submitting()}
class="w-full rounded-xl bg-gray-2/60 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)] focus:outline-none focus:ring-2 focus:ring-gray-6/20"
/>
<label class="block">
<div class="mb-1 text-xs font-medium text-gray-11">{translate("dashboard.openwork_host_token_label")}</div>
<div class="flex items-center gap-2">
<input
type={openworkTokenVisible() ? "text" : "password"}
value={openworkToken()}
onInput={(event) => setOpenworkToken(event.currentTarget.value)}
placeholder={translate("dashboard.openwork_host_token_placeholder")}
disabled={submitting()}
class="w-full rounded-xl bg-gray-2/60 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)] focus:outline-none focus:ring-2 focus:ring-gray-6/20"
/>
<Button
variant="outline"
class="text-xs h-9 px-3 shrink-0"
onClick={() => setOpenworkTokenVisible((prev) => !prev)}
disabled={submitting()}
>
{openworkTokenVisible() ? translate("common.hide") : translate("common.show")}
</Button>
</div>
<div class="mt-1 text-xs text-gray-10">{translate("dashboard.openwork_host_token_hint")}</div>
</label>
<Button
variant="outline"
class="text-xs h-9 px-3 shrink-0"
onClick={() => setOpenworkTokenVisible((prev) => !prev)}
disabled={submitting()}
>
{openworkTokenVisible() ? translate("common.hide") : translate("common.show")}
</Button>
</div>
</Show>
<Show when={remoteType() === "opencode"}>
<TextInput
ref={inputRef}
label={translate("dashboard.remote_base_url_label")}
placeholder={translate("dashboard.remote_base_url_placeholder")}
value={opencodeBaseUrl()}
onInput={(event) => setOpencodeBaseUrl(event.currentTarget.value)}
disabled={submitting()}
/>
</Show>
<div class="mt-1 text-xs text-gray-10">{translate("dashboard.openwork_host_token_hint")}</div>
</label>
<TextInput
label={translate("dashboard.remote_directory_label")}
@@ -194,23 +148,14 @@ export default function CreateRemoteWorkspaceModal(props: {
<Button
onClick={() =>
props.onConfirm({
openworkHostUrl: remoteType() === "openwork" ? openworkHostUrl().trim() : null,
openworkToken: remoteType() === "openwork" ? openworkToken().trim() : null,
opencodeBaseUrl: remoteType() === "opencode" ? opencodeBaseUrl().trim() : null,
openworkHostUrl: openworkHostUrl().trim(),
openworkToken: openworkToken().trim(),
directory: directory().trim() ? directory().trim() : null,
displayName: displayName().trim() ? displayName().trim() : null,
})
}
disabled={!canSubmit()}
title={
remoteType() === "openwork"
? !openworkHostUrl().trim()
? translate("dashboard.remote_base_url_required")
: undefined
: !opencodeBaseUrl().trim()
? translate("dashboard.remote_base_url_required")
: undefined
}
title={!openworkHostUrl().trim() ? translate("dashboard.remote_base_url_required") : undefined}
>
{confirmLabel()}
</Button>

View File

@@ -4,7 +4,7 @@ import { applyEdits, modify } from "jsonc-parser";
import { join } from "@tauri-apps/api/path";
import { currentLocale, t } from "../../i18n";
import type { Client, Mode, PluginScope, ReloadReason, ReloadTrigger, SkillCard } from "../types";
import type { Client, PluginScope, ReloadReason, ReloadTrigger, SkillCard } from "../types";
import { addOpencodeCacheHint, isTauriRuntime } from "../utils";
import skillCreatorTemplate from "../data/skill-creator.md?raw";
import {
@@ -33,7 +33,6 @@ export type ExtensionsStore = ReturnType<typeof createExtensionsStore>;
export function createExtensionsStore(options: {
client: () => Client | null;
mode: () => Mode | null;
projectDir: () => string;
activeWorkspaceRoot: () => string;
workspaceType: () => "local" | "remote";
@@ -85,7 +84,7 @@ export function createExtensionsStore(options: {
async function refreshSkills(optionsOverride?: { force?: boolean }) {
const root = options.activeWorkspaceRoot().trim();
const isRemoteWorkspace = options.workspaceType() === "remote";
const isHostMode = options.mode() === "host";
const isLocalWorkspace = options.workspaceType() === "local";
const openworkClient = options.openworkServerClient();
const openworkWorkspaceId = options.openworkServerWorkspaceId();
const openworkCapabilities = options.openworkServerCapabilities();
@@ -101,8 +100,8 @@ export function createExtensionsStore(options: {
return;
}
// Host mode or remote workspace: prefer OpenWork server when available
if ((isRemoteWorkspace || isHostMode) && canUseOpenworkServer) {
// Prefer OpenWork server when available
if (canUseOpenworkServer) {
if (root !== skillsRoot) {
skillsLoaded = false;
}
@@ -121,7 +120,7 @@ export function createExtensionsStore(options: {
try {
setSkillsStatus(null);
const response = await openworkClient.listSkills(openworkWorkspaceId, {
includeGlobal: isHostMode,
includeGlobal: isLocalWorkspace,
});
if (refreshSkillsAborted) return;
const next: SkillCard[] = Array.isArray(response.items)
@@ -151,7 +150,7 @@ export function createExtensionsStore(options: {
// Host/Tauri mode fallback: read directly from `.opencode/skills` or `.claude/skills`
// so the UI still works even if the OpenCode engine is stopped or unreachable.
if (isHostMode && isTauriRuntime()) {
if (isLocalWorkspace && isTauriRuntime()) {
if (root !== skillsRoot) {
skillsLoaded = false;
}
@@ -201,11 +200,7 @@ export function createExtensionsStore(options: {
const c = options.client();
if (!c) {
setSkills([]);
setSkillsStatus(
isRemoteWorkspace
? "OpenWork server unavailable. Connect to load skills."
: translate("skills.connect_host_to_load"),
);
setSkillsStatus("OpenWork server unavailable. Connect to load skills.");
return;
}
@@ -274,7 +269,7 @@ export function createExtensionsStore(options: {
async function refreshPlugins(scopeOverride?: PluginScope) {
const isRemoteWorkspace = options.workspaceType() === "remote";
const isHostMode = options.mode() === "host";
const isLocalWorkspace = options.workspaceType() === "local";
const openworkClient = options.openworkServerClient();
const openworkWorkspaceId = options.openworkServerWorkspaceId();
const openworkCapabilities = options.openworkServerCapabilities();
@@ -295,60 +290,18 @@ export function createExtensionsStore(options: {
const scope = scopeOverride ?? pluginScope();
const targetDir = options.projectDir().trim();
// Remote workspace: use OpenWork server
if (isRemoteWorkspace) {
setPluginConfig(null);
setPluginConfigPath("opencode.json (remote)");
if (scope !== "project") {
setPluginStatus("Global plugins are only available in Host mode.");
setPluginList([]);
setSidebarPluginStatus("Switch to project scope to view remote plugins.");
setSidebarPluginList([]);
refreshPluginsInFlight = false;
return;
}
if (!canUseOpenworkServer) {
setPluginStatus("OpenWork server unavailable. Plugins are read-only.");
setPluginList([]);
setSidebarPluginStatus("Connect OpenWork server to load plugins.");
setSidebarPluginList([]);
refreshPluginsInFlight = false;
return;
}
try {
setPluginStatus(null);
setSidebarPluginStatus(null);
const result = await openworkClient.listPlugins(openworkWorkspaceId);
if (refreshPluginsAborted) return;
const configItems = result.items.filter((item) => item.source === "config");
const list = configItems.map((item) => item.spec);
setPluginList(list);
setSidebarPluginList(list);
if (!list.length) {
setPluginStatus("No plugins configured yet.");
}
} catch (e) {
if (refreshPluginsAborted) return;
setPluginList([]);
setSidebarPluginStatus("Failed to load plugins.");
setSidebarPluginList([]);
setPluginStatus(e instanceof Error ? e.message : "Failed to load plugins.");
} finally {
refreshPluginsInFlight = false;
}
if (scope !== "project" && !isLocalWorkspace) {
setPluginStatus("Global plugins are only available for local workspaces.");
setPluginList([]);
setSidebarPluginStatus("Global plugins require a local workspace.");
setSidebarPluginList([]);
refreshPluginsInFlight = false;
return;
}
// Host mode with OpenWork server: prefer server for project scope plugins
if (isHostMode && scope === "project" && canUseOpenworkServer) {
if (scope === "project" && canUseOpenworkServer) {
setPluginConfig(null);
setPluginConfigPath("opencode.json (openwork server)");
setPluginConfigPath(`opencode.json (${isRemoteWorkspace ? "remote" : "openwork"} server)`);
try {
setPluginStatus(null);
@@ -356,10 +309,10 @@ export function createExtensionsStore(options: {
if (refreshPluginsAborted) return;
const result = await openworkClient.listPlugins(openworkWorkspaceId);
const result = await openworkClient.listPlugins(openworkWorkspaceId, { includeGlobal: false });
if (refreshPluginsAborted) return;
const configItems = result.items.filter((item) => item.source === "config");
const configItems = result.items.filter((item) => item.source === "config" && item.scope === "project");
const list = configItems.map((item) => item.spec);
setPluginList(list);
setSidebarPluginList(list);
@@ -389,6 +342,15 @@ export function createExtensionsStore(options: {
return;
}
if (!isLocalWorkspace && !canUseOpenworkServer) {
setPluginStatus("OpenWork server unavailable. Connect to manage plugins.");
setPluginList([]);
setSidebarPluginStatus("Connect an OpenWork server to load plugins.");
setSidebarPluginList([]);
refreshPluginsInFlight = false;
return;
}
if (scope === "project" && !targetDir) {
setPluginStatus(translate("skills.pick_project_for_plugins"));
setPluginList([]);
@@ -447,7 +409,7 @@ export function createExtensionsStore(options: {
const triggerName = stripPluginVersion(pluginName);
const isRemoteWorkspace = options.workspaceType() === "remote";
const isHostMode = options.mode() === "host";
const isLocalWorkspace = options.workspaceType() === "local";
const openworkClient = options.openworkServerClient();
const openworkWorkspaceId = options.openworkServerWorkspaceId();
const openworkCapabilities = options.openworkServerCapabilities();
@@ -464,31 +426,12 @@ export function createExtensionsStore(options: {
return;
}
if (isRemoteWorkspace) {
if (pluginScope() !== "project") {
setPluginStatus("Global plugins are only available in Host mode.");
return;
}
if (!canUseOpenworkServer) {
setPluginStatus("OpenWork server unavailable. Connect to add plugins.");
return;
}
try {
setPluginStatus(null);
await openworkClient.addPlugin(openworkWorkspaceId, pluginName);
if (isManualInput) {
setPluginInput("");
}
await refreshPlugins("project");
} catch (e) {
setPluginStatus(e instanceof Error ? e.message : "Failed to add plugin.");
}
if (pluginScope() !== "project" && !isLocalWorkspace) {
setPluginStatus("Global plugins are only available for local workspaces.");
return;
}
// Host mode with OpenWork server: use server for project scope
if (isHostMode && pluginScope() === "project" && canUseOpenworkServer) {
if (pluginScope() === "project" && canUseOpenworkServer) {
try {
setPluginStatus(null);
await openworkClient.addPlugin(openworkWorkspaceId, pluginName);
@@ -507,6 +450,11 @@ export function createExtensionsStore(options: {
return;
}
if (!isLocalWorkspace && !canUseOpenworkServer) {
setPluginStatus("OpenWork server unavailable. Connect to manage plugins.");
return;
}
const scope = pluginScope();
const targetDir = options.projectDir().trim();
@@ -560,8 +508,15 @@ export function createExtensionsStore(options: {
}
async function importLocalSkill() {
if (options.mode() !== "host" || !isTauriRuntime()) {
options.setError(translate("skills.import_host_only"));
const isLocalWorkspace = options.workspaceType() === "local";
if (!isTauriRuntime()) {
options.setError(translate("skills.desktop_required"));
return;
}
if (!isLocalWorkspace) {
options.setError("Local workspaces are required to import skills.");
return;
}
@@ -607,7 +562,7 @@ export function createExtensionsStore(options: {
async function installSkillCreator() {
const isRemoteWorkspace = options.workspaceType() === "remote";
const isHostMode = options.mode() === "host";
const isLocalWorkspace = options.workspaceType() === "local";
const openworkClient = options.openworkServerClient();
const openworkWorkspaceId = options.openworkServerWorkspaceId();
const openworkCapabilities = options.openworkServerCapabilities();
@@ -617,8 +572,8 @@ export function createExtensionsStore(options: {
openworkWorkspaceId &&
openworkCapabilities?.skills?.write;
// Remote workspace or host mode with server: use OpenWork server
if ((isRemoteWorkspace || isHostMode) && canUseOpenworkServer) {
// Use OpenWork server when available
if (canUseOpenworkServer) {
options.setBusy(true);
options.setError(null);
setSkillsStatus(translate("skills.installing_skill_creator"));
@@ -650,8 +605,8 @@ export function createExtensionsStore(options: {
return;
}
if (options.mode() !== "host") {
options.setError(translate("skills.host_only_error"));
if (!isLocalWorkspace) {
options.setError("Local workspaces are required to install skills.");
return;
}
@@ -729,8 +684,8 @@ export function createExtensionsStore(options: {
return;
}
if (options.mode() !== "host") {
options.setError(translate("skills.host_only_error"));
if (options.workspaceType() !== "local") {
options.setError("Local workspaces are required to uninstall skills.");
return;
}

View File

@@ -2,14 +2,22 @@ import { createMemo, createSignal } from "solid-js";
import type {
Client,
Mode,
StartupPreference,
OnboardingStep,
WorkspaceDisplay,
WorkspaceOpenworkConfig,
WorkspacePreset,
EngineRuntime,
} from "../types";
import { addOpencodeCacheHint, isTauriRuntime, normalizeDirectoryPath, safeStringify, writeModePreference } from "../utils";
import {
addOpencodeCacheHint,
clearStartupPreference,
isTauriRuntime,
normalizeDirectoryPath,
readStartupPreference,
safeStringify,
writeStartupPreference,
} from "../utils";
import { unwrap } from "../lib/opencode";
import {
createOpenworkServerClient,
@@ -53,11 +61,12 @@ import { mapConfigProvidersToList } from "../utils/providers";
export type WorkspaceStore = ReturnType<typeof createWorkspaceStore>;
export function createWorkspaceStore(options: {
mode: () => Mode | null;
setMode: (mode: Mode | null) => void;
startupPreference: () => StartupPreference | null;
setStartupPreference: (value: StartupPreference | null) => void;
onboardingStep: () => OnboardingStep;
setOnboardingStep: (step: OnboardingStep) => void;
rememberModeChoice: () => boolean;
rememberStartupChoice: () => boolean;
setRememberStartupChoice: (value: boolean) => void;
baseUrl: () => string;
setBaseUrl: (value: string) => void;
clientDirectory: () => string;
@@ -198,33 +207,38 @@ export function createWorkspaceStore(options: {
} catch (error) {
if (error instanceof OpenworkServerError && (error.status === 401 || error.status === 403)) {
if (!trimmedToken) {
throw new Error("Access token required for OpenWork host.");
throw new Error("Access token required for OpenWork server.");
}
throw new Error("OpenWork host rejected the client token.");
throw new Error("OpenWork server rejected the access token.");
}
return { kind: "fallback" as const };
}
if (!trimmedToken) {
throw new Error("Access token required for OpenWork host.");
throw new Error("Access token required for OpenWork server.");
}
const response = await client.listWorkspaces();
const items = Array.isArray(response.items) ? response.items : [];
const workspace = items[0] as OpenworkWorkspaceInfo | undefined;
if (!workspace) {
throw new Error("OpenWork host did not return a workspace.");
throw new Error("OpenWork server did not return a workspace.");
}
let opencodeBaseUrl = workspace.opencode?.baseUrl?.trim() ?? workspace.baseUrl?.trim() ?? "";
if (!opencodeBaseUrl) {
throw new Error("OpenWork host did not provide an OpenCode URL.");
throw new Error("OpenWork server did not provide an OpenCode URL.");
}
const opencodeUsername = workspace.opencode?.username?.trim() ?? "";
const opencodePassword = workspace.opencode?.password?.trim() ?? "";
const opencodeAuth =
let opencodeAuth: OpencodeAuth | undefined =
opencodeUsername && opencodePassword ? { username: opencodeUsername, password: opencodePassword } : undefined;
if (!isTauriRuntime()) {
opencodeBaseUrl = `${normalized.replace(/\/+$/, "")}/opencode`;
opencodeAuth = trimmedToken ? { token: trimmedToken, mode: "openwork" } : undefined;
}
try {
const hostUrl = new URL(normalized);
const opencodeUrl = new URL(opencodeBaseUrl);
@@ -288,7 +302,8 @@ export function createWorkspaceStore(options: {
const info = await engineInfo();
setEngine(info);
const syncLocalState = options.mode() !== "client";
const isRemoteWorkspace = activeWorkspaceInfo()?.workspaceType === "remote";
const syncLocalState = !isRemoteWorkspace;
const username = info.opencodeUsername?.trim() ?? "";
const password = info.opencodePassword?.trim() ?? "";
@@ -304,7 +319,6 @@ export function createWorkspaceStore(options: {
if (
syncLocalState &&
options.mode() === "host" &&
info.running &&
info.baseUrl &&
!options.client() &&
@@ -362,12 +376,12 @@ export function createWorkspaceStore(options: {
try {
if (isRemote) {
options.setMode("client");
options.setStartupPreference("server");
if (remoteType === "openwork") {
const hostUrl = next.openworkHostUrl?.trim() ?? "";
if (!hostUrl) {
options.setError("OpenWork host URL is required.");
options.setError("OpenWork server URL is required.");
return false;
}
@@ -492,7 +506,8 @@ export function createWorkspaceStore(options: {
return true;
}
const wasHostMode = options.mode() === "host" && options.client();
const wasLocalConnection = options.startupPreference() === "local" && options.client();
options.setStartupPreference("local");
const nextRoot = isRemote ? next.directory?.trim() ?? "" : next.path;
const oldWorkspacePath = projectDir();
const workspaceChanged = oldWorkspacePath !== nextRoot;
@@ -544,7 +559,7 @@ export function createWorkspaceStore(options: {
await options.loadCommands({ workspaceRoot: next.path }).catch(() => undefined);
}
if (!isRemote && workspaceChanged && options.client() && !wasHostMode) {
if (!isRemote && workspaceChanged && options.client() && !wasLocalConnection) {
options.setSelectedSessionId(null);
options.setMessages([]);
options.setTodos([]);
@@ -553,8 +568,8 @@ export function createWorkspaceStore(options: {
await options.loadSessions(next.path).catch(() => undefined);
}
// In Host mode, restart the engine when workspace changes
if (!isRemote && wasHostMode && workspaceChanged) {
// When running locally, restart the engine when workspace changes
if (!isRemote && wasLocalConnection && workspaceChanged) {
options.setError(null);
options.setBusy(true);
options.setBusyLabel("status.restarting_engine");
@@ -829,62 +844,56 @@ export function createWorkspaceStore(options: {
async function createRemoteWorkspaceFlow(input: {
openworkHostUrl?: string | null;
openworkToken?: string | null;
opencodeBaseUrl?: string | null;
directory?: string | null;
displayName?: string | null;
}) {
const hostUrl = normalizeOpenworkServerUrl(input.openworkHostUrl ?? "") ?? "";
const token = input.openworkToken?.trim() ?? "";
const directBaseUrl = input.opencodeBaseUrl?.trim() ?? "";
const directory = input.directory?.trim() ?? "";
const displayName = input.displayName?.trim() || null;
if (!hostUrl && !directBaseUrl) {
options.setError("Add an OpenWork host URL or a direct OpenCode URL to continue.");
if (!hostUrl) {
options.setError(t("app.error.remote_base_url_required", currentLocale()));
return false;
}
options.setError(null);
console.log("[workspace] create remote", {
hostUrl: hostUrl || null,
baseUrl: directBaseUrl || null,
directory: directory || null,
displayName,
});
options.setMode("client");
options.setStartupPreference("server");
let remoteType: "openwork" | "opencode" = "opencode";
let resolvedBaseUrl = directBaseUrl;
let remoteType: "openwork" = "openwork";
let resolvedBaseUrl = "";
let resolvedDirectory = directory;
let openworkWorkspace: OpenworkWorkspaceInfo | null = null;
let resolvedAuth: OpencodeAuth | undefined = undefined;
let resolvedHostUrl = hostUrl;
if (hostUrl) {
options.updateOpenworkServerSettings({
...options.openworkServerSettings(),
urlOverride: hostUrl,
token: token || undefined,
});
options.updateOpenworkServerSettings({
...options.openworkServerSettings(),
urlOverride: hostUrl,
token: token || undefined,
});
try {
const resolved = await resolveOpenworkHost({ hostUrl, token });
if (resolved.kind === "openwork") {
remoteType = "openwork";
resolvedBaseUrl = resolved.opencodeBaseUrl;
resolvedDirectory = resolved.directory || directory;
openworkWorkspace = resolved.workspace;
resolvedHostUrl = resolved.hostUrl;
resolvedAuth = resolved.auth;
} else if (!resolvedBaseUrl) {
resolvedBaseUrl = hostUrl;
}
} catch (error) {
const message = error instanceof Error ? error.message : safeStringify(error);
options.setError(addOpencodeCacheHint(message));
try {
const resolved = await resolveOpenworkHost({ hostUrl, token });
if (resolved.kind !== "openwork") {
options.setError("OpenWork server unavailable. Check the URL and token.");
return false;
}
resolvedBaseUrl = resolved.opencodeBaseUrl;
resolvedDirectory = resolved.directory || directory;
openworkWorkspace = resolved.workspace;
resolvedHostUrl = resolved.hostUrl;
resolvedAuth = resolved.auth;
} catch (error) {
const message = error instanceof Error ? error.message : safeStringify(error);
options.setError(addOpencodeCacheHint(message));
return false;
}
if (!resolvedBaseUrl) {
@@ -1243,8 +1252,8 @@ export function createWorkspaceStore(options: {
options.setSessionStatusById({});
options.setSseConnected(false);
options.setMode(null);
options.setOnboardingStep("mode");
options.setStartupPreference(null);
options.setOnboardingStep("welcome");
options.setView("session");
} catch (e) {
@@ -1263,8 +1272,8 @@ export function createWorkspaceStore(options: {
return false;
}
if (options.mode() !== "host") {
options.setError("Reload is only available in Host mode.");
if (activeWorkspaceDisplay().workspaceType !== "local") {
options.setError("Reload is only available for local workspaces.");
return false;
}
@@ -1501,13 +1510,7 @@ export function createWorkspaceStore(options: {
}
async function bootstrapOnboarding() {
const modePref = (() => {
try {
return window.localStorage.getItem("openwork.modePref") as Mode | null;
} catch {
return null;
}
})();
const startupPref = readStartupPreference();
const onboardingComplete = (() => {
try {
return window.localStorage.getItem("openwork.onboardingComplete") === "1";
@@ -1568,29 +1571,38 @@ export function createWorkspaceStore(options: {
const activeWorkspace = activeWorkspaceInfo();
if (activeWorkspace?.workspaceType === "remote") {
options.setMode("client");
options.setStartupPreference("server");
options.setOnboardingStep("connecting");
const ok = await activateWorkspace(activeWorkspace.id);
if (!ok) {
options.setOnboardingStep("client");
options.setOnboardingStep("server");
}
return;
}
if (!modePref && activeWorkspacePath().trim()) {
options.setMode("host");
if (startupPref) {
options.setStartupPreference(startupPref);
}
if (startupPref === "server") {
options.setOnboardingStep("server");
return;
}
if (activeWorkspacePath().trim()) {
options.setStartupPreference("local");
if (info?.running && info.baseUrl) {
options.setOnboardingStep("connecting");
const ok = await connectToServer(
info.baseUrl,
info.projectDir ?? undefined,
{ reason: "bootstrap-host" },
{ reason: "bootstrap-local" },
engineAuth() ?? undefined,
);
if (!ok) {
options.setMode(null);
options.setOnboardingStep("mode");
options.setStartupPreference(null);
options.setOnboardingStep("welcome");
return;
}
markOnboardingComplete();
@@ -1600,135 +1612,85 @@ export function createWorkspaceStore(options: {
options.setOnboardingStep("connecting");
const ok = await startHost({ workspacePath: activeWorkspacePath().trim() });
if (!ok) {
options.setOnboardingStep("host");
options.setOnboardingStep("local");
return;
}
markOnboardingComplete();
return;
}
if (!modePref) return;
if (modePref === "host") {
options.setMode("host");
if (info?.running && info.baseUrl) {
options.setOnboardingStep("connecting");
const ok = await connectToServer(
info.baseUrl,
info.projectDir ?? undefined,
{ reason: "bootstrap-host" },
engineAuth() ?? undefined,
);
if (!ok) {
options.setMode(null);
options.setOnboardingStep("mode");
}
return;
}
if (isTauriRuntime() && activeWorkspacePath().trim()) {
if (!authorizedDirs().length && activeWorkspacePath().trim()) {
setAuthorizedDirs([activeWorkspacePath().trim()]);
}
options.setOnboardingStep("connecting");
const ok = await startHost({ workspacePath: activeWorkspacePath().trim() });
if (!ok) {
options.setOnboardingStep("host");
}
return;
}
options.setOnboardingStep("host");
if (startupPref === "local") {
options.setOnboardingStep("local");
return;
}
options.setMode("client");
if (!options.baseUrl().trim()) {
options.setOnboardingStep("client");
return;
}
options.setOnboardingStep("connecting");
const ok = await connectToServer(
options.baseUrl().trim(),
options.clientDirectory().trim() ? options.clientDirectory().trim() : undefined,
{ reason: "client-connect" },
);
if (!ok) {
options.setOnboardingStep("client");
}
options.setOnboardingStep("welcome");
}
function onModeSelect(nextMode: Mode) {
if (nextMode === "host" && options.rememberModeChoice()) {
writeModePreference("host");
function onSelectStartup(nextPref: StartupPreference) {
if (options.rememberStartupChoice()) {
writeStartupPreference(nextPref);
}
if (nextMode === "client" && options.rememberModeChoice()) {
writeModePreference("client");
}
options.setMode(nextMode);
options.setOnboardingStep(nextMode === "host" ? "host" : "client");
options.setStartupPreference(nextPref);
options.setOnboardingStep(nextPref === "local" ? "local" : "server");
}
function onBackToMode() {
options.setMode(null);
options.setOnboardingStep("mode");
function onBackToWelcome() {
options.setStartupPreference(null);
options.setOnboardingStep("welcome");
}
async function onStartHost() {
options.setMode("host");
options.setStartupPreference("local");
options.setOnboardingStep("connecting");
const ok = await startHost({ workspacePath: activeWorkspacePath().trim() });
if (!ok) {
options.setOnboardingStep("host");
options.setOnboardingStep("local");
}
}
async function onAttachHost() {
options.setMode("host");
options.setStartupPreference("local");
options.setOnboardingStep("connecting");
const ok = await connectToServer(
engine()?.baseUrl ?? "",
engine()?.projectDir ?? undefined,
{ reason: "attach-host" },
{ reason: "attach-local" },
engineAuth() ?? undefined,
);
if (!ok) {
options.setMode(null);
options.setOnboardingStep("mode");
options.setStartupPreference(null);
options.setOnboardingStep("welcome");
}
}
async function onConnectClient() {
options.setMode("client");
options.setStartupPreference("server");
options.setOnboardingStep("connecting");
const settings = options.openworkServerSettings();
const ok = await createRemoteWorkspaceFlow({
openworkHostUrl: settings.urlOverride ?? null,
openworkToken: settings.token ?? null,
opencodeBaseUrl: options.baseUrl().trim(),
directory: options.clientDirectory().trim() ? options.clientDirectory().trim() : null,
displayName: null,
});
if (!ok) {
options.setOnboardingStep("client");
options.setOnboardingStep("server");
}
}
function onRememberModeToggle() {
function onRememberStartupToggle() {
if (typeof window === "undefined") return;
const next = !options.rememberModeChoice();
const next = !options.rememberStartupChoice();
options.setRememberStartupChoice(next);
try {
if (next) {
const current = options.mode();
if (current === "host" || current === "client") {
writeModePreference(current);
const current = options.startupPreference();
if (current === "local" || current === "server") {
writeStartupPreference(current);
}
} else {
window.localStorage.removeItem("openwork.modePref");
clearStartupPreference();
}
} catch {
// ignore
@@ -1783,12 +1745,12 @@ export function createWorkspaceStore(options: {
stopHost,
reloadWorkspaceEngine,
bootstrapOnboarding,
onModeSelect,
onBackToMode,
onSelectStartup,
onBackToWelcome,
onStartHost,
onAttachHost,
onConnectClient,
onRememberModeToggle,
onRememberStartupToggle,
onInstallEngine,
addAuthorizedDir,
addAuthorizedDirFromPicker,

View File

@@ -10,6 +10,8 @@ type FieldsResult<T> =
export type OpencodeAuth = {
username?: string;
password?: string;
token?: string;
mode?: "basic" | "openwork";
};
const encodeBasicAuth = (auth?: OpencodeAuth) => {
@@ -21,11 +23,19 @@ const encodeBasicAuth = (auth?: OpencodeAuth) => {
return buffer ? buffer.from(token, "utf8").toString("base64") : null;
};
const createTauriFetch = (auth?: OpencodeAuth) => {
const resolveAuthHeader = (auth?: OpencodeAuth) => {
if (auth?.mode === "openwork" && auth.token) {
return `Bearer ${auth.token}`;
}
const encoded = encodeBasicAuth(auth);
return encoded ? `Basic ${encoded}` : null;
};
const createTauriFetch = (auth?: OpencodeAuth) => {
const authHeader = resolveAuthHeader(auth);
const addAuth = (headers: Headers) => {
if (!encoded || headers.has("Authorization")) return;
headers.set("Authorization", `Basic ${encoded}`);
if (!authHeader || headers.has("Authorization")) return;
headers.set("Authorization", authHeader);
};
return (input: RequestInfo | URL, init?: RequestInit) => {
@@ -61,9 +71,9 @@ export function unwrap<T>(result: FieldsResult<T>): NonNullable<T> {
export function createClient(baseUrl: string, directory?: string, auth?: OpencodeAuth) {
const headers: Record<string, string> = {};
if (!isTauriRuntime()) {
const encoded = encodeBasicAuth(auth);
if (encoded) {
headers.Authorization = `Basic ${encoded}`;
const authHeader = resolveAuthHeader(auth);
if (authHeader) {
headers.Authorization = authHeader;
}
}

View File

@@ -9,6 +9,7 @@ import type {
SettingsTab,
ScheduledJob,
SkillCard,
StartupPreference,
WorkspaceCommand,
View,
} from "../types";
@@ -63,7 +64,7 @@ export type DashboardViewProps = {
submitProviderApiKey: (providerId: string, apiKey: string) => Promise<string | void>;
view: View;
setView: (view: View, sessionId?: string) => void;
mode: "host" | "client" | null;
startupPreference: StartupPreference | null;
baseUrl: string;
clientConnected: boolean;
busy: boolean;
@@ -84,6 +85,7 @@ export type DashboardViewProps = {
openworkAuditError: string | null;
opencodeConnectStatus: OpencodeConnectStatus | null;
engineInfo: EngineInfo | null;
engineDoctorVersion: string | null;
openwrkStatus: OpenwrkStatus | null;
owpenbotInfo: OwpenbotInfo | null;
updateOpenworkServerSettings: (next: OpenworkServerSettings) => void;
@@ -436,7 +438,12 @@ export default function DashboardView(props: DashboardViewProps) {
<div class="space-y-4">
<Show when={props.mode === "client" && props.openworkServerStatus === "disconnected"}>
<Show
when={
props.activeWorkspaceDisplay.workspaceType === "remote" &&
props.openworkServerStatus === "disconnected"
}
>
<div class="text-[11px] text-gray-9 px-1">
OpenWork server is offline remote tasks still run.
</div>
@@ -842,7 +849,6 @@ export default function DashboardView(props: DashboardViewProps) {
<Match when={props.tab === "skills"}>
<SkillsView
busy={props.busy}
mode={props.mode}
canInstallSkillCreator={props.canInstallSkillCreator}
canUseDesktopTools={props.canUseDesktopTools}
accessHint={props.skillsAccessHint}
@@ -881,7 +887,6 @@ export default function DashboardView(props: DashboardViewProps) {
<Match when={props.tab === "mcp"}>
<McpView
mode={props.mode}
busy={props.busy}
activeWorkspaceRoot={props.activeWorkspaceRoot}
mcpServers={props.mcpServers}
@@ -901,7 +906,7 @@ export default function DashboardView(props: DashboardViewProps) {
<Match when={props.tab === "settings"}>
<SettingsView
mode={props.mode}
startupPreference={props.startupPreference}
baseUrl={props.baseUrl}
headerStatus={props.headerStatus}
busy={props.busy}
@@ -925,6 +930,7 @@ export default function DashboardView(props: DashboardViewProps) {
engineInfo={props.engineInfo}
openwrkStatus={props.openwrkStatus}
owpenbotInfo={props.owpenbotInfo}
engineDoctorVersion={props.engineDoctorVersion}
updateOpenworkServerSettings={props.updateOpenworkServerSettings}
resetOpenworkServerSettings={props.resetOpenworkServerSettings}
testOpenworkServerConnection={props.testOpenworkServerConnection}

View File

@@ -24,7 +24,6 @@ import TextInput from "../components/text-input";
import { currentLocale, t, type Language } from "../../i18n";
export type McpViewProps = {
mode: "host" | "client" | null;
busy: boolean;
activeWorkspaceRoot: string;
mcpServers: McpServerEntry[];
@@ -178,8 +177,7 @@ export default function McpView(props: McpViewProps) {
return status?.status === "connected";
};
const canConnect = (entry: McpDirectoryInfo) =>
props.mode === "host" && isTauriRuntime() && !props.busy && !!props.activeWorkspaceRoot.trim();
const canConnect = () => !props.busy;
return (
<section class="space-y-6">
@@ -263,7 +261,7 @@ export default function McpView(props: McpViewProps) {
<Button
variant="secondary"
onClick={() => props.connectMcp(entry)}
disabled={!canConnect(entry) || props.mcpConnectingName === entry.name}
disabled={!canConnect() || props.mcpConnectingName === entry.name}
>
{props.mcpConnectingName === entry.name ? (
<>

View File

@@ -1,5 +1,5 @@
import { For, Match, Show, Switch, createSignal } from "solid-js";
import type { Mode, OnboardingStep } from "../types";
import type { OnboardingStep, StartupPreference } from "../types";
import type { WorkspaceInfo } from "../lib/tauri";
import { CheckCircle2, ChevronDown, Circle, Globe } from "lucide-solid";
@@ -11,11 +11,10 @@ import { isTauriRuntime, isWindowsPlatform } from "../utils/index";
import { currentLocale, t } from "../../i18n";
export type OnboardingViewProps = {
mode: Mode | null;
startupPreference: StartupPreference | null;
onboardingStep: OnboardingStep;
rememberModeChoice: boolean;
rememberStartupChoice: boolean;
busy: boolean;
baseUrl: string;
clientDirectory: string;
openworkHostUrl: string;
openworkToken: string;
@@ -38,12 +37,11 @@ export type OnboardingViewProps = {
error: string | null;
developerMode: boolean;
isWindows: boolean;
onBaseUrlChange: (value: string) => void;
onClientDirectoryChange: (value: string) => void;
onOpenworkHostUrlChange: (value: string) => void;
onOpenworkTokenChange: (value: string) => void;
onModeSelect: (mode: Mode) => void;
onRememberModeToggle: () => void;
onSelectStartup: (mode: StartupPreference) => void;
onRememberStartupToggle: () => void;
onStartHost: () => void;
onCreateWorkspace: (preset: "starter" | "automation" | "minimal", folder: string | null) => void;
onPickWorkspaceFolder: () => Promise<string | null>;
@@ -51,7 +49,7 @@ export type OnboardingViewProps = {
importingWorkspaceConfig: boolean;
onAttachHost: () => void;
onConnectClient: () => void;
onBackToMode: () => void;
onBackToWelcome: () => void;
onSetAuthorizedDir: (value: string) => void;
onAddAuthorizedDir: () => void;
onAddAuthorizedDirFromPicker: () => void;
@@ -103,10 +101,12 @@ export default function OnboardingView(props: OnboardingViewProps) {
</div>
<div class="text-center">
<h2 class="text-xl font-medium mb-2">
{props.mode === "host" ? translate("onboarding.starting_host") : translate("onboarding.searching_host")}
{props.startupPreference === "local"
? translate("onboarding.starting_host")
: translate("onboarding.searching_host")}
</h2>
<p class="text-gray-10 text-sm">
{props.mode === "host"
{props.startupPreference === "local"
? translate("onboarding.getting_ready")
: translate("onboarding.verifying")}
</p>
@@ -116,7 +116,7 @@ export default function OnboardingView(props: OnboardingViewProps) {
</div>
</Match>
<Match when={props.onboardingStep === "host"}>
<Match when={props.onboardingStep === "local"}>
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-1 text-gray-12 p-6 relative">
<div class="absolute top-0 left-0 w-full h-96 bg-gradient-to-b from-gray-2 to-transparent opacity-20 pointer-events-none" />
@@ -222,7 +222,7 @@ export default function OnboardingView(props: OnboardingViewProps) {
{translate("onboarding.start")}
</Button>
<Button variant="ghost" onClick={props.onBackToMode} disabled={props.busy} class="w-full">
<Button variant="ghost" onClick={props.onBackToWelcome} disabled={props.busy} class="w-full">
{translate("onboarding.back")}
</Button>
@@ -422,7 +422,7 @@ export default function OnboardingView(props: OnboardingViewProps) {
</div>
</Match>
<Match when={props.onboardingStep === "client"}>
<Match when={props.onboardingStep === "server"}>
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-1 text-gray-12 p-6 relative">
<div class="absolute top-0 left-0 w-full h-96 bg-gradient-to-b from-gray-2 to-transparent opacity-20 pointer-events-none" />
@@ -447,11 +447,34 @@ export default function OnboardingView(props: OnboardingViewProps) {
<div class="space-y-4">
<TextInput
label={translate("dashboard.remote_base_url_label")}
placeholder={translate("dashboard.remote_base_url_placeholder")}
value={props.baseUrl}
onInput={(e) => props.onBaseUrlChange(e.currentTarget.value)}
label={translate("dashboard.openwork_host_label")}
placeholder={translate("dashboard.openwork_host_placeholder")}
value={props.openworkHostUrl}
onInput={(e) => props.onOpenworkHostUrlChange(e.currentTarget.value)}
hint={translate("dashboard.openwork_host_hint")}
/>
<label class="block">
<div class="mb-1 text-xs font-medium text-gray-11">{translate("dashboard.openwork_host_token_label")}</div>
<div class="flex items-center gap-2">
<input
type={openworkTokenVisible() ? "text" : "password"}
value={props.openworkToken}
onInput={(e) => props.onOpenworkTokenChange(e.currentTarget.value)}
placeholder={translate("dashboard.openwork_host_token_placeholder")}
disabled={props.busy}
class="w-full rounded-xl bg-gray-2/60 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)] focus:outline-none focus:ring-2 focus:ring-gray-6/20"
/>
<Button
variant="outline"
class="text-xs h-9 px-3 shrink-0"
onClick={() => setOpenworkTokenVisible((prev) => !prev)}
disabled={props.busy}
>
{openworkTokenVisible() ? translate("common.hide") : translate("common.show")}
</Button>
</div>
<div class="mt-1 text-xs text-gray-10">{translate("dashboard.openwork_host_token_hint")}</div>
</label>
<TextInput
label={translate("dashboard.remote_directory_label")}
placeholder={translate("dashboard.remote_directory_placeholder")}
@@ -461,55 +484,15 @@ export default function OnboardingView(props: OnboardingViewProps) {
/>
</div>
<details class="rounded-2xl border border-gray-6 bg-gray-1/60 px-4 py-3">
<summary class="flex items-center justify-between cursor-pointer text-xs text-gray-10">
{translate("onboarding.advanced_openwork_host")}
<ChevronDown size={14} class="text-gray-7" />
</summary>
<div class="pt-3 space-y-3">
<div class="text-xs text-gray-10">{translate("onboarding.advanced_openwork_hint")}</div>
<TextInput
label={translate("dashboard.openwork_host_label")}
placeholder={translate("dashboard.openwork_host_placeholder")}
value={props.openworkHostUrl}
onInput={(e) => props.onOpenworkHostUrlChange(e.currentTarget.value)}
hint={translate("dashboard.openwork_host_hint")}
/>
<label class="block">
<div class="mb-1 text-xs font-medium text-gray-11">{translate("dashboard.openwork_host_token_label")}</div>
<div class="flex items-center gap-2">
<input
type={openworkTokenVisible() ? "text" : "password"}
value={props.openworkToken}
onInput={(e) => props.onOpenworkTokenChange(e.currentTarget.value)}
placeholder={translate("dashboard.openwork_host_token_placeholder")}
disabled={props.busy}
class="w-full rounded-xl bg-gray-2/60 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)] focus:outline-none focus:ring-2 focus:ring-gray-6/20"
/>
<Button
variant="outline"
class="text-xs h-9 px-3 shrink-0"
onClick={() => setOpenworkTokenVisible((prev) => !prev)}
disabled={props.busy}
>
{openworkTokenVisible() ? translate("common.hide") : translate("common.show")}
</Button>
</div>
<div class="mt-1 text-xs text-gray-10">{translate("dashboard.openwork_host_token_hint")}</div>
</label>
</div>
</details>
<Button
onClick={props.onConnectClient}
disabled={props.busy || (!props.baseUrl.trim() && !props.openworkHostUrl.trim())}
disabled={props.busy || !props.openworkHostUrl.trim()}
class="w-full py-3 text-base"
>
{translate("onboarding.remote_workspace_action")}
</Button>
<Button variant="ghost" onClick={props.onBackToMode} disabled={props.busy} class="w-full">
<Button variant="ghost" onClick={props.onBackToWelcome} disabled={props.busy} class="w-full">
{translate("onboarding.back")}
</Button>
@@ -540,7 +523,7 @@ export default function OnboardingView(props: OnboardingViewProps) {
<div class="space-y-4">
<button
onClick={() => props.onModeSelect("host")}
onClick={() => props.onSelectStartup("local")}
class="group w-full relative bg-gray-2 hover:bg-gray-4 border border-gray-6 hover:border-gray-7 p-6 md:p-8 rounded-3xl text-left transition-all duration-300 hover:shadow-2xl hover:shadow-indigo-6/10 hover:-translate-y-0.5 flex items-start gap-6"
>
<div class="shrink-0 w-14 h-14 rounded-2xl bg-gradient-to-br from-indigo-7/20 to-purple-7/20 flex items-center justify-center border border-indigo-7/20 group-hover:border-indigo-7/40 transition-colors">
@@ -578,7 +561,7 @@ export default function OnboardingView(props: OnboardingViewProps) {
</Show>
<button
onClick={() => props.onModeSelect("client")}
onClick={() => props.onSelectStartup("server")}
class="group w-full relative bg-gray-2 hover:bg-gray-4 border border-gray-6 hover:border-gray-7 p-6 md:p-8 rounded-3xl text-left transition-all duration-300 hover:shadow-2xl hover:shadow-gray-12/10 hover:-translate-y-0.5 flex items-start gap-6"
>
<div class="shrink-0 w-14 h-14 rounded-2xl bg-gradient-to-br from-gray-7/20 to-gray-5/10 flex items-center justify-center border border-gray-6 group-hover:border-gray-7 transition-colors">
@@ -596,17 +579,17 @@ export default function OnboardingView(props: OnboardingViewProps) {
<div class="flex items-center gap-2 px-2 py-1">
<button
onClick={props.onRememberModeToggle}
onClick={props.onRememberStartupToggle}
class="flex items-center gap-2 text-xs text-gray-10 hover:text-gray-11 transition-colors group"
>
<div
class={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${
props.rememberModeChoice
props.rememberStartupChoice
? "bg-indigo-7 border-indigo-7 text-gray-12"
: "border-gray-7 bg-transparent group-hover:border-gray-7"
}`}
>
<Show when={props.rememberModeChoice}>
<Show when={props.rememberStartupChoice}>
<CheckCircle2 size={10} />
</Show>
</div>

View File

@@ -75,22 +75,22 @@ export default function ScheduledTasksView(props: ScheduledTasksViewProps) {
});
const sourceDescription = createMemo(() =>
props.source === "remote"
? "Automations that run on a schedule from the connected OpenWork host."
? "Automations that run on a schedule from the connected OpenWork server."
: "Automations that run on a schedule from this device."
);
const sourceLabel = createMemo(() =>
props.source === "remote" ? "From OpenWork host" : "From local scheduler"
props.source === "remote" ? "From OpenWork server" : "From local scheduler"
);
const schedulerLabel = createMemo(() => (props.source === "remote" ? "OpenWork host" : "Local"));
const schedulerLabel = createMemo(() => (props.source === "remote" ? "OpenWork server" : "Local"));
const schedulerHint = createMemo(() =>
props.source === "remote" ? "Remote instance" : "Launchd or systemd"
);
const schedulerUnavailableHint = createMemo(() =>
props.source === "remote" ? "OpenWork host unavailable" : "Desktop-only"
props.source === "remote" ? "OpenWork server unavailable" : "Desktop-only"
);
const deleteDescription = createMemo(() =>
props.source === "remote"
? "This removes the schedule and deletes the job definition from the connected OpenWork host."
? "This removes the schedule and deletes the job definition from the connected OpenWork server."
: "This removes the schedule and deletes the job definition from your machine."
);

View File

@@ -48,7 +48,6 @@ export type SessionViewProps = {
activeWorkspaceRoot: string;
setWorkspaceSearch: (value: string) => void;
setWorkspacePickerOpen: (open: boolean) => void;
mode: "host" | "client" | null;
clientConnected: boolean;
openworkServerStatus: OpenworkServerStatus;
stopHost: () => void;

View File

@@ -6,7 +6,7 @@ import Button from "../components/button";
import TextInput from "../components/text-input";
import SettingsKeybinds, { type KeybindSetting } from "../components/settings-keybinds";
import { ChevronDown, HardDrive, MessageCircle, PlugZap, RefreshCcw, Shield, Smartphone, X } from "lucide-solid";
import type { OpencodeConnectStatus, ProviderListItem, SettingsTab } from "../types";
import type { OpencodeConnectStatus, ProviderListItem, SettingsTab, StartupPreference } from "../types";
import { createOpenworkServerClient } from "../lib/openwork-server";
import type {
OpenworkAuditEntry,
@@ -41,7 +41,7 @@ import {
} from "../lib/tauri";
export type SettingsViewProps = {
mode: "host" | "client" | null;
startupPreference: StartupPreference | null;
baseUrl: string;
headerStatus: string;
busy: boolean;
@@ -127,7 +127,6 @@ export type SettingsViewProps = {
// Owpenbot Settings Component
function OwpenbotSettings(props: {
busy: boolean;
mode: "host" | "client" | null;
openworkServerStatus: OpenworkServerStatus;
openworkServerUrl: string;
openworkServerSettings: OpenworkServerSettings;
@@ -157,7 +156,7 @@ function OwpenbotSettings(props: {
const hostToken = props.openworkServerHostInfo?.hostToken?.trim() ?? "";
const clientToken = props.openworkServerHostInfo?.clientToken?.trim() ?? "";
const settingsToken = props.openworkServerSettings.token?.trim() ?? "";
const token = props.mode === "host" ? clientToken : settingsToken;
const token = clientToken || settingsToken;
if (!baseUrl || !token || !props.openworkServerWorkspaceId) return null;
return createOpenworkServerClient({ baseUrl, token, hostToken });
});
@@ -310,9 +309,10 @@ function OwpenbotSettings(props: {
setOwpenbotStatus(latestStatus);
}
const serverClient = openworkServerClient();
const useRemote = Boolean(serverClient && props.openworkServerWorkspaceId);
const workspaceId = props.openworkServerWorkspaceId;
const useRemote = Boolean(serverClient && workspaceId);
debugOwpenbot("save-token:start", {
mode: props.mode ?? "unknown",
connection: props.openworkServerHostInfo ? "local" : "remote",
tauri: isTauriRuntime(),
useRemote,
openworkServerStatus: props.openworkServerStatus,
@@ -320,13 +320,10 @@ function OwpenbotSettings(props: {
openworkServerWorkspaceId: props.openworkServerWorkspaceId,
owpenbotHealthPort: latestStatus?.healthPort ?? owpenbotStatus()?.healthPort ?? null,
hasToken: Boolean(
(props.mode === "host"
? props.openworkServerHostInfo?.clientToken?.trim()
: props.openworkServerSettings.token?.trim())
?? false,
(props.openworkServerHostInfo?.clientToken?.trim() || props.openworkServerSettings.token?.trim()) ?? false,
),
});
if (useRemote) {
if (useRemote && serverClient && workspaceId) {
if (props.openworkServerStatus === "disconnected") {
setTelegramFeedback(
"error",
@@ -344,7 +341,7 @@ function OwpenbotSettings(props: {
setTelegramFeedback("checking", "Saving token on the host...");
try {
await serverClient.setOwpenbotTelegramToken(
props.openworkServerWorkspaceId,
workspaceId,
token,
latestStatus?.healthPort ?? owpenbotStatus()?.healthPort ?? null,
);
@@ -814,8 +811,6 @@ export default function SettingsView(props: SettingsViewProps) {
const [openworkTokenVisible, setOpenworkTokenVisible] = createSignal(false);
const [clientTokenVisible, setClientTokenVisible] = createSignal(false);
const [hostTokenVisible, setHostTokenVisible] = createSignal(false);
const [opencodeUserVisible, setOpencodeUserVisible] = createSignal(false);
const [opencodePasswordVisible, setOpencodePasswordVisible] = createSignal(false);
const [copyingField, setCopyingField] = createSignal<string | null>(null);
let copyTimeout: number | undefined;
@@ -960,6 +955,14 @@ export default function SettingsView(props: SettingsViewProps) {
return "bg-green-7/10 text-green-11 border-green-7/20";
});
const isLocalEngineRunning = createMemo(() => Boolean(props.engineInfo?.running));
const isLocalPreference = createMemo(() => props.startupPreference === "local");
const startupLabel = createMemo(() => {
if (props.startupPreference === "local") return "Start local server";
if (props.startupPreference === "server") return "Connect to server";
return "Not set";
});
const tabLabel = (tab: SettingsTab) => {
switch (tab) {
case "model":
@@ -1104,8 +1107,6 @@ export default function SettingsView(props: SettingsViewProps) {
return info?.connectUrl ?? info?.mdnsUrl ?? info?.lanUrl ?? info?.baseUrl ?? "";
});
const hostConnectUrlUsesMdns = createMemo(() => hostConnectUrl().includes(".local"));
const opencodeUsername = createMemo(() => props.engineInfo?.opencodeUsername?.trim() ?? "");
const opencodePassword = createMemo(() => props.engineInfo?.opencodePassword?.trim() ?? "");
const handleCopy = async (value: string, field: string) => {
if (!value) return;
@@ -1198,14 +1199,14 @@ export default function SettingsView(props: SettingsViewProps) {
<Shield size={16} />
{props.developerMode ? "Disable Developer Mode" : "Enable Developer Mode"}
</Button>
<Show when={props.mode === "host"}>
<Show when={isLocalEngineRunning()}>
<Button variant="danger" onClick={props.stopHost} disabled={props.busy}>
Stop engine
Stop local server
</Button>
</Show>
<Show when={props.mode === "client"}>
<Show when={!isLocalEngineRunning() && props.openworkServerStatus === "connected"}>
<Button variant="outline" onClick={props.stopHost} disabled={props.busy}>
Disconnect
Disconnect server
</Button>
</Show>
</div>
@@ -1448,14 +1449,14 @@ export default function SettingsView(props: SettingsViewProps) {
<div class="flex items-center gap-3">
<div
class={`p-2 rounded-lg ${
props.mode === "host" ? "bg-indigo-7/10 text-indigo-11" : "bg-green-7/10 text-green-11"
isLocalPreference() ? "bg-indigo-7/10 text-indigo-11" : "bg-green-7/10 text-green-11"
}`}
>
<Show when={props.mode === "host"} fallback={<Smartphone size={18} />}>
<Show when={isLocalPreference()} fallback={<Smartphone size={18} />}>
<HardDrive size={18} />
</Show>
</div>
<span class="capitalize text-sm font-medium text-gray-12">{props.mode} mode</span>
<span class="text-sm font-medium text-gray-12">{startupLabel()}</span>
</div>
<Button variant="outline" class="text-xs h-8 py-0 px-3" onClick={props.stopHost} disabled={props.busy}>
Switch
@@ -1463,12 +1464,12 @@ export default function SettingsView(props: SettingsViewProps) {
</div>
<Button variant="secondary" class="w-full justify-between group" onClick={props.onResetStartupPreference}>
<span class="text-gray-11">Reset default startup mode</span>
<span class="text-gray-11">Reset startup preference</span>
<RefreshCcw size={14} class="text-gray-10 group-hover:rotate-180 transition-transform" />
</Button>
<p class="text-xs text-gray-7">
This clears your saved preference and shows mode selection on next launch.
This clears your saved preference and shows the connection choice on next launch.
</p>
</div>
@@ -1478,7 +1479,7 @@ export default function SettingsView(props: SettingsViewProps) {
<div class="text-xs text-gray-10">Power options for the engine and reset actions.</div>
</div>
<Show when={isTauriRuntime() && props.mode === "host"}>
<Show when={isTauriRuntime() && isLocalPreference()}>
<div class="space-y-3">
<div class="text-xs text-gray-10">Engine source</div>
<div class="grid grid-cols-2 gap-2">
@@ -1567,14 +1568,14 @@ export default function SettingsView(props: SettingsViewProps) {
<Match when={activeTab() === "remote"}>
<div class="space-y-6">
<Show when={props.mode === "host"}>
<Show when={hostInfo()}>
<div class="space-y-4">
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<div class="text-sm font-medium text-gray-12">OpenWork host pairing</div>
<div class="text-sm font-medium text-gray-12">OpenWork server sharing</div>
<div class="text-xs text-gray-10">
Share these details with a trusted device. Keep the host on the same network for the fastest setup.
Share these details with a trusted device. Keep the server on the same network for the fastest setup.
</div>
</div>
<div class={`text-xs px-2 py-1 rounded-full border ${hostStatusStyle()}`}>
@@ -1609,7 +1610,7 @@ export default function SettingsView(props: SettingsViewProps) {
<div class="flex items-center justify-between bg-gray-1 p-3 rounded-xl border border-gray-6 gap-3">
<div class="min-w-0">
<div class="text-xs font-medium text-gray-11">Client token</div>
<div class="text-xs font-medium text-gray-11">Access token</div>
<div class="text-xs text-gray-7 font-mono truncate">
{clientTokenVisible()
? hostInfo()?.clientToken || "—"
@@ -1617,7 +1618,7 @@ export default function SettingsView(props: SettingsViewProps) {
? "••••••••••••"
: "—"}
</div>
<div class="text-[11px] text-gray-8 mt-1">Use on phones or laptops connecting to this host.</div>
<div class="text-[11px] text-gray-8 mt-1">Use on phones or laptops connecting to this server.</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
@@ -1641,7 +1642,7 @@ export default function SettingsView(props: SettingsViewProps) {
<div class="flex items-center justify-between bg-gray-1 p-3 rounded-xl border border-gray-6 gap-3">
<div class="min-w-0">
<div class="text-xs font-medium text-gray-11">Host token</div>
<div class="text-xs font-medium text-gray-11">Server token</div>
<div class="text-xs text-gray-7 font-mono truncate">
{hostTokenVisible()
? hostInfo()?.hostToken || "—"
@@ -1649,7 +1650,7 @@ export default function SettingsView(props: SettingsViewProps) {
? "••••••••••••"
: "—"}
</div>
<div class="text-[11px] text-gray-8 mt-1">Keep private. Required for host approvals.</div>
<div class="text-[11px] text-gray-8 mt-1">Keep private. Required for approval actions.</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
@@ -1673,183 +1674,96 @@ export default function SettingsView(props: SettingsViewProps) {
</div>
</div>
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
<div>
<div class="text-sm font-medium text-gray-12">OpenCode direct access</div>
<div class="text-xs text-gray-10">
Use these credentials when a client connects directly to OpenCode without an OpenWork host.
</div>
</div>
<div class="grid gap-3">
<div class="flex items-center justify-between bg-gray-1 p-3 rounded-xl border border-gray-6 gap-3">
<div class="min-w-0">
<div class="text-xs font-medium text-gray-11">OpenCode username</div>
<div class="text-xs text-gray-7 font-mono truncate">
{opencodeUserVisible()
? opencodeUsername() || "—"
: opencodeUsername()
? "••••••••"
: "—"}
</div>
<div class="text-[11px] text-gray-8 mt-1">Use with the password when connecting directly.</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
variant="outline"
class="text-xs h-8 py-0 px-3"
onClick={() => setOpencodeUserVisible((prev) => !prev)}
disabled={!opencodeUsername()}
>
{opencodeUserVisible() ? "Hide" : "Show"}
</Button>
<Button
variant="outline"
class="text-xs h-8 py-0 px-3"
onClick={() => handleCopy(opencodeUsername(), "opencode-user")}
disabled={!opencodeUsername()}
>
{copyingField() === "opencode-user" ? "Copied" : "Copy"}
</Button>
</div>
</div>
<div class="flex items-center justify-between bg-gray-1 p-3 rounded-xl border border-gray-6 gap-3">
<div class="min-w-0">
<div class="text-xs font-medium text-gray-11">OpenCode password</div>
<div class="text-xs text-gray-7 font-mono truncate">
{opencodePasswordVisible()
? opencodePassword() || "—"
: opencodePassword()
? "••••••••"
: "—"}
</div>
<div class="text-[11px] text-gray-8 mt-1">Keep private. Required for direct OpenCode access.</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
variant="outline"
class="text-xs h-8 py-0 px-3"
onClick={() => setOpencodePasswordVisible((prev) => !prev)}
disabled={!opencodePassword()}
>
{opencodePasswordVisible() ? "Hide" : "Show"}
</Button>
<Button
variant="outline"
class="text-xs h-8 py-0 px-3"
onClick={() => handleCopy(opencodePassword(), "opencode-pass")}
disabled={!opencodePassword()}
>
{copyingField() === "opencode-pass" ? "Copied" : "Copy"}
</Button>
</div>
</div>
</div>
</div>
</div>
</Show>
<Show when={props.mode === "client"}>
<div class="space-y-4">
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<div class="flex items-center gap-2">
<div class="text-sm font-medium text-gray-12">OpenWork host</div>
<span class="text-[10px] uppercase tracking-wide px-2 py-0.5 rounded-full bg-amber-7/10 text-amber-11 border border-amber-7/30">
Alpha
</span>
</div>
<div class="text-xs text-gray-10">
Connect to a host running OpenWork on another device. Use the host URL and client token from pairing.
</div>
</div>
<div class={`text-xs px-2 py-1 rounded-full border ${openworkStatusStyle()}`}>
{openworkStatusLabel()}
</div>
</div>
<div class="grid gap-3">
<TextInput
label="OpenWork host URL"
value={openworkUrl()}
onInput={(event) => setOpenworkUrl(event.currentTarget.value)}
placeholder="http://127.0.0.1:8787"
hint="Use the host URL shared during pairing."
disabled={props.busy}
/>
<label class="block">
<div class="mb-1 text-xs font-medium text-gray-11">Client token</div>
<div class="flex items-center gap-2">
<input
type={openworkTokenVisible() ? "text" : "password"}
value={openworkToken()}
onInput={(event) => setOpenworkToken(event.currentTarget.value)}
placeholder="Paste your token"
disabled={props.busy}
class="w-full rounded-xl bg-gray-2/60 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)] focus:outline-none focus:ring-2 focus:ring-gray-6/20"
/>
<Button
variant="outline"
class="text-xs h-9 px-3 shrink-0"
onClick={() => setOpenworkTokenVisible((prev) => !prev)}
disabled={props.busy}
>
{openworkTokenVisible() ? "Hide" : "Show"}
</Button>
</div>
<div class="mt-1 text-xs text-gray-10">Optional. Paste the client token from the host to pair.</div>
</label>
</div>
<div class="text-[11px] text-gray-7 font-mono truncate">
Resolved host: {props.openworkServerUrl || "Not set"}
</div>
<div class="flex flex-wrap gap-2">
<Button
variant="secondary"
onClick={async () => {
const next = buildOpenworkSettings();
props.updateOpenworkServerSettings(next);
await props.testOpenworkServerConnection(next);
}}
disabled={props.busy}
>
Test connection
</Button>
<Button
variant="outline"
onClick={() => props.updateOpenworkServerSettings(buildOpenworkSettings())}
disabled={props.busy || !hasOpenworkChanges()}
>
Save
</Button>
<Button
variant="ghost"
onClick={props.resetOpenworkServerSettings}
disabled={props.busy}
>
Clear
</Button>
</div>
</div>
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-3">
<div class="space-y-4">
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<div class="text-sm font-medium text-gray-12">OpenCode direct</div>
<div class="text-sm font-medium text-gray-12">OpenWork server</div>
<div class="text-xs text-gray-10">
Use this only when no OpenWork host is available. Manage direct engine connections from the workspace picker.
Connect to an OpenWork server. Use the URL and access token from your server admin.
</div>
</div>
<div class="text-[11px] text-gray-7 font-mono truncate">
Current engine: {props.baseUrl || "Not connected"}
<div class={`text-xs px-2 py-1 rounded-full border ${openworkStatusStyle()}`}>
{openworkStatusLabel()}
</div>
</div>
<div class="grid gap-3">
<TextInput
label="OpenWork server URL"
value={openworkUrl()}
onInput={(event) => setOpenworkUrl(event.currentTarget.value)}
placeholder="http://127.0.0.1:8787"
hint="Use the URL shared by your OpenWork server."
disabled={props.busy}
/>
<label class="block">
<div class="mb-1 text-xs font-medium text-gray-11">Access token</div>
<div class="flex items-center gap-2">
<input
type={openworkTokenVisible() ? "text" : "password"}
value={openworkToken()}
onInput={(event) => setOpenworkToken(event.currentTarget.value)}
placeholder="Paste your token"
disabled={props.busy}
class="w-full rounded-xl bg-gray-2/60 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)] focus:outline-none focus:ring-2 focus:ring-gray-6/20"
/>
<Button
variant="outline"
class="text-xs h-9 px-3 shrink-0"
onClick={() => setOpenworkTokenVisible((prev) => !prev)}
disabled={props.busy}
>
{openworkTokenVisible() ? "Hide" : "Show"}
</Button>
</div>
<div class="mt-1 text-xs text-gray-10">Optional. Paste the access token to authenticate.</div>
</label>
</div>
<div class="text-[11px] text-gray-7 font-mono truncate">
Resolved server: {openworkUrl().trim() || "Not set"}
</div>
<div class="flex flex-wrap gap-2">
<Button
variant="secondary"
onClick={async () => {
const next = buildOpenworkSettings();
props.updateOpenworkServerSettings(next);
await props.testOpenworkServerConnection(next);
}}
disabled={props.busy}
>
Test connection
</Button>
<Button
variant="outline"
onClick={() => props.updateOpenworkServerSettings(buildOpenworkSettings())}
disabled={props.busy || !hasOpenworkChanges()}
>
Save
</Button>
<Button
variant="ghost"
onClick={props.resetOpenworkServerSettings}
disabled={props.busy}
>
Reset
</Button>
</div>
<Show when={openworkStatusLabel() !== "Connected"}>
<div class="text-xs text-gray-9">
OpenWork server connection needed to sync skills, plugins, and commands.
</div>
</Show>
</div>
</Show>
</div>
</div>
</Match>
@@ -1857,7 +1771,6 @@ export default function SettingsView(props: SettingsViewProps) {
<div class="space-y-6">
<OwpenbotSettings
busy={props.busy}
mode={props.mode}
openworkServerStatus={props.openworkServerStatus}
openworkServerUrl={props.openworkServerUrl}
openworkServerSettings={props.openworkServerSettings}

View File

@@ -8,7 +8,6 @@ import { currentLocale, t } from "../../i18n";
export type SkillsViewProps = {
busy: boolean;
mode: "host" | "client" | null;
canInstallSkillCreator: boolean;
canUseDesktopTools: boolean;
accessHint?: string | null;
@@ -56,7 +55,6 @@ export default function SkillsView(props: SkillsViewProps) {
<Show
when={
!props.accessHint &&
props.mode !== "host" &&
!props.canInstallSkillCreator &&
!props.canUseDesktopTools
}

View File

@@ -8,7 +8,6 @@ import { relaunch } from "@tauri-apps/plugin-process";
import type {
Client,
Mode,
PluginScope,
ReloadReason,
ReloadTrigger,
@@ -32,7 +31,6 @@ export type NotionState = {
export function createSystemState(options: {
client: Accessor<Client | null>;
mode: Accessor<Mode | null>;
sessions: Accessor<Session[]>;
sessionStatusById: Accessor<Record<string, string>>;
refreshPlugins: (scopeOverride?: PluginScope) => Promise<void>;
@@ -210,9 +208,8 @@ export function createSystemState(options: {
if (reloadBusy()) return false;
const override = options.canReloadWorkspaceEngine?.();
if (override === true) return true;
if (!options.client()) return false;
if (override === false) return false;
if (options.mode() !== "host") return false;
if (!options.client()) return false;
return true;
});
@@ -230,10 +227,6 @@ export function createSystemState(options: {
setReloadError("Reload is unavailable for this workspace.");
return;
}
if (override !== true && options.mode() !== "host") {
setReloadError("Reload is only available in Host mode.");
return;
}
// if (anyActiveRuns()) {
// setReloadError("Waiting for active tasks to complete before reloading.");

View File

@@ -91,11 +91,11 @@ export type OpencodeEvent = {
export type View = "onboarding" | "dashboard" | "session" | "proto";
export type Mode = "host" | "client";
export type StartupPreference = "local" | "server";
export type EngineRuntime = "direct" | "openwrk";
export type OnboardingStep = "mode" | "host" | "client" | "connecting";
export type OnboardingStep = "welcome" | "local" | "server" | "connecting";
export type DashboardTab =
| "home"

View File

@@ -85,23 +85,22 @@ export function isWindowsPlatform() {
return /windows/i.test(platform) || /windows/i.test(ua);
}
export function readModePreference(): "host" | "client" | null {
const STARTUP_PREF_KEY = "openwork.startupPref";
const LEGACY_PREF_KEY = "openwork.modePref";
const LEGACY_PREF_KEY_ALT = "openwork_mode_pref";
export function readStartupPreference(): "local" | "server" | null {
if (typeof window === "undefined") return null;
try {
const pref =
window.localStorage.getItem("openwork.modePref") ??
window.localStorage.getItem("openwork_mode_pref");
window.localStorage.getItem(STARTUP_PREF_KEY) ??
window.localStorage.getItem(LEGACY_PREF_KEY) ??
window.localStorage.getItem(LEGACY_PREF_KEY_ALT);
if (pref === "host" || pref === "client") {
// Migrate legacy key if needed.
try {
window.localStorage.setItem("openwork.modePref", pref);
} catch {
// ignore
}
return pref;
}
if (pref === "local" || pref === "server") return pref;
if (pref === "host") return "local";
if (pref === "client") return "server";
} catch {
// ignore
}
@@ -109,24 +108,25 @@ export function readModePreference(): "host" | "client" | null {
return null;
}
export function writeModePreference(nextMode: "host" | "client") {
export function writeStartupPreference(nextPref: "local" | "server") {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem("openwork.modePref", nextMode);
// Keep legacy key for now.
window.localStorage.setItem("openwork_mode_pref", nextMode);
window.localStorage.setItem(STARTUP_PREF_KEY, nextPref);
window.localStorage.removeItem(LEGACY_PREF_KEY);
window.localStorage.removeItem(LEGACY_PREF_KEY_ALT);
} catch {
// ignore
}
}
export function clearModePreference() {
export function clearStartupPreference() {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem("openwork.modePref");
window.localStorage.removeItem("openwork_mode_pref");
window.localStorage.removeItem(STARTUP_PREF_KEY);
window.localStorage.removeItem(LEGACY_PREF_KEY);
window.localStorage.removeItem(LEGACY_PREF_KEY_ALT);
} catch {
// ignore
}

View File

@@ -51,21 +51,21 @@ export default {
"dashboard.create_workspace_subtitle": "Initialize a new folder-based workspace.",
"dashboard.create_workspace_confirm": "Create Workspace",
"dashboard.create_remote_workspace_title": "Add Remote Workspace",
"dashboard.create_remote_workspace_subtitle": "Save an OpenWork host or OpenCode engine as a workspace.",
"dashboard.create_remote_workspace_subtitle": "Save an OpenWork server as a workspace.",
"dashboard.create_remote_workspace_confirm": "Add Workspace",
"dashboard.remote_workspace_title": "Remote workspace",
"dashboard.remote_workspace_hint": "Track an OpenWork host or OpenCode engine and reconnect anytime.",
"dashboard.remote_base_url_label": "OpenCode URL",
"dashboard.remote_base_url_placeholder": "http://127.0.0.1:4096",
"dashboard.remote_workspace_hint": "Track an OpenWork server and reconnect anytime.",
"dashboard.remote_base_url_label": "OpenWork server URL",
"dashboard.remote_base_url_placeholder": "http://127.0.0.1:8787",
"dashboard.remote_base_url_required": "Add a server URL to continue.",
"dashboard.openwork_host_label": "OpenWork host URL",
"dashboard.openwork_host_placeholder": "https://your-host.openwork.app",
"dashboard.openwork_host_hint": "Use the URL shared by your OpenWork host.",
"dashboard.openwork_host_token_label": "Client token",
"dashboard.openwork_host_label": "OpenWork server URL",
"dashboard.openwork_host_placeholder": "https://your-server.openwork.app",
"dashboard.openwork_host_hint": "Use the URL shared by your OpenWork server.",
"dashboard.openwork_host_token_label": "Access token",
"dashboard.openwork_host_token_placeholder": "Paste your token",
"dashboard.openwork_host_token_hint": "Optional. Paste the client token from the host to pair.",
"dashboard.remote_mode_openwork_alpha": "OpenWork host (alpha)",
"dashboard.remote_mode_direct": "OpenCode direct (recommended)",
"dashboard.openwork_host_token_hint": "Optional. Paste the access token from the server to authenticate.",
"dashboard.remote_mode_openwork_alpha": "OpenWork server",
"dashboard.remote_mode_direct": "Direct (legacy)",
"dashboard.remote_connection_openwork": "OpenWork",
"dashboard.remote_connection_direct": "Direct",
"dashboard.remote_directory_label": "Workspace directory (optional)",
@@ -197,7 +197,7 @@ export default {
"skills.add_title": "Add skills",
"skills.add_description": "Install a starter command, import a skill, or open the folder.",
"skills.install_from_openpackage": "Install from OpenPackage",
"skills.host_mode_only": "Host mode only",
"skills.host_mode_only": "Local workspace only",
"skills.install": "Install",
"skills.installed_label": "Installed",
"skills.install_hint": "Installs OpenPackage packages into the current workspace. Skills should land in `.opencode/skills`.",
@@ -213,7 +213,7 @@ export default {
"skills.installed": "Installed skills",
"skills.no_skills": "No skills detected in `.opencode/skills` or `.claude/skills`.",
"skills.desktop_required": "Skill management requires the desktop app.",
"skills.host_only_error": "Skill management is only available in Host mode.",
"skills.host_only_error": "Skill management requires a local workspace or connected OpenWork server.",
"skills.install_skill_creator": "Install skill creator",
"skills.install_skill_creator_hint": "This skill allows you to create other skills from within the chat.",
"skills.installing_skill_creator": "Installing skill creator...",
@@ -233,13 +233,13 @@ export default {
"skills.notion_crm_title": "Notion CRM Enrichment Skills",
"skills.notion_crm_description": "Add enrichment workflows for contacts, pipelines, and follow-ups.",
"skills.notion_crm_card_description": "Enrich Notion CRM data with ready-made skills.",
"skills.connect_host_to_load": "Connect to a host to load skills.",
"skills.connect_host_to_load": "Connect an OpenWork server to load skills.",
"skills.pick_workspace_first": "Pick a workspace folder first.",
"skills.no_skills_found": "No skills found yet.",
"skills.installed_description": "Skills available in this workspace.",
"skills.failed_to_load": "Failed to load skills",
"skills.plugin_management_host_only": "Plugin management is only available in Host mode.",
"skills.plugins_host_only": "Plugins are only available in Host mode.",
"skills.plugin_management_host_only": "Plugin management requires the desktop app.",
"skills.plugins_host_only": "Plugins are only available in the desktop app.",
"skills.pick_project_for_plugins": "Pick a project folder to manage project plugins.",
"skills.pick_project_for_active": "Pick a project folder to load active plugins.",
"skills.no_opencode_found": "No opencode.json found yet. Add a plugin to create one.",
@@ -250,13 +250,13 @@ export default {
"skills.enter_plugin_name": "Enter a plugin package name.",
"skills.plugin_already_listed": "Plugin already listed in opencode.json.",
"skills.failed_update_opencode": "Failed to update opencode.json",
"skills.opackage_install_host_only": "OpenPackage installs are only available in Host mode.",
"skills.opackage_install_host_only": "OpenPackage installs require the desktop app.",
"skills.pick_project_first": "Pick a project folder first.",
"skills.enter_opackage_source": "Enter an OpenPackage source (e.g. github:anthropics/claude-code).",
"skills.installing_opackage": "Installing OpenPackage...",
"skills.install_complete": "Installed.",
"skills.curated_list_notice": "This is a curated list, not an OpenPackage yet. Copy the link or watch the PRD for planned registry search integration.",
"skills.import_host_only": "Skill import is only available in Host mode.",
"skills.import_host_only": "Skill import requires the desktop app.",
"skills.select_skill_folder": "Select skill folder",
"skills.import_failed": "Import failed ({status})",
"skills.imported": "Imported.",
@@ -373,7 +373,7 @@ export default {
"mcp.status_disabled": "Disabled",
"mcp.disconnected": "Disconnected",
"mcp.failed": "Failed",
"mcp.host_mode_only": "MCP connections are only available in Host mode.",
"mcp.host_mode_only": "MCP connections require the desktop app.",
"mcp.pick_workspace_first": "Pick a workspace folder first.",
"mcp.desktop_required": "MCP connections require the desktop app.",
"mcp.connect_server_first": "Connect to the OpenCode server first.",
@@ -422,7 +422,7 @@ export default {
"mcp.auth.reauth_running": "Reauthorizing...",
"mcp.auth.reauth_failed": "Reauthorization failed.",
"mcp.auth.reauth_cli_hint": "Run: opencode mcp auth {server}",
"mcp.auth.reauth_remote_hint": "Reauthorize from the host machine where this workspace runs.",
"mcp.auth.reauth_remote_hint": "Reauthorize from the machine running this workspace.",
"mcp.auth.authorization_still_required": "Authorization is still required. Try again to restart the flow.",
"mcp.auth.oauth_not_supported_hint": "This could mean:\n• The MCP server doesn't advertise OAuth capabilities\n• The engine needs to reload to discover server capabilities\n• Try: opencode mcp auth {server} from the CLI",
"mcp.auth.try_reload_engine": "{message}. Try reloading the engine first.",
@@ -581,13 +581,13 @@ export default {
"reload.toast_reload_stopped": "Reload & Stop Tasks",
"reload.toast_reloading": "Reloading...",
"reload.toast_dismiss": "Later",
"reload.toast_blocked_host": "Reloading is only available in Host mode.",
"reload.toast_blocked_host": "Reloading is only available for local workspaces.",
"reload.toast_blocked_connect": "Connect to this workspace to reload.",
"reload.toast_blocked_runs": "Waiting for active tasks to complete before reloading.",
// ==================== Onboarding ====================
"onboarding.starting_host": "Starting OpenWork...",
"onboarding.searching_host": "Searching for Host...",
"onboarding.starting_host": "Starting OpenWork server...",
"onboarding.searching_host": "Connecting to OpenWork server...",
"onboarding.getting_ready": "Getting everything ready",
"onboarding.verifying": "Verifying secure handshake",
"onboarding.create_first_workspace": "Create your first workspace",
@@ -605,10 +605,10 @@ export default {
"onboarding.cli_ready": "OpenCode CLI ready.",
"onboarding.cli_version": "OpenCode {version}",
"onboarding.windows_install_instruction": "Install OpenCode for Windows, then restart OpenWork. Ensure opencode.exe is on PATH.",
"onboarding.install_instruction": "Install OpenCode to enable host mode (no terminal required).",
"onboarding.install_instruction": "Install OpenCode to enable the local server (no terminal required).",
"onboarding.install": "Install OpenCode",
"onboarding.recheck": "Re-check",
"onboarding.ready_message": "OpenCode is ready to start in host mode.",
"onboarding.ready_message": "OpenCode is ready to start the local server.",
"onboarding.resolved_path": "Resolved path",
"onboarding.version": "Version",
"onboarding.search_notes": "Search notes",
@@ -630,7 +630,7 @@ export default {
"onboarding.remove": "Remove",
"onboarding.cli_label": "OpenCode CLI",
"onboarding.cli_checking": "Checking install...",
"onboarding.cli_not_found_hint": "Not found. Install to run Host mode.",
"onboarding.cli_not_found_hint": "Not found. Install to run the local server.",
"onboarding.cli_version_installed": "Installed",
"onboarding.cli_recheck": "Re-check",
"onboarding.cli_install_commands": "Install OpenCode with one of the commands below, then restart OpenWork.",
@@ -638,23 +638,23 @@ export default {
"onboarding.last_checked": "Last checked {time}",
"onboarding.server_url_placeholder": "http://localhost:8088",
"onboarding.directory_placeholder": "my-project",
"onboarding.connect_host": "Connect to Host",
"onboarding.connect_host": "Connect to server",
"onboarding.connect_description": "Pair with an existing OpenCode server (LAN or tunnel).",
"onboarding.server_url": "Server URL",
"onboarding.directory": "Directory (optional)",
"onboarding.directory_hint": "Use if your host runs multiple workspaces.",
"onboarding.directory_hint": "Use if your server runs multiple workspaces.",
"onboarding.connect": "Connect",
"onboarding.remote_workspace_title": "Connect OpenCode (recommended)",
"onboarding.remote_workspace_description": "OpenWork connects directly by default. Use an OpenWork host to pair devices.",
"onboarding.remote_workspace_title": "Connect to OpenWork server",
"onboarding.remote_workspace_description": "Connect to an OpenWork server to access a workspace from anywhere.",
"onboarding.remote_workspace_action": "Connect",
"onboarding.remote_workspace_card_title": "Connect a remote workspace",
"onboarding.remote_workspace_card_description": "Connect to OpenCode directly or pair with an OpenWork host.",
"onboarding.advanced_openwork_host": "OpenWork host (alpha)",
"onboarding.advanced_openwork_hint": "Use a host URL and client token for shared access.",
"onboarding.remote_workspace_card_description": "Connect to an OpenWork server to access a shared workspace.",
"onboarding.advanced_openwork_host": "OpenWork server",
"onboarding.advanced_openwork_hint": "Use a server URL and access token for shared access.",
"onboarding.advanced_opencode_direct": "Advanced: OpenCode direct",
"onboarding.advanced_opencode_hint": "Connect straight to an OpenCode engine when no host is available.",
"onboarding.advanced_opencode_hint": "Connect straight to an OpenCode engine when no server is available.",
"onboarding.welcome_title": "How would you like to run OpenWork today?",
"onboarding.run_local": "Run on this computer",
"onboarding.run_local": "Run locally",
"onboarding.run_local_description": "OpenWork runs OpenCode locally and keeps your work private.",
"onboarding.engine_running": "Engine already running",
"onboarding.attach_description": "Attach to the existing session on this device.",

View File

@@ -51,21 +51,21 @@ export default {
"dashboard.create_workspace_subtitle": "初始化新的基于文件夹的工作区。",
"dashboard.create_workspace_confirm": "创建工作区",
"dashboard.create_remote_workspace_title": "添加远程工作区",
"dashboard.create_remote_workspace_subtitle": " OpenWork 主机或 OpenCode 引擎保存为工作区。",
"dashboard.create_remote_workspace_subtitle": "保存 OpenWork 服务器为工作区。",
"dashboard.create_remote_workspace_confirm": "添加工作区",
"dashboard.remote_workspace_title": "远程工作区",
"dashboard.remote_workspace_hint": "跟踪 OpenWork 主机或 OpenCode 引擎,随时重新连接。",
"dashboard.remote_base_url_label": "OpenCode 地址",
"dashboard.remote_base_url_placeholder": "http://127.0.0.1:4096",
"dashboard.remote_workspace_hint": "记录 OpenWork 服务器,随时重新连接。",
"dashboard.remote_base_url_label": "OpenWork 服务器地址",
"dashboard.remote_base_url_placeholder": "http://127.0.0.1:8787",
"dashboard.remote_base_url_required": "请先填写服务器地址。",
"dashboard.openwork_host_label": "OpenWork 主机地址",
"dashboard.openwork_host_placeholder": "https://your-host.openwork.app",
"dashboard.openwork_host_hint": "使用 OpenWork 主机提供的地址。",
"dashboard.openwork_host_token_label": "客户端令牌",
"dashboard.openwork_host_label": "OpenWork 服务器地址",
"dashboard.openwork_host_placeholder": "https://your-server.openwork.app",
"dashboard.openwork_host_hint": "使用 OpenWork 服务器提供的地址。",
"dashboard.openwork_host_token_label": "访问令牌",
"dashboard.openwork_host_token_placeholder": "粘贴你的令牌",
"dashboard.openwork_host_token_hint": "可选。粘贴主机提供的客户端令牌以完成配对。",
"dashboard.remote_mode_openwork_alpha": "OpenWork 主机Alpha",
"dashboard.remote_mode_direct": "OpenCode 直连(推荐",
"dashboard.openwork_host_token_hint": "可选。粘贴服务器提供的访问令牌以验证。",
"dashboard.remote_mode_openwork_alpha": "OpenWork 服务器",
"dashboard.remote_mode_direct": "直连(旧版",
"dashboard.remote_connection_openwork": "OpenWork",
"dashboard.remote_connection_direct": "直连",
"dashboard.remote_directory_label": "工作区目录(可选)",
@@ -198,7 +198,7 @@ export default {
"skills.add_title": "添加 skills",
"skills.add_description": "安装起始命令、导入 skill或打开文件夹。",
"skills.install_from_openpackage": "从 OpenPackage 安装",
"skills.host_mode_only": "仅主机模式",
"skills.host_mode_only": "仅本地工作区",
"skills.install": "安装",
"skills.installed_label": "已安装",
"skills.install_hint": "将 OpenPackage 包安装到当前工作区。Skills 应放在 `.opencode/skills` 中。",
@@ -214,7 +214,7 @@ export default {
"skills.installed": "已安装的 skills",
"skills.no_skills": "在 `.opencode/skills` 或 `.claude/skills` 中未检测到 skills。",
"skills.desktop_required": "技能管理需要桌面应用。",
"skills.host_only_error": "技能管理仅在主机模式下可用。",
"skills.host_only_error": "技能管理需要本地工作区或已连接的 OpenWork 服务器。",
"skills.install_skill_creator": "安装 skill creator",
"skills.install_skill_creator_hint": "此 skill 可让你在聊天中创建其他 skills。",
"skills.installing_skill_creator": "正在安装 skill creator...",
@@ -234,13 +234,13 @@ export default {
"skills.notion_crm_title": "Notion CRM 增强技能",
"skills.notion_crm_description": "为联系人、管道和跟进添加增强工作流。",
"skills.notion_crm_card_description": "使用现成的技能丰富 Notion CRM 数据。",
"skills.connect_host_to_load": "连接到主机以加载 skills。",
"skills.connect_host_to_load": "连接 OpenWork 服务器以加载技能。",
"skills.pick_workspace_first": "先选择一个工作区文件夹。",
"skills.no_skills_found": "还没有找到 skills。",
"skills.installed_description": "此工作区可用的 skills。",
"skills.failed_to_load": "加载 skills 失败",
"skills.plugin_management_host_only": "插件管理仅在主机模式下可用。",
"skills.plugins_host_only": "插件仅在主机模式下可用。",
"skills.plugin_management_host_only": "插件管理需要桌面应用。",
"skills.plugins_host_only": "插件仅在桌面应用中可用。",
"skills.pick_project_for_plugins": "选择项目文件夹以管理项目插件。",
"skills.pick_project_for_active": "选择项目文件夹以加载活动插件。",
"skills.no_opencode_found": "尚未找到 opencode.json。添加插件以创建一个。",
@@ -251,13 +251,13 @@ export default {
"skills.enter_plugin_name": "输入插件包名称。",
"skills.plugin_already_listed": "插件已在 opencode.json 中列出。",
"skills.failed_update_opencode": "更新 opencode.json 失败",
"skills.opackage_install_host_only": "OpenPackage 安装仅在主机模式下可用。",
"skills.opackage_install_host_only": "OpenPackage 安装需要桌面应用。",
"skills.pick_project_first": "先选择一个项目文件夹。",
"skills.enter_opackage_source": "输入 OpenPackage 源例如github:anthropics/claude-code。",
"skills.installing_opackage": "正在安装 OpenPackage...",
"skills.install_complete": "已安装。",
"skills.curated_list_notice": "这是一个精选列表,还不是 OpenPackage。复制链接或关注 PRD 以了解计划的注册表搜索集成。",
"skills.import_host_only": "Skill 导入仅在主机模式下可用。",
"skills.import_host_only": "Skill 导入需要桌面应用。",
"skills.select_skill_folder": "选择 skill 文件夹",
"skills.import_failed": "导入失败({status}",
"skills.imported": "已导入。",
@@ -378,7 +378,7 @@ export default {
"mcp.status_disabled": "已禁用",
"mcp.disconnected": "已断开",
"mcp.failed": "失败",
"mcp.host_mode_only": "MCP 连接仅在主机模式下可用。",
"mcp.host_mode_only": "MCP 连接需要桌面应用。",
"mcp.pick_workspace_first": "先选择一个工作区文件夹。",
"mcp.desktop_required": "MCP 连接需要桌面应用。",
"mcp.connect_server_first": "先连接到 OpenCode 服务器。",
@@ -416,7 +416,7 @@ export default {
"mcp.auth.reauth_running": "正在重新授权...",
"mcp.auth.reauth_failed": "重新授权失败。",
"mcp.auth.reauth_cli_hint": "运行opencode mcp auth {server}",
"mcp.auth.reauth_remote_hint": "请在运行该工作区的主机上重新授权。",
"mcp.auth.reauth_remote_hint": "请在运行该工作区的设备上重新授权。",
"mcp.auth.authorization_still_required": "仍需要授权。请重试以重新启动流程。",
"mcp.auth.oauth_not_supported_hint": "这可能意味着:\n• MCP 服务器未声明 OAuth 功能\n• 引擎需要重新加载以发现服务器功能\n• 尝试:从 CLI 运行 opencode mcp auth {server}",
"mcp.auth.try_reload_engine": "{message}。请尝试先重新加载引擎。",
@@ -574,13 +574,13 @@ export default {
"reload.toast_reload": "重新加载",
"reload.toast_reloading": "正在重新加载...",
"reload.toast_dismiss": "忽略",
"reload.toast_blocked_host": "仅主机模式可重新加载。",
"reload.toast_blocked_host": "仅本地工作区可重新加载。",
"reload.toast_blocked_connect": "连接到此工作区后才能重新加载。",
"reload.toast_blocked_runs": "请先停止活动运行再重新加载。",
// ==================== Onboarding ====================
"onboarding.starting_host": "正在启动 OpenWork...",
"onboarding.searching_host": "正在搜索主机...",
"onboarding.starting_host": "正在启动 OpenWork 服务器...",
"onboarding.searching_host": "正在连接 OpenWork 服务器...",
"onboarding.getting_ready": "准备就绪",
"onboarding.verifying": "验证安全握手",
"onboarding.create_first_workspace": "创建您的第一个工作区",
@@ -598,10 +598,10 @@ export default {
"onboarding.cli_ready": "OpenCode CLI 就绪。",
"onboarding.cli_version": "OpenCode {version}",
"onboarding.windows_install_instruction": "安装 Windows 版 OpenCode然后重启 OpenWork。确保 opencode.exe 在 PATH 中。",
"onboarding.install_instruction": "安装 OpenCode 以启用主机模式(无需终端)。",
"onboarding.install_instruction": "安装 OpenCode 以启用本地服务器(无需终端)。",
"onboarding.install": "安装 OpenCode",
"onboarding.recheck": "重新检查",
"onboarding.ready_message": "OpenCode 已准备好以主机模式启动。",
"onboarding.ready_message": "OpenCode 已准备好启动本地服务器。",
"onboarding.resolved_path": "解析路径",
"onboarding.version": "版本",
"onboarding.search_notes": "搜索说明",
@@ -623,7 +623,7 @@ export default {
"onboarding.remove": "移除",
"onboarding.cli_label": "OpenCode CLI",
"onboarding.cli_checking": "正在检查安装...",
"onboarding.cli_not_found_hint": "未找到。请安装以运行主机模式。",
"onboarding.cli_not_found_hint": "未找到。请安装以运行本地服务器。",
"onboarding.cli_version_installed": "已安装",
"onboarding.cli_recheck": "重新检查",
"onboarding.cli_install_commands": "使用以下命令之一安装 OpenCode然后重启 OpenWork。",
@@ -631,23 +631,23 @@ export default {
"onboarding.last_checked": "上次检查时间 {time}",
"onboarding.server_url_placeholder": "http://localhost:8088",
"onboarding.directory_placeholder": "my-project",
"onboarding.connect_host": "连接到主机",
"onboarding.connect_host": "连接服务器",
"onboarding.connect_description": "与现有的 OpenCode 服务器配对(局域网或隧道)。",
"onboarding.server_url": "服务器 URL",
"onboarding.directory": "目录(可选)",
"onboarding.directory_hint": "如果主机运行多个工作区,请使用此选项。",
"onboarding.directory_hint": "如果服务器运行多个工作区,请使用此选项。",
"onboarding.connect": "连接",
"onboarding.remote_workspace_title": "连接 OpenCode推荐",
"onboarding.remote_workspace_description": "OpenWork 默认直接连接。需要跨设备时使用 OpenWork 主机。",
"onboarding.remote_workspace_title": "连接 OpenWork 服务器",
"onboarding.remote_workspace_description": "连接 OpenWork 服务器以随时访问工作区。",
"onboarding.remote_workspace_action": "连接",
"onboarding.remote_workspace_card_title": "连接远程工作区",
"onboarding.remote_workspace_card_description": "直接连接 OpenCode或配对 OpenWork 主机。",
"onboarding.advanced_openwork_host": "OpenWork 主机Alpha",
"onboarding.advanced_openwork_hint": "使用主机地址和客户端令牌进行共享访问。",
"onboarding.remote_workspace_card_description": "连接 OpenWork 服务器以访问共享工作区。",
"onboarding.advanced_openwork_host": "OpenWork 服务器",
"onboarding.advanced_openwork_hint": "使用服务器地址和访问令牌进行共享访问。",
"onboarding.advanced_opencode_direct": "高级OpenCode 直连",
"onboarding.advanced_opencode_hint": "当没有主机时,直接连接 OpenCode 引擎。",
"onboarding.advanced_opencode_hint": "当没有服务器时,直接连接 OpenCode 引擎。",
"onboarding.welcome_title": "今天想如何运行 OpenWork",
"onboarding.run_local": "在此计算机上运行",
"onboarding.run_local": "本地运行",
"onboarding.run_local_description": "OpenWork 在本地运行 OpenCode 并保持您的工作私密。",
"onboarding.engine_running": "引擎已在运行",
"onboarding.attach_description": "附加到此设备上的现有会话。",

View File

@@ -58,6 +58,19 @@ export function startServer(config: ServerConfig) {
return withCors(new Response(null, { status: 204 }), request, config);
}
if (url.pathname === "/opencode" || url.pathname.startsWith("/opencode/")) {
try {
requireClient(request, config);
const response = await proxyOpencodeRequest({ request, url, config });
return withCors(response, request, config);
} catch (error) {
const apiError = error instanceof ApiError
? error
: new ApiError(500, "internal_error", "Unexpected server error");
return withCors(jsonResponse(formatError(apiError), apiError.status), request, config);
}
}
const route = matchRoute(routes, request.method, url.pathname);
if (!route) {
return withCors(jsonResponse({ code: "not_found", message: "Not found" }, 404), request, config);
@@ -119,6 +132,47 @@ function pathToRegex(path: string, keys: string[]): RegExp {
return new RegExp(`^${pattern}$`);
}
function buildOpencodeProxyUrl(baseUrl: string, path: string, search: string) {
const target = new URL(baseUrl);
const trimmedPath = path.replace(/^\/opencode/, "");
target.pathname = trimmedPath.startsWith("/") ? trimmedPath : `/${trimmedPath}`;
target.search = search;
return target.toString();
}
async function proxyOpencodeRequest(input: {
request: Request;
url: URL;
config: ServerConfig;
}) {
const workspace = input.config.workspaces[0];
const baseUrl = workspace?.baseUrl?.trim() ?? "";
if (!baseUrl) {
throw new ApiError(400, "opencode_unconfigured", "OpenCode base URL is missing for this workspace");
}
const targetUrl = buildOpencodeProxyUrl(baseUrl, input.url.pathname, input.url.search);
const headers = new Headers(input.request.headers);
headers.delete("authorization");
headers.delete("host");
headers.delete("origin");
const auth = workspace ? buildOpencodeAuthHeader(workspace) : null;
if (auth) {
headers.set("Authorization", auth);
}
const method = input.request.method.toUpperCase();
const body = method === "GET" || method === "HEAD" ? undefined : input.request.body;
const response = await fetch(targetUrl, {
method,
headers,
body,
});
return response;
}
function jsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
@@ -149,7 +203,10 @@ function withCors(response: Response, request: Request, config: ServerConfig) {
if (!allowOrigin) return response;
const headers = new Headers(response.headers);
headers.set("Access-Control-Allow-Origin", allowOrigin);
headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-OpenWork-Host-Token, X-OpenWork-Client-Id");
headers.set(
"Access-Control-Allow-Headers",
"Authorization, Content-Type, X-OpenWork-Host-Token, X-OpenWork-Client-Id, X-OpenCode-Directory, X-Opencode-Directory",
);
headers.set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
headers.set("Vary", "Origin");
return new Response(response.body, { status: response.status, headers });

8
pnpm-lock.yaml generated
View File

@@ -1315,8 +1315,8 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
get-tsconfig@4.13.1:
resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -2676,7 +2676,7 @@ snapshots:
gensync@1.0.0-beta.2: {}
get-tsconfig@4.13.0:
get-tsconfig@4.13.1:
dependencies:
resolve-pkg-maps: 1.0.0
optional: true
@@ -3064,7 +3064,7 @@ snapshots:
tsx@4.21.0:
dependencies:
esbuild: 0.27.2
get-tsconfig: 4.13.0
get-tsconfig: 4.13.1
optionalDependencies:
fsevents: 2.3.3
optional: true