feat: auto-connect web sessions and sidebar workspace hub (#438)

This commit is contained in:
ben
2026-02-04 09:10:21 -08:00
committed by GitHub
parent 2d447daaf1
commit 1b272355de
12 changed files with 564 additions and 438 deletions

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ packages/*/node_modules/
out/
dist/
packages/*/dist/
tmp/
# Tauri/Rust
packages/desktop/src-tauri/target/

View File

@@ -6,6 +6,7 @@
"dev": "pnpm --filter @different-ai/openwork dev",
"dev:ui": "pnpm --filter @different-ai/openwork-ui dev",
"dev:web": "pnpm --filter @different-ai/openwork-ui dev",
"dev:headless-web": "bun scripts/dev-headless-web.ts",
"build": "pnpm --filter @different-ai/openwork build",
"build:ui": "pnpm --filter @different-ai/openwork-ui build",
"build:web": "pnpm --filter @different-ai/openwork-ui build",

View File

@@ -29,7 +29,6 @@ import ResetModal from "./components/reset-modal";
import CommandModal from "./components/command-modal";
import CommandRunModal from "./components/command-run-modal";
import CommandPaletteModal, { type PaletteGroup } from "./components/command-palette-modal";
import WorkspacePicker from "./components/workspace-picker";
import WorkspaceSwitchOverlay from "./components/workspace-switch-overlay";
import CreateRemoteWorkspaceModal from "./components/create-remote-workspace-modal";
import CreateWorkspaceModal from "./components/create-workspace-modal";
@@ -138,6 +137,7 @@ import {
} from "./lib/tauri";
import {
createOpenworkServerClient,
hydrateOpenworkServerSettingsFromEnv,
normalizeOpenworkServerUrl,
readOpenworkServerSettings,
writeOpenworkServerSettings,
@@ -295,6 +295,7 @@ export default function App() {
createEffect(() => {
if (typeof window === "undefined") return;
hydrateOpenworkServerSettingsFromEnv();
setOpenworkServerSettings(readOpenworkServerSettings());
});
@@ -838,9 +839,6 @@ export default function App() {
}
}
async function openConnectFlow() {
workspaceStore.setWorkspacePickerOpen(true);
}
async function listAgents(): Promise<Agent[]> {
const c = client();
@@ -1581,6 +1579,22 @@ export default function App() {
return ok;
};
const openWorkspaceConnectionSettings = (workspaceId: string) => {
const workspace = workspaceStore.workspaces().find((item) => item.id === workspaceId) ?? null;
if (workspace?.workspaceType === "remote" && workspace.remoteType === "openwork") {
const hostUrl = normalizeOpenworkServerUrl(workspace.openworkHostUrl ?? "") ?? "";
if (hostUrl) {
updateOpenworkServerSettings({
...openworkServerSettings(),
urlOverride: hostUrl,
});
}
}
setSettingsTab("remote");
setTab("settings");
setView("dashboard");
};
const commandState = createCommandState({
client,
selectedSession,
@@ -2480,6 +2494,7 @@ export default function App() {
skills: true,
authorizedFolders: false,
});
const [autoConnectAttempted, setAutoConnectAttempted] = createSignal(false);
const [appVersion, setAppVersion] = createSignal<string | null>(null);
@@ -2512,6 +2527,19 @@ export default function App() {
return busy();
});
createEffect(() => {
if (isTauriRuntime()) return;
if (autoConnectAttempted()) return;
if (client()) return;
if (openworkServerStatus() !== "connected") return;
const settings = openworkServerSettings();
if (!settings.urlOverride || !settings.token) return;
setAutoConnectAttempted(true);
void workspaceStore.onConnectClient();
});
createEffect(() => {
// If we lose the client (disconnect / stop engine), don't strand the user
// in a session view that can't operate.
@@ -3999,13 +4027,8 @@ export default function App() {
reloadBusy: reloadBusy(),
reloadError: reloadError(),
activeWorkspaceDisplay: activeWorkspaceDisplay(),
workspaceSearch: workspaceStore.workspaceSearch(),
setWorkspaceSearch: workspaceStore.setWorkspaceSearch,
workspacePickerOpen: workspaceStore.workspacePickerOpen(),
setWorkspacePickerOpen: workspaceStore.setWorkspacePickerOpen,
connectingWorkspaceId: workspaceStore.connectingWorkspaceId(),
workspaces: workspaceStore.workspaces(),
filteredWorkspaces: workspaceStore.filteredWorkspaces(),
activeWorkspaceId: workspaceStore.activeWorkspaceId(),
activateWorkspace: workspaceStore.activateWorkspace,
exportWorkspaceConfig: workspaceStore.exportWorkspaceConfig,
@@ -4190,9 +4213,15 @@ export default function App() {
workspaces: workspaceStore.workspaces(),
activeWorkspaceId: workspaceStore.activeWorkspaceId(),
connectingWorkspaceId: workspaceStore.connectingWorkspaceId(),
workspaceConnectionStateById: workspaceStore.workspaceConnectionStateById(),
activateWorkspace: workspaceStore.activateWorkspace,
setWorkspaceSearch: workspaceStore.setWorkspaceSearch,
setWorkspacePickerOpen: workspaceStore.setWorkspacePickerOpen,
testWorkspaceConnection: workspaceStore.testWorkspaceConnection,
editWorkspaceConnection: openWorkspaceConnectionSettings,
forgetWorkspace: workspaceStore.forgetWorkspace,
openCreateWorkspace: () => workspaceStore.setCreateWorkspaceOpen(true),
openCreateRemoteWorkspace: () => workspaceStore.setCreateRemoteWorkspaceOpen(true),
importWorkspaceConfig: workspaceStore.importWorkspaceConfig,
importingWorkspaceConfig: workspaceStore.importingWorkspaceConfig(),
clientConnected: Boolean(client()),
openworkServerStatus: openworkServerStatus(),
stopHost,
@@ -4246,7 +4275,6 @@ export default function App() {
respondQuestion: respondQuestion,
safeStringify: safeStringify,
showTryNotionPrompt: tryNotionPromptVisible() && notionIsActive(),
openConnect: openConnectFlow,
startProviderAuth: startProviderAuth,
submitProviderApiKey: submitProviderApiKey,
openProviderAuthModal: openProviderAuthModal,
@@ -4512,22 +4540,6 @@ export default function App() {
onDismiss={() => setReloadToastDismissedAt(Date.now())}
/>
<WorkspacePicker
open={workspaceStore.workspacePickerOpen()}
workspaces={workspaceStore.workspaces()}
activeWorkspaceId={workspaceStore.activeWorkspaceId()}
search={workspaceStore.workspaceSearch()}
onSearch={workspaceStore.setWorkspaceSearch}
onClose={() => workspaceStore.setWorkspacePickerOpen(false)}
onSelect={workspaceStore.activateWorkspace}
onCreateLocal={() => workspaceStore.setCreateWorkspaceOpen(true)}
onCreateRemote={() => workspaceStore.setCreateRemoteWorkspaceOpen(true)}
onImport={workspaceStore.importWorkspaceConfig}
importing={workspaceStore.importingWorkspaceConfig()}
onForget={workspaceStore.forgetWorkspace}
connectingWorkspaceId={workspaceStore.connectingWorkspaceId()}
/>
<CreateWorkspaceModal
open={workspaceStore.createWorkspaceOpen()}
onClose={() => workspaceStore.setCreateWorkspaceOpen(false)}

View File

@@ -1,7 +1,7 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { Check, ChevronDown, GripVertical, Loader2, Plus } from "lucide-solid";
import { Check, ChevronDown, GripVertical, Loader2, Plus, RefreshCcw, Settings, Trash2 } from "lucide-solid";
import type { TodoItem } from "../../types";
import type { TodoItem, WorkspaceConnectionState } from "../../types";
import type { WorkspaceInfo } from "../../lib/tauri";
type SessionSummary = {
@@ -32,8 +32,15 @@ export type SidebarProps = {
workspaceGroups: WorkspaceSessionGroup[];
activeWorkspaceId: string;
connectingWorkspaceId?: string | null;
workspaceConnectionStateById: Record<string, WorkspaceConnectionState>;
onSelectWorkspace: (workspaceId: string) => void;
onAddWorkspace: () => void;
onCreateWorkspace: () => void;
onCreateRemoteWorkspace: () => void;
onImportWorkspace: () => void;
importingWorkspaceConfig?: boolean;
onEditWorkspace: (workspaceId: string) => void;
onTestWorkspaceConnection: (workspaceId: string) => void;
onForgetWorkspace: (workspaceId: string) => void;
onReorderWorkspace: (fromId: string, toId: string | null) => void;
onSelectSession: (workspaceId: string, sessionId: string) => void;
selectedSessionId: string | null;
@@ -73,6 +80,8 @@ export default function SessionSidebar(props: SidebarProps) {
const [showAllSessionsByWorkspaceId, setShowAllSessionsByWorkspaceId] = createSignal<
Record<string, boolean>
>({});
const [addWorkspaceMenuOpen, setAddWorkspaceMenuOpen] = createSignal(false);
let addWorkspaceMenuRef: HTMLDivElement | undefined;
const workspaceLabel = (workspace: WorkspaceInfo) =>
workspace.displayName?.trim() ||
@@ -253,6 +262,17 @@ export default function SessionSidebar(props: SidebarProps) {
});
});
createEffect(() => {
if (!addWorkspaceMenuOpen()) return;
const closeMenu = (event: MouseEvent) => {
const target = event.target as Node | null;
if (addWorkspaceMenuRef && target && addWorkspaceMenuRef.contains(target)) return;
setAddWorkspaceMenuOpen(false);
};
window.addEventListener("click", closeMenu);
onCleanup(() => window.removeEventListener("click", closeMenu));
});
return (
<div class="flex flex-col h-full overflow-hidden">
<div class="px-4 pt-4 shrink-0">
@@ -288,6 +308,15 @@ export default function SessionSidebar(props: SidebarProps) {
const detailLabel = () => workspaceDetailLabel(group.workspace);
const sessions = () => group.sessions;
const allowActions = () => !props.connectingWorkspaceId || isConnecting();
const connectionState = () => props.workspaceConnectionStateById[group.workspace.id];
const connectionStatus = () => connectionState()?.status ?? "idle";
const connectionMessage = () => connectionState()?.message?.trim() ?? "";
const connectionDotClass = () => {
if (connectionStatus() === "connected") return "bg-green-9";
if (connectionStatus() === "connecting") return "bg-amber-9 animate-pulse";
if (connectionStatus() === "error") return "bg-red-9";
return "bg-gray-7";
};
const collapsed = () => isWorkspaceCollapsed(group.workspace.id);
const dragOver = () => dragOverWorkspaceId() === group.workspace.id;
const showingAll = () => isShowingAllSessions(group.workspace.id);
@@ -324,6 +353,7 @@ export default function SessionSidebar(props: SidebarProps) {
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 space-y-0.5">
<div class="flex items-center gap-2">
<span class={`h-2 w-2 rounded-full ${connectionDotClass()}`} />
<span class="text-xs font-semibold truncate">
{workspaceLabel(group.workspace)}
</span>
@@ -341,12 +371,17 @@ export default function SessionSidebar(props: SidebarProps) {
</Show>
</div>
<div class="flex items-center gap-2 text-[10px] shrink-0">
<Show when={isConnecting()}>
<Show when={isConnecting() || connectionStatus() === "connecting"}>
<Loader2 size={12} class="text-gray-10 animate-spin" />
</Show>
<Show when={!isConnecting()}>
<Show when={isActive()} fallback={<span class="text-gray-9">Switch</span>}>
<span class="text-green-11 font-medium">Active</span>
<Show when={!isConnecting() && connectionStatus() !== "connecting"}>
<Show when={connectionStatus() === "error"}>
<span class="text-red-11 font-medium">Needs attention</span>
</Show>
<Show when={connectionStatus() !== "error"}>
<Show when={isActive()} fallback={<span class="text-gray-9">Switch</span>}>
<span class="text-green-11 font-medium">Active</span>
</Show>
</Show>
</Show>
</div>
@@ -378,6 +413,42 @@ export default function SessionSidebar(props: SidebarProps) {
</div>
<Show when={!collapsed()}>
<div class="space-y-1 pl-2 pb-2">
<Show when={connectionStatus() === "error" && connectionMessage()}>
<div class="mx-3 rounded-lg border border-red-7/30 bg-red-1/40 px-3 py-2 text-[11px] text-red-11">
{connectionMessage()}
</div>
</Show>
<div class="flex flex-wrap gap-2 px-3 pb-1">
<Show when={group.workspace.workspaceType === "remote"}>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-gray-6 px-2 py-1 text-[10px] text-gray-10 hover:text-gray-12 hover:border-gray-7 hover:bg-gray-2 transition-colors"
onClick={() => props.onEditWorkspace(group.workspace.id)}
disabled={!allowActions()}
>
<Settings size={12} />
Edit connection
</button>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-gray-6 px-2 py-1 text-[10px] text-gray-10 hover:text-gray-12 hover:border-gray-7 hover:bg-gray-2 transition-colors"
onClick={() => props.onTestWorkspaceConnection(group.workspace.id)}
disabled={!allowActions()}
>
<RefreshCcw size={12} class={connectionStatus() === "connecting" ? "animate-spin" : ""} />
Test connection
</button>
</Show>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-gray-6 px-2 py-1 text-[10px] text-gray-10 hover:text-gray-12 hover:border-gray-7 hover:bg-gray-2 transition-colors"
onClick={() => props.onForgetWorkspace(group.workspace.id)}
disabled={!allowActions()}
>
<Trash2 size={12} />
Remove
</button>
</div>
<Show
when={sessions().length > 0}
fallback={
@@ -451,17 +522,57 @@ export default function SessionSidebar(props: SidebarProps) {
}}
</For>
</Show>
<button
type="button"
class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-gray-11 border border-dashed border-gray-6 hover:border-gray-7 hover:text-gray-12 hover:bg-gray-2 transition-colors"
onClick={props.onAddWorkspace}
onDragOver={(event) => handleDragOver(event, null)}
onDragLeave={() => handleDragLeave(null)}
onDrop={(event) => handleDrop(event, null)}
>
<Plus size={14} />
Add new workspace
</button>
<div class="relative" ref={(el) => (addWorkspaceMenuRef = el)}>
<button
type="button"
class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-gray-11 border border-dashed border-gray-6 hover:border-gray-7 hover:text-gray-12 hover:bg-gray-2 transition-colors"
onClick={() => setAddWorkspaceMenuOpen((prev) => !prev)}
onDragOver={(event) => handleDragOver(event, null)}
onDragLeave={() => handleDragLeave(null)}
onDrop={(event) => handleDrop(event, null)}
>
<Plus size={14} />
Add new workspace
</button>
<Show when={addWorkspaceMenuOpen()}>
<div class="mt-2 rounded-lg border border-gray-6 bg-gray-1 shadow-lg overflow-hidden">
<button
type="button"
class="w-full flex items-center gap-2 px-3 py-2 text-xs text-gray-11 hover:bg-gray-2 transition-colors"
onClick={() => {
props.onCreateWorkspace();
setAddWorkspaceMenuOpen(false);
}}
>
<Plus size={12} />
New workspace
</button>
<button
type="button"
class="w-full flex items-center gap-2 px-3 py-2 text-xs text-gray-11 hover:bg-gray-2 transition-colors"
onClick={() => {
props.onCreateRemoteWorkspace();
setAddWorkspaceMenuOpen(false);
}}
>
<Plus size={12} />
Connect remote
</button>
<button
type="button"
class="w-full flex items-center gap-2 px-3 py-2 text-xs text-gray-11 hover:bg-gray-2 transition-colors disabled:opacity-60"
disabled={props.importingWorkspaceConfig}
onClick={() => {
props.onImportWorkspace();
setAddWorkspaceMenuOpen(false);
}}
>
<Plus size={12} />
Import config
</button>
</div>
</Show>
</div>
</div>
</div>

View File

@@ -1,199 +0,0 @@
import { For, Show, createEffect, createMemo } from "solid-js";
import { Check, Globe, Loader2, Plus, Search, Trash2, Upload } from "lucide-solid";
import { t, currentLocale } from "../../i18n";
import type { WorkspaceInfo } from "../lib/tauri";
export default function WorkspacePicker(props: {
open: boolean;
workspaces: WorkspaceInfo[];
activeWorkspaceId: string;
search: string;
onSearch: (value: string) => void;
onClose: () => void;
onSelect: (workspaceId: string) => Promise<boolean> | boolean | void;
onCreateLocal: () => void;
onCreateRemote: () => void;
onImport: () => void;
importing?: boolean;
onForget: (workspaceId: string) => void;
connectingWorkspaceId?: string | null;
}) {
const translate = (key: string) => t(key, currentLocale());
const filtered = createMemo(() => {
const query = props.search.trim().toLowerCase();
if (!query) return props.workspaces;
return props.workspaces.filter((w) =>
`${w.name} ${w.path} ${w.baseUrl ?? ""} ${w.displayName ?? ""} ${w.directory ?? ""} ${
w.openworkHostUrl ?? ""
} ${w.openworkWorkspaceName ?? ""}`
.toLowerCase()
.includes(query)
);
});
const totalCount = createMemo(() => props.workspaces.length);
let searchInputRef: HTMLInputElement | undefined;
createEffect(() => {
if (props.open) {
requestAnimationFrame(() => searchInputRef?.focus());
}
});
return (
<Show when={props.open}>
<div
class="fixed inset-0 z-50 flex items-start justify-center pt-24 bg-gray-1/20 backdrop-blur-[2px]"
onClick={props.onClose}
>
<div
class="bg-gray-2 border border-gray-6 w-full max-w-sm rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-100"
onClick={(e) => e.stopPropagation()}
>
<div class="p-2 border-b border-gray-6">
<div class="relative">
<Search size={14} class="absolute left-3 top-2.5 text-gray-10" />
<input
ref={(el) => (searchInputRef = el)}
type="text"
placeholder={translate("dashboard.find_workspace")}
value={props.search}
onInput={(e) => props.onSearch(e.currentTarget.value)}
class="w-full bg-gray-1 border border-gray-6 rounded-lg py-1.5 pl-9 pr-3 text-sm text-gray-12 focus:outline-none focus:border-gray-7"
/>
</div>
</div>
<div class="max-h-64 overflow-y-auto p-1">
<div class="px-3 py-2 text-[10px] font-semibold text-gray-10 uppercase tracking-wider">
{translate("dashboard.workspaces")} ({totalCount()})
</div>
<Show
when={filtered().length}
fallback={
<div class="px-3 py-6 text-sm text-gray-10 text-center">
{translate("dashboard.no_workspaces")}
</div>
}
>
<For each={filtered()}>
{(ws) => (
<div
class={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
props.activeWorkspaceId === ws.id
? "bg-gray-4 text-gray-12"
: "text-gray-11 hover:text-gray-12 hover:bg-gray-4/50"
}`}
>
<button
onClick={() => {
const result = props.onSelect(ws.id);
if (result instanceof Promise) {
result.then((ok) => {
if (ok !== false) props.onClose();
});
return;
}
if (result !== false) props.onClose();
}}
class="flex-1 text-left min-w-0"
>
<div class="flex items-center gap-2">
<div class="font-medium truncate">{ws.name}</div>
<Show when={ws.workspaceType === "remote"}>
<span class="inline-flex items-center gap-1 text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded-full bg-gray-3 text-gray-11">
<Globe size={10} />
{translate("dashboard.remote")}
</span>
<span class="inline-flex items-center text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded-full bg-gray-2 text-gray-10">
{ws.remoteType === "openwork"
? translate("dashboard.remote_connection_openwork")
: translate("dashboard.remote_connection_direct")}
</span>
</Show>
</div>
<div class="text-[10px] text-gray-7 font-mono truncate max-w-[200px]">
{ws.workspaceType === "remote"
? ws.remoteType === "openwork"
? ws.openworkHostUrl ?? ws.baseUrl ?? ws.path
: ws.baseUrl ?? ws.path
: ws.path}
</div>
<Show
when={
ws.workspaceType === "remote" &&
(ws.directory || ws.openworkWorkspaceName)
}
>
<div class="text-[10px] text-gray-8 truncate max-w-[200px]">
{ws.openworkWorkspaceName ?? ws.directory}
</div>
</Show>
</button>
<Show when={props.activeWorkspaceId === ws.id}>
<Check size={14} class="text-indigo-11" />
</Show>
<Show when={props.connectingWorkspaceId === ws.id}>
<Loader2 size={14} class="text-gray-10 animate-spin" />
</Show>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
props.onForget(ws.id);
}}
class="p-1 rounded-md text-gray-9 hover:text-gray-12 hover:bg-gray-3 transition-colors"
title={translate("dashboard.forget_workspace")}
>
<Trash2 size={14} />
</button>
</div>
)}
</For>
</Show>
</div>
<div class="p-2 border-t border-gray-6 bg-gray-2">
<div class="grid gap-2">
<button
onClick={() => {
props.onImport();
props.onClose();
}}
disabled={props.importing}
class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-gray-11 hover:bg-gray-4 hover:text-gray-12 transition-colors disabled:opacity-60 disabled:hover:bg-transparent"
>
<Upload size={16} />
Import workspace config
</button>
<button
onClick={() => {
props.onCreateLocal();
props.onClose();
}}
class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-gray-11 hover:bg-gray-4 hover:text-gray-12 transition-colors"
>
<Plus size={16} />
{translate("dashboard.new_workspace")}
</button>
<button
onClick={() => {
props.onCreateRemote();
props.onClose();
}}
class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-gray-11 hover:bg-gray-4 hover:text-gray-12 transition-colors"
>
<Globe size={16} />
{translate("dashboard.new_remote_workspace")}
</button>
</div>
</div>
</div>
</div>
</Show>
);
}

View File

@@ -1,4 +1,4 @@
import { createMemo, createSignal } from "solid-js";
import { createEffect, createMemo, createSignal } from "solid-js";
import type {
Client,
@@ -7,6 +7,7 @@ import type {
WorkspaceDisplay,
WorkspaceOpenworkConfig,
WorkspacePreset,
WorkspaceConnectionState,
EngineRuntime,
} from "../types";
import {
@@ -130,11 +131,12 @@ export function createWorkspaceStore(options: {
const [workspaceConfig, setWorkspaceConfig] = createSignal<WorkspaceOpenworkConfig | null>(null);
const [workspaceConfigLoaded, setWorkspaceConfigLoaded] = createSignal(false);
const [workspaceSearch, setWorkspaceSearch] = createSignal("");
const [workspacePickerOpen, setWorkspacePickerOpen] = createSignal(false);
const [createWorkspaceOpen, setCreateWorkspaceOpen] = createSignal(false);
const [createRemoteWorkspaceOpen, setCreateRemoteWorkspaceOpen] = createSignal(false);
const [connectingWorkspaceId, setConnectingWorkspaceId] = createSignal<string | null>(null);
const [workspaceConnectionStateById, setWorkspaceConnectionStateById] = createSignal<
Record<string, WorkspaceConnectionState>
>({});
const [exportingWorkspaceConfig, setExportingWorkspaceConfig] = createSignal(false);
const [importingWorkspaceConfig, setImportingWorkspaceConfig] = createSignal(false);
@@ -178,14 +180,50 @@ export function createWorkspaceStore(options: {
return ws.path ?? "";
});
const activeWorkspaceRoot = createMemo(() => activeWorkspacePath().trim());
const filteredWorkspaces = createMemo(() => {
const query = workspaceSearch().trim().toLowerCase();
if (!query) return workspaces();
return workspaces().filter((ws) => {
const haystack = `${ws.name ?? ""} ${ws.path ?? ""} ${ws.baseUrl ?? ""} ${
ws.displayName ?? ""
} ${ws.directory ?? ""} ${ws.openworkHostUrl ?? ""} ${ws.openworkWorkspaceName ?? ""}`.toLowerCase();
return haystack.includes(query);
const updateWorkspaceConnectionState = (
workspaceId: string,
next: Partial<WorkspaceConnectionState>,
) => {
const id = workspaceId.trim();
if (!id) return;
setWorkspaceConnectionStateById((prev) => {
const current = prev[id] ?? { status: "idle", message: null, checkedAt: null };
return {
...prev,
[id]: {
...current,
...next,
checkedAt: Date.now(),
},
};
});
};
const clearWorkspaceConnectionState = (workspaceId: string) => {
const id = workspaceId.trim();
if (!id) return;
setWorkspaceConnectionStateById((prev) => {
if (!prev[id]) return prev;
const next = { ...prev };
delete next[id];
return next;
});
};
createEffect(() => {
const ids = new Set(workspaces().map((workspace) => workspace.id));
setWorkspaceConnectionStateById((prev) => {
let changed = false;
const next: Record<string, WorkspaceConnectionState> = {};
for (const [id, state] of Object.entries(prev)) {
if (!ids.has(id)) {
changed = true;
continue;
}
next[id] = state;
}
return changed ? next : prev;
});
});
@@ -295,6 +333,72 @@ export function createWorkspaceStore(options: {
}
};
async function testWorkspaceConnection(workspaceId: string) {
const id = workspaceId.trim();
if (!id) return false;
const workspace = workspaces().find((item) => item.id === id) ?? null;
if (!workspace) return false;
updateWorkspaceConnectionState(id, { status: "connecting", message: null });
if (workspace.workspaceType !== "remote") {
updateWorkspaceConnectionState(id, { status: "connected", message: null });
return true;
}
const remoteType = normalizeRemoteType(workspace.remoteType);
if (remoteType === "openwork") {
const hostUrl =
workspace.openworkHostUrl?.trim() || workspace.baseUrl?.trim() || workspace.path?.trim() || "";
if (!hostUrl) {
updateWorkspaceConnectionState(id, {
status: "error",
message: "OpenWork server URL is required.",
});
return false;
}
const token = options.openworkServerSettings().token ?? undefined;
try {
const resolved = await resolveOpenworkHost({ hostUrl, token });
if (resolved.kind !== "openwork") {
updateWorkspaceConnectionState(id, {
status: "error",
message: "OpenWork server unavailable. Check the URL and token.",
});
return false;
}
updateWorkspaceConnectionState(id, { status: "connected", message: null });
return true;
} catch (error) {
const message = error instanceof Error ? error.message : safeStringify(error);
updateWorkspaceConnectionState(id, { status: "error", message });
return false;
}
}
const baseUrl = workspace.baseUrl?.trim() || "";
if (!baseUrl) {
updateWorkspaceConnectionState(id, {
status: "error",
message: "Remote base URL is required.",
});
return false;
}
try {
const client = createClient(baseUrl, workspace.directory?.trim() || undefined);
await waitForHealthy(client, { timeoutMs: 8_000 });
updateWorkspaceConnectionState(id, { status: "connected", message: null });
return true;
} catch (error) {
const message = error instanceof Error ? error.message : safeStringify(error);
updateWorkspaceConnectionState(id, { status: "error", message });
return false;
}
}
async function refreshEngine() {
if (!isTauriRuntime()) return;
@@ -373,6 +477,7 @@ export function createWorkspaceStore(options: {
const baseUrl = isRemote ? next.baseUrl?.trim() ?? "" : "";
setConnectingWorkspaceId(id);
updateWorkspaceConnectionState(id, { status: "connecting", message: null });
try {
if (isRemote) {
@@ -382,6 +487,10 @@ export function createWorkspaceStore(options: {
const hostUrl = next.openworkHostUrl?.trim() ?? "";
if (!hostUrl) {
options.setError("OpenWork server URL is required.");
updateWorkspaceConnectionState(id, {
status: "error",
message: "OpenWork server URL is required.",
});
return false;
}
@@ -414,11 +523,16 @@ export function createWorkspaceStore(options: {
} catch (error) {
const message = error instanceof Error ? error.message : safeStringify(error);
options.setError(addOpencodeCacheHint(message));
updateWorkspaceConnectionState(id, { status: "error", message });
return false;
}
if (!resolvedBaseUrl) {
options.setError(t("app.error.remote_base_url_required", currentLocale()));
updateWorkspaceConnectionState(id, {
status: "error",
message: "Remote base URL is required.",
});
return false;
}
@@ -435,6 +549,10 @@ export function createWorkspaceStore(options: {
);
if (!ok) {
updateWorkspaceConnectionState(id, {
status: "error",
message: "Failed to connect to workspace.",
});
return false;
}
@@ -470,11 +588,16 @@ export function createWorkspaceStore(options: {
}
}
updateWorkspaceConnectionState(id, { status: "connected", message: null });
return true;
}
if (!baseUrl) {
options.setError(t("app.error.remote_base_url_required", currentLocale()));
updateWorkspaceConnectionState(id, {
status: "error",
message: "Remote base URL is required.",
});
return false;
}
@@ -486,6 +609,10 @@ export function createWorkspaceStore(options: {
});
if (!ok) {
updateWorkspaceConnectionState(id, {
status: "error",
message: "Failed to connect to workspace.",
});
return false;
}
@@ -503,6 +630,7 @@ export function createWorkspaceStore(options: {
}
}
updateWorkspaceConnectionState(id, { status: "connected", message: null });
return true;
}
@@ -646,6 +774,7 @@ export function createWorkspaceStore(options: {
options.refreshSkills({ force: true }).catch(() => undefined);
options.refreshPlugins().catch(() => undefined);
updateWorkspaceConnectionState(id, { status: "connected", message: null });
return true;
} finally {
setConnectingWorkspaceId(null);
@@ -818,6 +947,9 @@ export function createWorkspaceStore(options: {
const ws = await workspaceCreate({ folderPath: resolvedFolder, name, preset });
setWorkspaces(ws.workspaces);
syncActiveWorkspaceId(ws.activeId);
if (ws.activeId) {
updateWorkspaceConnectionState(ws.activeId, { status: "connected", message: null });
}
const active = ws.workspaces.find((w) => w.id === ws.activeId) ?? null;
if (active) {
@@ -826,7 +958,6 @@ export function createWorkspaceStore(options: {
await options.loadCommands({ workspaceRoot: active.path, quiet: true }).catch(() => undefined);
}
setWorkspacePickerOpen(false);
setCreateWorkspaceOpen(false);
options.setTab("home");
options.setView("dashboard");
@@ -964,8 +1095,11 @@ export function createWorkspaceStore(options: {
setWorkspaceConfigLoaded(true);
setAuthorizedDirs([]);
setWorkspacePickerOpen(false);
setCreateRemoteWorkspaceOpen(false);
const activeId = activeWorkspaceId();
if (activeId) {
updateWorkspaceConnectionState(activeId, { status: "connected", message: null });
}
return true;
} catch (e) {
const message = e instanceof Error ? e.message : safeStringify(e);
@@ -993,6 +1127,7 @@ export function createWorkspaceStore(options: {
const previousActive = activeWorkspaceId();
const ws = await workspaceForget(id);
setWorkspaces(ws.workspaces);
clearWorkspaceConnectionState(id);
syncActiveWorkspaceId(ws.activeId);
const active = ws.workspaces.find((w) => w.id === ws.activeId) ?? null;
@@ -1120,7 +1255,6 @@ export function createWorkspaceStore(options: {
setWorkspaces(ws.workspaces);
syncActiveWorkspaceId(ws.activeId);
setWorkspacePickerOpen(false);
setCreateWorkspaceOpen(false);
setCreateRemoteWorkspaceOpen(false);
options.setTab("home");
@@ -1709,19 +1843,15 @@ export function createWorkspaceStore(options: {
newAuthorizedDir,
workspaceConfig,
workspaceConfigLoaded,
workspaceSearch,
workspacePickerOpen,
createWorkspaceOpen,
createRemoteWorkspaceOpen,
connectingWorkspaceId,
workspaceConnectionStateById,
exportingWorkspaceConfig,
importingWorkspaceConfig,
activeWorkspaceDisplay,
activeWorkspacePath,
activeWorkspaceRoot,
filteredWorkspaces,
setWorkspaceSearch,
setWorkspacePickerOpen,
setCreateWorkspaceOpen,
setCreateRemoteWorkspaceOpen,
setProjectDir,
@@ -1734,6 +1864,7 @@ export function createWorkspaceStore(options: {
refreshEngine,
refreshEngineDoctor,
activateWorkspace,
testWorkspaceConnection,
connectToServer,
createWorkspaceFlow,
createRemoteWorkspaceFlow,

View File

@@ -188,6 +188,52 @@ export function writeOpenworkServerSettings(next: OpenworkServerSettings): Openw
}
}
export function hydrateOpenworkServerSettingsFromEnv() {
if (typeof window === "undefined") return;
const envUrl = typeof import.meta.env?.VITE_OPENWORK_URL === "string"
? import.meta.env.VITE_OPENWORK_URL.trim()
: "";
const envPort = typeof import.meta.env?.VITE_OPENWORK_PORT === "string"
? import.meta.env.VITE_OPENWORK_PORT.trim()
: "";
const envToken = typeof import.meta.env?.VITE_OPENWORK_TOKEN === "string"
? import.meta.env.VITE_OPENWORK_TOKEN.trim()
: "";
if (!envUrl && !envPort && !envToken) return;
try {
const current = readOpenworkServerSettings();
const next: OpenworkServerSettings = { ...current };
let changed = false;
if (!current.urlOverride && envUrl) {
next.urlOverride = normalizeOpenworkServerUrl(envUrl) ?? undefined;
changed = true;
}
if (!current.portOverride && envPort) {
const parsed = Number(envPort);
if (Number.isFinite(parsed) && parsed > 0) {
next.portOverride = parsed;
changed = true;
}
}
if (!current.token && envToken) {
next.token = envToken;
changed = true;
}
if (changed) {
writeOpenworkServerSettings(next);
}
} catch {
// ignore
}
}
export function clearOpenworkServerSettings() {
if (typeof window === "undefined") return;
try {

View File

@@ -26,7 +26,6 @@ import type { EngineInfo, OpenwrkStatus, OpenworkServerInfo, OwpenbotInfo, Works
import Button from "../components/button";
import OpenWorkLogo from "../components/openwork-logo";
import WorkspaceChip from "../components/workspace-chip";
import McpView from "./mcp";
import PluginsView from "./plugins";
import ScheduledTasksView from "./scheduled";
@@ -36,18 +35,7 @@ import SkillsView from "./skills";
import CommandsView from "./commands";
import StatusBar from "../components/status-bar";
import ProviderAuthModal from "../components/provider-auth-modal";
import {
Command,
Copy,
Check,
Cpu,
Calendar,
Package,
Play,
Plus,
Server,
Terminal,
} from "lucide-solid";
import { Command, Cpu, Calendar, Package, Play, Plus, Server, Terminal } from "lucide-solid";
export type DashboardViewProps = {
tab: DashboardTab;
@@ -102,24 +90,8 @@ export type DashboardViewProps = {
onResetKeybind: (id: string) => void;
onResetAllKeybinds: () => void;
activeWorkspaceDisplay: WorkspaceInfo;
workspaceSearch: string;
setWorkspaceSearch: (value: string) => void;
workspacePickerOpen: boolean;
setWorkspacePickerOpen: (open: boolean) => void;
connectingWorkspaceId: string | null;
workspaces: WorkspaceInfo[];
filteredWorkspaces: WorkspaceInfo[];
activeWorkspaceId: string;
activateWorkspace: (id: string) => Promise<boolean> | boolean;
exportWorkspaceConfig: () => void;
exportWorkspaceBusy: boolean;
createWorkspaceOpen: boolean;
setCreateWorkspaceOpen: (open: boolean) => void;
createWorkspaceFlow: (
preset: "starter" | "automation" | "minimal",
folder: string | null
) => void;
pickWorkspaceFolder: () => Promise<string | null>;
sessions: Array<{
id: string;
slug?: string | null;
@@ -297,8 +269,6 @@ export default function DashboardView(props: DashboardViewProps) {
const [refreshInProgress, setRefreshInProgress] = createSignal(false);
const [taskDraft, setTaskDraft] = createSignal("");
const [providerAuthActionBusy, setProviderAuthActionBusy] = createSignal(false);
const [copiedWorkspaceId, setCopiedWorkspaceId] = createSignal<string | null>(null);
let copyTimeout: number | undefined;
const canCreateTask = createMemo(
() => !props.newTaskDisabled && taskDraft().trim().length > 0
@@ -339,34 +309,9 @@ export default function DashboardView(props: DashboardViewProps) {
};
onCleanup(() => {
if (copyTimeout !== undefined) {
window.clearTimeout(copyTimeout);
}
// no-op
});
const workspacePathLabel = (workspace: WorkspaceInfo) =>
workspace.workspaceType === "remote"
? workspace.baseUrl ?? workspace.path
: workspace.path;
const handleCopyWorkspace = async (workspace: WorkspaceInfo) => {
const value = workspacePathLabel(workspace)?.trim();
if (!value) return;
try {
await navigator.clipboard.writeText(value);
setCopiedWorkspaceId(workspace.id);
if (copyTimeout !== undefined) {
window.clearTimeout(copyTimeout);
}
copyTimeout = window.setTimeout(() => {
setCopiedWorkspaceId(null);
copyTimeout = undefined;
}, 2000);
} catch {
// ignore
}
};
createEffect(() => {
const currentTab = props.tab;
@@ -487,14 +432,9 @@ export default function DashboardView(props: DashboardViewProps) {
</Show>
<Show when={!props.clientConnected}>
<Button
variant="secondary"
onClick={() => props.setWorkspacePickerOpen(true)}
disabled={props.busy}
class="w-full"
>
Connect folder
</Button>
<div class="text-[11px] text-gray-9 px-1">
Add a workspace from the Sessions sidebar to get started.
</div>
</Show>
</div>
</aside>
@@ -502,14 +442,9 @@ export default function DashboardView(props: DashboardViewProps) {
<main class="flex-1 overflow-y-auto relative pb-24 md:pb-12">
<header class="h-16 flex items-center justify-between px-6 md:px-10 border-b border-gray-6 sticky top-0 bg-gray-1/80 backdrop-blur-md z-10">
<div class="flex items-center gap-3">
<WorkspaceChip
workspace={props.activeWorkspaceDisplay}
connecting={props.connectingWorkspaceId === props.activeWorkspaceDisplay.id}
onClick={() => {
props.setWorkspaceSearch("");
props.setWorkspacePickerOpen(true);
}}
/>
<div class="px-3 py-1.5 rounded-xl bg-gray-2 text-xs text-gray-11 font-medium">
{props.activeWorkspaceDisplay.name}
</div>
<h1 class="text-lg font-medium">{title()}</h1>
<Show when={props.developerMode}>
<span class="text-xs text-gray-7">{props.headerStatus}</span>
@@ -666,91 +601,6 @@ export default function DashboardView(props: DashboardViewProps) {
</Show>
</section>
<section>
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-gray-11 uppercase tracking-wider">
Workspaces
</h3>
<div class="flex items-center gap-2">
<Button
variant="outline"
class="text-xs h-8 px-3"
onClick={props.exportWorkspaceConfig}
disabled={!canExportWorkspace() || props.exportWorkspaceBusy}
title={
!canExportWorkspace()
? "Export is only available for local workspaces"
: "Export workspace config"
}
>
Share config
</Button>
<Button
variant="secondary"
class="text-xs h-8 px-3"
onClick={() => props.setWorkspacePickerOpen(true)}
>
<Plus size={14} />
Add workspace
</Button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<For each={props.workspaces}>
{(workspace) => (
<div class="rounded-2xl border border-gray-6/60 bg-gray-1/40 p-4 space-y-3">
<div class="flex items-start justify-between">
<div class="space-y-1 min-w-0">
<div class="text-sm font-semibold text-gray-12 truncate">
{workspace.displayName ?? workspace.name}
</div>
<div class="flex items-center gap-2 text-xs text-gray-10 font-mono">
<span class="truncate min-w-0">
{workspacePathLabel(workspace)}
</span>
<button
type="button"
class="shrink-0 rounded-md p-1 text-gray-9 hover:text-gray-12 hover:bg-gray-3 transition-colors"
onClick={() => handleCopyWorkspace(workspace)}
title={copiedWorkspaceId() === workspace.id ? "Copied" : "Copy path"}
aria-label="Copy workspace path"
>
<Show when={copiedWorkspaceId() === workspace.id} fallback={<Copy size={12} />}>
<Check size={12} class="text-green-11" />
</Show>
</button>
</div>
</div>
<span class="text-[11px] text-gray-9">
{workspace.workspaceType === "remote" ? "Remote" : "Local"}
</span>
</div>
<div class="flex items-center justify-end text-xs text-gray-9 h-8">
<Show when={workspace.id === props.activeWorkspaceId}>
<span class="text-green-11 font-medium flex items-center gap-1.5 !px-2">
Active
</span>
</Show>
<Show when={workspace.id !== props.activeWorkspaceId}>
<Button
variant="ghost"
class="text-xs !px-2 py-1"
onClick={() => props.activateWorkspace(workspace.id)}
disabled={props.connectingWorkspaceId === workspace.id}
>
{props.connectingWorkspaceId === workspace.id
? "Switching..."
: "Switch"}
</Button>
</Show>
</div>
</div>
)}
</For>
</div>
</section>
<section>
<h3 class="text-sm font-medium text-gray-11 uppercase tracking-wider mb-4">
Recent Sessions

View File

@@ -18,6 +18,7 @@ import type {
TodoItem,
View,
WorkspaceCommand,
WorkspaceConnectionState,
WorkspaceDisplay,
} from "../types";
@@ -27,7 +28,6 @@ import { ArrowRight, ChevronDown, HardDrive, Shield, Zap } from "lucide-solid";
import Button from "../components/button";
import RenameSessionModal from "../components/rename-session-modal";
import WorkspaceChip from "../components/workspace-chip";
import ProviderAuthModal from "../components/provider-auth-modal";
import StatusBar from "../components/status-bar";
import type { OpenworkServerStatus } from "../lib/openwork-server";
@@ -53,9 +53,15 @@ export type SessionViewProps = {
workspaces: WorkspaceInfo[];
activeWorkspaceId: string;
connectingWorkspaceId: string | null;
workspaceConnectionStateById: Record<string, WorkspaceConnectionState>;
activateWorkspace: (workspaceId: string) => Promise<boolean> | boolean | void;
setWorkspaceSearch: (value: string) => void;
setWorkspacePickerOpen: (open: boolean) => void;
testWorkspaceConnection: (workspaceId: string) => Promise<boolean> | boolean;
editWorkspaceConnection: (workspaceId: string) => void;
forgetWorkspace: (workspaceId: string) => void;
openCreateWorkspace: () => void;
openCreateRemoteWorkspace: () => void;
importWorkspaceConfig: () => void;
importingWorkspaceConfig: boolean;
clientConnected: boolean;
openworkServerStatus: OpenworkServerStatus;
stopHost: () => void;
@@ -110,7 +116,6 @@ export type SessionViewProps = {
error: string | null;
sessionStatus: string;
renameSession: (sessionId: string, title: string) => Promise<void>;
openConnect: () => void;
startProviderAuth: (providerId?: string) => Promise<string>;
submitProviderApiKey: (providerId: string, apiKey: string) => Promise<string | void>;
openProviderAuthModal: () => Promise<void>;
@@ -1242,11 +1247,6 @@ export default function SessionView(props: SessionViewProps) {
props.setView("dashboard");
};
const openWorkspacePicker = () => {
props.setWorkspaceSearch("");
props.setWorkspacePickerOpen(true);
};
const handleSelectSession = async (workspaceId: string, sessionId: string) => {
const targetWorkspaceId = workspaceId?.trim();
if (!targetWorkspaceId || !sessionId) return;
@@ -1307,11 +1307,9 @@ export default function SessionView(props: SessionViewProps) {
<ArrowRight class="rotate-180 w-5 h-5" />
<span class="hidden md:inline text-xs">Back</span>
</Button>
<WorkspaceChip
workspace={props.activeWorkspaceDisplay}
connecting={props.connectingWorkspaceId === props.activeWorkspaceDisplay.id}
onClick={openWorkspacePicker}
/>
<div class="px-3 py-1.5 rounded-xl bg-gray-2 text-xs text-gray-11 font-medium">
{props.activeWorkspaceDisplay.name}
</div>
<Show when={props.developerMode}>
<span class="text-xs text-gray-7">{props.headerStatus}</span>
</Show>
@@ -1341,8 +1339,15 @@ export default function SessionView(props: SessionViewProps) {
workspaceGroups={sessionWorkspaceGroups()}
activeWorkspaceId={props.activeWorkspaceId}
connectingWorkspaceId={props.connectingWorkspaceId}
workspaceConnectionStateById={props.workspaceConnectionStateById}
onSelectWorkspace={props.activateWorkspace}
onAddWorkspace={openWorkspacePicker}
onCreateWorkspace={props.openCreateWorkspace}
onCreateRemoteWorkspace={props.openCreateRemoteWorkspace}
onImportWorkspace={props.importWorkspaceConfig}
importingWorkspaceConfig={props.importingWorkspaceConfig}
onEditWorkspace={props.editWorkspaceConnection}
onTestWorkspaceConnection={props.testWorkspaceConnection}
onForgetWorkspace={props.forgetWorkspace}
onReorderWorkspace={handleReorderWorkspace}
onSelectSession={handleSelectSession}
selectedSessionId={props.selectedSessionId}

View File

@@ -112,6 +112,14 @@ export type SettingsTab = "general" | "model" | "keybinds" | "advanced" | "remot
export type WorkspacePreset = "starter" | "automation" | "minimal";
export type WorkspaceConnectionStatus = "idle" | "connecting" | "connected" | "error";
export type WorkspaceConnectionState = {
status: WorkspaceConnectionStatus;
message?: string | null;
checkedAt?: number | null;
};
export type ResetOpenworkMode = "onboarding" | "all";
export type CommandScope = "workspace" | "global" | "unknown";

View File

@@ -210,7 +210,7 @@ function withCors(response: Response, request: Request, config: ServerConfig) {
headers.set("Access-Control-Allow-Origin", allowOrigin);
headers.set(
"Access-Control-Allow-Headers",
"Authorization, Content-Type, X-OpenWork-Host-Token, X-OpenWork-Client-Id, X-OpenCode-Directory, X-Opencode-Directory",
"Authorization, Content-Type, X-OpenWork-Host-Token, X-OpenWork-Client-Id, X-OpenCode-Directory, X-Opencode-Directory, x-opencode-directory",
);
headers.set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
headers.set("Vary", "Origin");

160
scripts/dev-headless-web.ts Normal file
View File

@@ -0,0 +1,160 @@
import { spawn } from "node:child_process";
import { openSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { createServer } from "node:net";
import { randomUUID } from "node:crypto";
import path from "node:path";
const cwd = process.cwd();
const tmpDir = path.join(cwd, "tmp");
const ensureTmp = async () => {
await mkdir(tmpDir, { recursive: true });
};
const isPortFree = (port: number, host: string) =>
new Promise<boolean>((resolve) => {
const server = createServer();
server.once("error", () => resolve(false));
server.listen(port, host, () => {
server.close(() => resolve(true));
});
});
const getFreePort = (host: string) =>
new Promise<number>((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, host, () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Unable to resolve free port")));
return;
}
const port = address.port;
server.close(() => resolve(port));
});
});
const resolvePort = async (value: string | undefined, host: string) => {
if (value) {
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed > 0) {
const free = await isPortFree(parsed, host);
if (free) return parsed;
}
}
return await getFreePort(host);
};
const logLine = (message: string) => {
process.stdout.write(`${message}\n`);
};
const spawnLogged = (command: string, args: string[], logPath: string, env: NodeJS.ProcessEnv) => {
const logFd = openSync(logPath, "w");
return spawn(command, args, {
cwd,
env,
stdio: ["ignore", logFd, logFd],
});
};
const shutdown = (label: string, code: number | null, signal: NodeJS.Signals | null) => {
const reason = code !== null ? `code ${code}` : signal ? `signal ${signal}` : "unknown";
logLine(`[dev:headless-web] ${label} exited (${reason})`);
process.exit(code ?? 1);
};
await ensureTmp();
const host = process.env.OPENWORK_HOST ?? "0.0.0.0";
const publicHost = process.env.OPENWORK_PUBLIC_HOST ?? null;
const clientHost = publicHost ?? (host === "0.0.0.0" ? "127.0.0.1" : host);
const workspace = process.env.OPENWORK_WORKSPACE ?? cwd;
const openworkPort = await resolvePort(process.env.OPENWORK_PORT, "127.0.0.1");
const webPort = await resolvePort(process.env.OPENWORK_WEB_PORT, "127.0.0.1");
const openworkToken = process.env.OPENWORK_TOKEN ?? randomUUID();
const openworkHostToken = process.env.OPENWORK_HOST_TOKEN ?? randomUUID();
const openworkServerBin = path.join(cwd, "packages/server/dist/bin/openwork-server");
const openworkUrl = `http://${clientHost}:${openworkPort}`;
const webUrl = `http://${clientHost}:${webPort}`;
const viteEnv = {
...process.env,
HOST: process.env.HOST ?? "0.0.0.0",
PORT: String(webPort),
VITE_OPENWORK_URL: process.env.VITE_OPENWORK_URL ?? openworkUrl,
VITE_OPENWORK_PORT: process.env.VITE_OPENWORK_PORT ?? String(openworkPort),
VITE_OPENWORK_TOKEN: process.env.VITE_OPENWORK_TOKEN ?? openworkToken,
};
const headlessEnv = {
...process.env,
OPENWORK_WORKSPACE: workspace,
OPENWORK_HOST: host,
OPENWORK_PORT: String(openworkPort),
OPENWORK_TOKEN: openworkToken,
OPENWORK_HOST_TOKEN: openworkHostToken,
OPENWORK_SERVER_BIN: openworkServerBin,
};
logLine("[dev:headless-web] Starting services");
logLine(`[dev:headless-web] Workspace: ${workspace}`);
logLine(`[dev:headless-web] OpenWork server: ${openworkUrl}`);
logLine(`[dev:headless-web] Web port: ${webPort}`);
logLine(`[dev:headless-web] Web URL: ${webUrl}`);
logLine(`[dev:headless-web] OPENWORK_TOKEN: ${openworkToken}`);
logLine(`[dev:headless-web] OPENWORK_HOST_TOKEN: ${openworkHostToken}`);
logLine(`[dev:headless-web] Web logs: ${path.relative(cwd, path.join(tmpDir, "dev-web.log"))}`);
logLine(`[dev:headless-web] Headless logs: ${path.relative(cwd, path.join(tmpDir, "dev-headless.log"))}`);
const webProcess = spawnLogged(
"pnpm",
["dev:web"],
path.join(tmpDir, "dev-web.log"),
viteEnv,
);
const headlessProcess = spawnLogged(
"pnpm",
[
"--filter",
"openwrk",
"dev",
"--",
"start",
"--workspace",
workspace,
"--approval",
"auto",
"--allow-external",
"--no-opencode-auth",
"--owpenbot",
"false",
"--openwork-host",
host,
"--openwork-port",
String(openworkPort),
"--openwork-token",
openworkToken,
"--openwork-host-token",
openworkHostToken,
],
path.join(tmpDir, "dev-headless.log"),
headlessEnv,
);
const stopAll = (signal: NodeJS.Signals) => {
webProcess.kill(signal);
headlessProcess.kill(signal);
};
process.on("SIGINT", () => {
stopAll("SIGINT");
});
process.on("SIGTERM", () => {
stopAll("SIGTERM");
});
webProcess.on("exit", (code, signal) => shutdown("web", code, signal));
headlessProcess.on("exit", (code, signal) => shutdown("openwrk", code, signal));