diff --git a/.gitignore b/.gitignore index 00c3f22c..6cad5dd9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ packages/*/node_modules/ out/ dist/ packages/*/dist/ +tmp/ # Tauri/Rust packages/desktop/src-tauri/target/ diff --git a/package.json b/package.json index bc1f1593..c71a5b66 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 8e1c94e4..ae380546 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -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 { 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(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())} /> - 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()} - /> - workspaceStore.setCreateWorkspaceOpen(false)} diff --git a/packages/app/src/app/components/session/sidebar.tsx b/packages/app/src/app/components/session/sidebar.tsx index 1fc54257..cf18ef25 100644 --- a/packages/app/src/app/components/session/sidebar.tsx +++ b/packages/app/src/app/components/session/sidebar.tsx @@ -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; 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 >({}); + 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 (
@@ -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) {
+ {workspaceLabel(group.workspace)} @@ -341,12 +371,17 @@ export default function SessionSidebar(props: SidebarProps) {
- + - - Switch}> - Active + + + Needs attention + + + Switch}> + Active +
@@ -378,6 +413,42 @@ export default function SessionSidebar(props: SidebarProps) {
+ +
+ {connectionMessage()} +
+
+
+ + + + + +
0} fallback={ @@ -451,17 +522,57 @@ export default function SessionSidebar(props: SidebarProps) { }} - +
(addWorkspaceMenuRef = el)}> + + +
+ + + +
+
+
diff --git a/packages/app/src/app/components/workspace-picker.tsx b/packages/app/src/app/components/workspace-picker.tsx deleted file mode 100644 index de63d10f..00000000 --- a/packages/app/src/app/components/workspace-picker.tsx +++ /dev/null @@ -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 | 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 ( - -
-
e.stopPropagation()} - > -
-
- - (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" - /> -
-
- -
-
- {translate("dashboard.workspaces")} ({totalCount()}) -
- - - {translate("dashboard.no_workspaces")} -
- } - > - - {(ws) => ( -
- - - - - - - - -
- )} -
- -
- -
-
- - - -
-
-
-
- - ); -} diff --git a/packages/app/src/app/context/workspace.ts b/packages/app/src/app/context/workspace.ts index c434bbd7..2a8b7824 100644 --- a/packages/app/src/app/context/workspace.ts +++ b/packages/app/src/app/context/workspace.ts @@ -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(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(null); + const [workspaceConnectionStateById, setWorkspaceConnectionStateById] = createSignal< + Record + >({}); 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, + ) => { + 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 = {}; + 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, diff --git a/packages/app/src/app/lib/openwork-server.ts b/packages/app/src/app/lib/openwork-server.ts index f2b27a18..8f3308ca 100644 --- a/packages/app/src/app/lib/openwork-server.ts +++ b/packages/app/src/app/lib/openwork-server.ts @@ -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 { diff --git a/packages/app/src/app/pages/dashboard.tsx b/packages/app/src/app/pages/dashboard.tsx index e47d278b..4bce928f 100644 --- a/packages/app/src/app/pages/dashboard.tsx +++ b/packages/app/src/app/pages/dashboard.tsx @@ -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; exportWorkspaceConfig: () => void; exportWorkspaceBusy: boolean; - createWorkspaceOpen: boolean; - setCreateWorkspaceOpen: (open: boolean) => void; - createWorkspaceFlow: ( - preset: "starter" | "automation" | "minimal", - folder: string | null - ) => void; - pickWorkspaceFolder: () => Promise; 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(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) { - +
+ Add a workspace from the Sessions sidebar to get started. +
@@ -502,14 +442,9 @@ export default function DashboardView(props: DashboardViewProps) {
- { - props.setWorkspaceSearch(""); - props.setWorkspacePickerOpen(true); - }} - /> +
+ {props.activeWorkspaceDisplay.name} +

{title()}

{props.headerStatus} @@ -666,91 +601,6 @@ export default function DashboardView(props: DashboardViewProps) { -
-
-

- Workspaces -

-
- - -
-
- -
- - {(workspace) => ( -
-
-
-
- {workspace.displayName ?? workspace.name} -
-
- - {workspacePathLabel(workspace)} - - -
-
- - {workspace.workspaceType === "remote" ? "Remote" : "Local"} - -
-
- - - Active - - - - - -
-
- )} -
-
-
-

Recent Sessions diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index 056b2a74..ef8e63ab 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -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; activateWorkspace: (workspaceId: string) => Promise | boolean | void; - setWorkspaceSearch: (value: string) => void; - setWorkspacePickerOpen: (open: boolean) => void; + testWorkspaceConnection: (workspaceId: string) => Promise | 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; - openConnect: () => void; startProviderAuth: (providerId?: string) => Promise; submitProviderApiKey: (providerId: string, apiKey: string) => Promise; openProviderAuthModal: () => Promise; @@ -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) { - +
+ {props.activeWorkspaceDisplay.name} +
{props.headerStatus} @@ -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} diff --git a/packages/app/src/app/types.ts b/packages/app/src/app/types.ts index 56e23663..4aa5fa83 100644 --- a/packages/app/src/app/types.ts +++ b/packages/app/src/app/types.ts @@ -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"; diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index ab47db70..b1b10279 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -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"); diff --git a/scripts/dev-headless-web.ts b/scripts/dev-headless-web.ts new file mode 100644 index 00000000..fdeec260 --- /dev/null +++ b/scripts/dev-headless-web.ts @@ -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((resolve) => { + const server = createServer(); + server.once("error", () => resolve(false)); + server.listen(port, host, () => { + server.close(() => resolve(true)); + }); + }); + +const getFreePort = (host: string) => + new Promise((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));