diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index df32470a..22645b55 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -122,6 +122,7 @@ import { createSessionStore } from "./context/session"; import { createExtensionsStore } from "./context/extensions"; import { useGlobalSync } from "./context/global-sync"; import { createWorkspaceStore } from "./context/workspace"; +import { createTargetStore, LocalTarget } from "./context/targets"; import { updaterEnvironment, readOpencodeConfig, @@ -1321,6 +1322,25 @@ export default function App() { engineRuntime, }); + const targetStore = createTargetStore(); + const targetOptions = createMemo(() => [LocalTarget, ...targetStore.targets()]); + const activeTargetId = createMemo(() => targetStore.activeTargetId()); + const defaultTargetId = createMemo(() => targetStore.defaultTargetId()); + const activeSandbox = createMemo(() => { + const sandboxId = workspaceStore.activeSandboxId(); + if (!sandboxId) return null; + return workspaceStore.sandboxes().find((sandbox) => sandbox.id === sandboxId) ?? null; + }); + const activeTargetInfo = createMemo(() => workspaceStore.activeTargetInfo()); + + createEffect(() => { + const info = workspaceStore.activeTargetInfo(); + if (!info) return; + if (info.type === "local") { + targetStore.setActiveTarget(LocalTarget.id); + } + }); + createEffect(() => { if (typeof window === "undefined") return; const workspaceId = workspaceStore.activeWorkspaceId(); @@ -3236,6 +3256,38 @@ export default function App() { } } + const [targetSwitching, setTargetSwitching] = createSignal(false); + + const handleSelectTarget = async (targetId: string) => { + if (targetSwitching()) return; + const target = targetOptions().find((option) => option.id === targetId); + if (!target) return; + if (targetStore.activeTargetId() === targetId) return; + + setTargetSwitching(true); + try { + targetStore.setActiveTarget(targetId); + if (target.type === "remote") { + updateOpenworkServerSettings({ + ...openworkServerSettings(), + urlOverride: target.baseUrl ?? "", + token: target.token ?? undefined, + }); + setStartupPreference("server"); + targetStore.updateTarget(targetId, { lastUsedAt: Date.now() }); + } else { + setStartupPreference("local"); + } + + const ok = await workspaceStore.createSandbox({ source: "base" }); + if (ok) { + await createSessionAndOpen(); + } + } finally { + setTargetSwitching(false); + } + }; + onMount(async () => { const startupPref = readStartupPreference(); @@ -3982,6 +4034,14 @@ export default function App() { openwrkStatus: openwrkStatusState(), owpenbotInfo: owpenbotInfoState(), engineDoctorVersion: workspaceStore.engineDoctorResult()?.version ?? null, + targets: targetOptions(), + activeTargetId: activeTargetId(), + defaultTargetId: defaultTargetId(), + addTarget: targetStore.addTarget, + updateTarget: targetStore.updateTarget, + removeTarget: targetStore.removeTarget, + setDefaultTarget: targetStore.setDefaultTarget, + setActiveTarget: handleSelectTarget, updateOpenworkServerSettings, resetOpenworkServerSettings, testOpenworkServerConnection, @@ -3996,6 +4056,11 @@ export default function App() { setWorkspacePickerOpen: workspaceStore.setWorkspacePickerOpen, connectingWorkspaceId: workspaceStore.connectingWorkspaceId(), workspaces: workspaceStore.workspaces(), + sandboxes: workspaceStore.sandboxes(), + activeSandboxId: workspaceStore.activeSandboxId(), + createSandbox: workspaceStore.createSandbox, + activateSandbox: workspaceStore.activateSandbox, + archiveSandbox: workspaceStore.archiveSandbox, filteredWorkspaces: workspaceStore.filteredWorkspaces(), activeWorkspaceId: workspaceStore.activeWorkspaceId(), activateWorkspace: workspaceStore.activateWorkspace, @@ -4180,6 +4245,8 @@ export default function App() { activeWorkspaceRoot: workspaceStore.activeWorkspaceRoot().trim(), workspaces: workspaceStore.workspaces(), activeWorkspaceId: workspaceStore.activeWorkspaceId(), + activeSandbox: activeSandbox(), + activeTargetInfo: activeTargetInfo(), connectingWorkspaceId: workspaceStore.connectingWorkspaceId(), activateWorkspace: workspaceStore.activateWorkspace, setWorkspaceSearch: workspaceStore.setWorkspaceSearch, @@ -4246,6 +4313,9 @@ export default function App() { providers: providers(), providerConnectedIds: providerConnectedIds(), listAgents: listAgents, + targetOptions: targetOptions(), + activeTargetId: activeTargetId(), + onSelectTarget: handleSelectTarget, selectedSessionAgent: selectedSessionAgent(), setSessionAgent: setSessionAgent, saveSession: saveSessionExport, diff --git a/packages/app/src/app/components/sandbox-chip.tsx b/packages/app/src/app/components/sandbox-chip.tsx new file mode 100644 index 00000000..038f4700 --- /dev/null +++ b/packages/app/src/app/components/sandbox-chip.tsx @@ -0,0 +1,46 @@ +import type { OpenworkSandboxInfo, OpenworkTargetInfo } from "../lib/openwork-server"; + +import { Box, ChevronDown, Globe, HardDrive, Loader2 } from "lucide-solid"; + +const targetLabelFallback = (target: OpenworkTargetInfo | null) => { + if (!target) return "Target"; + return target.type === "remote" ? "Remote" : "Local"; +}; + +export default function SandboxChip(props: { + sandbox: OpenworkSandboxInfo | null; + target: OpenworkTargetInfo | null; + onClick: () => void; + connecting?: boolean; +}) { + const TargetIcon = props.target?.type === "remote" ? Globe : HardDrive; + const status = () => props.sandbox?.status ?? "active"; + const targetLabel = () => props.target?.label?.trim() || targetLabelFallback(props.target); + + return ( + + ); +} diff --git a/packages/app/src/app/components/session/composer.tsx b/packages/app/src/app/components/session/composer.tsx index 6d280205..4f9d69f6 100644 --- a/packages/app/src/app/components/session/composer.tsx +++ b/packages/app/src/app/components/session/composer.tsx @@ -1,8 +1,8 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"; import type { Agent } from "@opencode-ai/sdk/v2/client"; -import { ArrowRight, AtSign, ChevronDown, File, Paperclip, X, Zap } from "lucide-solid"; +import { ArrowRight, AtSign, ChevronDown, File, HardDrive, Paperclip, X, Zap } from "lucide-solid"; -import type { ComposerAttachment, ComposerDraft, ComposerPart, PromptMode } from "../../types"; +import type { ComposerAttachment, ComposerDraft, ComposerPart, PromptMode, TargetProfile } from "../../types"; export type CommandItem = { id: string; @@ -55,6 +55,9 @@ type ComposerProps = { isRemoteWorkspace: boolean; attachmentsEnabled: boolean; attachmentsDisabledReason: string | null; + targetOptions: TargetProfile[]; + activeTargetId: string; + onSelectTarget: (targetId: string) => void | Promise; }; const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024; @@ -276,8 +279,14 @@ export default function Composer(props: ComposerProps) { const [historyIndex, setHistoryIndex] = createSignal({ prompt: -1, shell: -1 }); const [history, setHistory] = createSignal({ prompt: [] as ComposerDraft[], shell: [] as ComposerDraft[] }); const [variantMenuOpen, setVariantMenuOpen] = createSignal(false); + const [targetPickerOpen, setTargetPickerOpen] = createSignal(false); + let targetPickerRef: HTMLDivElement | undefined; const activeVariant = createMemo(() => props.modelVariant ?? "none"); const attachmentsDisabled = createMemo(() => !props.attachmentsEnabled); + const activeTarget = createMemo(() => { + const current = props.targetOptions.find((option) => option.id === props.activeTargetId); + return current ?? props.targetOptions[0] ?? null; + }); onMount(() => { queueMicrotask(() => focusEditorEnd()); @@ -816,6 +825,17 @@ export default function Composer(props: ComposerProps) { onCleanup(() => window.removeEventListener("mousedown", handler)); }); + createEffect(() => { + if (!targetPickerOpen()) return; + const handler = (event: MouseEvent) => { + if (!targetPickerRef) return; + if (targetPickerRef.contains(event.target as Node)) return; + setTargetPickerOpen(false); + }; + window.addEventListener("mousedown", handler); + onCleanup(() => window.removeEventListener("mousedown", handler)); + }); + createEffect(() => { const handler = () => { editorRef?.focus(); @@ -1079,7 +1099,7 @@ export default function Composer(props: ComposerProps) { class="bg-transparent border-none p-0 pb-12 pr-20 text-gray-12 focus:ring-0 text-[15px] leading-relaxed resize-none min-h-[24px] outline-none relative z-10" /> -
+
+ + +
(targetPickerRef = el)}> + + + +
+
+ Run on +
+
+ + {(target) => ( + + )} + +
+
+
+
+
diff --git a/packages/app/src/app/context/targets.ts b/packages/app/src/app/context/targets.ts new file mode 100644 index 00000000..15bd7bcd --- /dev/null +++ b/packages/app/src/app/context/targets.ts @@ -0,0 +1,128 @@ +import { createEffect, createMemo, createSignal } from "solid-js"; +import { createStore } from "solid-js/store"; +import type { TargetProfile } from "../types"; +import { normalizeOpenworkServerUrl } from "../lib/openwork-server"; + +export type TargetStore = ReturnType; + +type TargetState = { + items: TargetProfile[]; + defaultId?: string | null; + lastActiveId?: string | null; +}; + +const STORAGE_KEY = "openwork.executionTargets.v1"; +const LOCAL_TARGET_ID = "tgt-local"; + +const readState = (): TargetState => { + if (typeof window === "undefined") return { items: [] }; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return { items: [] }; + const parsed = JSON.parse(raw) as TargetState; + if (!parsed || !Array.isArray(parsed.items)) return { items: [] }; + return parsed; + } catch { + return { items: [] }; + } +}; + +const writeState = (state: TargetState) => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + // ignore + } +}; + +const generateId = () => { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return `tgt-${crypto.randomUUID()}`; + } + return `tgt-${Math.random().toString(36).slice(2, 10)}`; +}; + +export function createTargetStore() { + const [state, setState] = createStore(readState()); + const [activeTargetId, setActiveTargetId] = createSignal( + state.lastActiveId ?? state.defaultId ?? LOCAL_TARGET_ID, + ); + + createEffect(() => { + writeState({ ...state, lastActiveId: activeTargetId() }); + }); + + const targets = createMemo(() => state.items); + + const addTarget = (input: { label: string; baseUrl: string; token?: string | null }) => { + const normalized = normalizeOpenworkServerUrl(input.baseUrl) ?? ""; + if (!normalized) return null; + const next: TargetProfile = { + id: generateId(), + label: input.label.trim() || normalized, + type: "remote", + baseUrl: normalized, + token: input.token?.trim() || null, + lastUsedAt: Date.now(), + status: "unknown", + }; + setState("items", (items) => [...items, next]); + return next; + }; + + const updateTarget = (id: string, input: Partial>) => { + setState( + "items", + (items) => + items.map((target) => + target.id === id + ? { + ...target, + label: input.label?.trim() ?? target.label, + baseUrl: input.baseUrl ? normalizeOpenworkServerUrl(input.baseUrl) ?? target.baseUrl : target.baseUrl, + token: input.token !== undefined ? input.token : target.token, + lastUsedAt: input.lastUsedAt ?? target.lastUsedAt, + status: input.status ?? target.status, + } + : target, + ), + ); + }; + + const removeTarget = (id: string) => { + setState("items", (items) => items.filter((target) => target.id !== id)); + if (state.defaultId === id) { + setState("defaultId", null); + } + if (activeTargetId() === id) { + setActiveTargetId(LOCAL_TARGET_ID); + } + }; + + const setDefaultTarget = (id: string | null) => { + setState("defaultId", id); + }; + + const setActiveTarget = (id: string) => { + setActiveTargetId(id); + }; + + return { + targets, + activeTargetId, + setActiveTarget, + addTarget, + updateTarget, + removeTarget, + defaultTargetId: () => state.defaultId ?? null, + setDefaultTarget, + storageKey: STORAGE_KEY, + }; +} + +export const LocalTarget = { + id: LOCAL_TARGET_ID, + label: "Local (this device)", + type: "local" as const, +}; diff --git a/packages/app/src/app/context/workspace.ts b/packages/app/src/app/context/workspace.ts index c434bbd7..08d3c9ba 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, @@ -26,6 +26,9 @@ import { type OpenworkServerClient, type OpenworkServerSettings, type OpenworkWorkspaceInfo, + type OpenworkSandboxInfo, + type OpenworkConnectDescriptor, + type OpenworkTargetInfo, } from "../lib/openwork-server"; import { downloadDir, homeDir } from "@tauri-apps/api/path"; import { @@ -120,6 +123,9 @@ export function createWorkspaceStore(options: { const [projectDir, setProjectDir] = createSignal(""); const [workspaces, setWorkspaces] = createSignal([]); const [activeWorkspaceId, setActiveWorkspaceId] = createSignal("starter"); + const [sandboxes, setSandboxes] = createSignal([]); + const [activeSandboxId, setActiveSandboxId] = createSignal(null); + const [activeTargetInfo, setActiveTargetInfo] = createSignal(null); const syncActiveWorkspaceId = (id: string) => { setActiveWorkspaceId(id); @@ -189,6 +195,184 @@ export function createWorkspaceStore(options: { }); }); + const isSandboxWorkspace = (workspace: WorkspaceInfo | null) => { + if (!workspace) return false; + if (workspace.openworkWorkspaceId) return true; + const path = workspace.path?.replace(/\\/g, "/") ?? ""; + const directory = workspace.directory?.replace(/\\/g, "/") ?? ""; + return path.includes("/.openwork/sandboxes/") || directory.includes("/.openwork/sandboxes/"); + }; + + const mapSandboxToWorkspace = ( + sandbox: OpenworkSandboxInfo, + targetType: "local" | "remote", + openworkBaseUrl: string, + ): WorkspaceInfo => { + const isRemote = targetType === "remote"; + return { + id: sandbox.id, + name: sandbox.name, + path: sandbox.path, + preset: "starter", + workspaceType: isRemote ? "remote" : "local", + remoteType: isRemote ? "openwork" : "opencode", + baseUrl: isRemote ? openworkBaseUrl : undefined, + directory: isRemote ? sandbox.path : undefined, + displayName: sandbox.name, + openworkHostUrl: isRemote ? openworkBaseUrl : undefined, + openworkWorkspaceId: sandbox.id, + openworkWorkspaceName: sandbox.name, + }; + }; + + const descriptorAuth = (descriptor: OpenworkConnectDescriptor) => { + const username = descriptor.opencode.username?.trim() ?? ""; + const password = descriptor.opencode.password?.trim() ?? ""; + if (username && password) return { username, password } as OpencodeAuth; + if (descriptor.openwork?.token) { + return { token: descriptor.openwork.token, mode: "openwork" } as OpencodeAuth; + } + return undefined; + }; + + const refreshSandboxes = async () => { + const client = options.openworkServerClient?.(); + if (!client) return; + try { + const response = await client.listSandboxes(); + const items = Array.isArray(response.items) ? response.items : []; + setSandboxes(items); + const activeId = response.activeId ?? null; + setActiveSandboxId(activeId); + const targetType = activeTargetInfo()?.type ?? "local"; + const mapped = items.map((sandbox) => mapSandboxToWorkspace(sandbox, targetType, client.baseUrl)); + if (mapped.length) { + setWorkspaces(mapped); + if (activeId) { + syncActiveWorkspaceId(activeId); + } + } + } catch (error) { + console.warn("[workspace] sandbox refresh failed", error); + } + }; + + createEffect(() => { + const client = options.openworkServerClient?.(); + if (!client) return; + void refreshSandboxes(); + }); + + const connectUsingDescriptor = async (context?: { reason?: string; quiet?: boolean }) => { + const client = options.openworkServerClient?.(); + if (!client) return false; + try { + const descriptor = await client.connectActive(); + const opencodeUrl = descriptor.opencode.connectUrl ?? descriptor.opencode.baseUrl ?? ""; + if (!opencodeUrl) { + options.setError("OpenCode URL missing from descriptor."); + return false; + } + const directory = descriptor.opencode.directory?.trim() ?? ""; + const auth = descriptorAuth(descriptor); + setActiveTargetInfo(descriptor.target ?? null); + setActiveSandboxId(descriptor.sandbox?.id ?? null); + const ok = await connectToServer( + opencodeUrl, + directory || undefined, + { + workspaceId: descriptor.sandbox?.id ?? undefined, + workspaceType: descriptor.target?.type === "remote" ? "remote" : "local", + targetRoot: directory, + reason: context?.reason ?? "descriptor-connect", + }, + auth, + { quiet: context?.quiet }, + ); + if (ok) { + if (directory) { + setProjectDir(directory); + } + await refreshSandboxes(); + } + return ok; + } catch (error) { + const message = error instanceof Error ? error.message : safeStringify(error); + options.setError(addOpencodeCacheHint(message)); + return false; + } + }; + + const createSandbox = async (input: { name?: string | null; source?: "base" | "sandbox" }) => { + const client = options.openworkServerClient?.(); + if (!client) return false; + options.setBusy(true); + options.setBusyLabel("status.creating_workspace"); + options.setBusyStartedAt(Date.now()); + try { + await client.createSandbox({ + name: input.name ?? null, + source: input.source ?? "base", + fromSandboxId: input.source === "sandbox" ? activeSandboxId() : null, + }); + const ok = await connectUsingDescriptor({ reason: "sandbox-create" }); + return ok; + } catch (error) { + const message = error instanceof Error ? error.message : safeStringify(error); + options.setError(addOpencodeCacheHint(message)); + return false; + } finally { + options.setBusy(false); + options.setBusyLabel(null); + options.setBusyStartedAt(null); + } + }; + + const activateSandbox = async (sandboxId: string) => { + const client = options.openworkServerClient?.(); + if (!client) return false; + setConnectingWorkspaceId(sandboxId); + try { + await client.activateSandbox(sandboxId); + const ok = await connectUsingDescriptor({ reason: "sandbox-activate", quiet: false }); + return ok; + } catch (error) { + const message = error instanceof Error ? error.message : safeStringify(error); + options.setError(addOpencodeCacheHint(message)); + return false; + } finally { + setConnectingWorkspaceId(null); + } + }; + + const archiveSandbox = async (sandboxId: string) => { + const client = options.openworkServerClient?.(); + if (!client) return false; + try { + await client.archiveSandbox(sandboxId); + await refreshSandboxes(); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : safeStringify(error); + options.setError(addOpencodeCacheHint(message)); + return false; + } + }; + + const deleteSandbox = async (sandboxId: string) => { + const client = options.openworkServerClient?.(); + if (!client) return false; + try { + await client.deleteSandbox(sandboxId); + await refreshSandboxes(); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : safeStringify(error); + options.setError(addOpencodeCacheHint(message)); + return false; + } + }; + const resolveOpenworkHost = async (input: { hostUrl: string; token?: string | null }) => { const normalized = normalizeOpenworkServerUrl(input.hostUrl) ?? ""; if (!normalized) { @@ -366,6 +550,9 @@ export function createWorkspaceStore(options: { const next = workspaces().find((w) => w.id === id) ?? null; if (!next) return false; + if (isSandboxWorkspace(next)) { + return activateSandbox(next.openworkWorkspaceId ?? next.id); + } const isRemote = next.workspaceType === "remote"; console.log("[workspace] activate", { id: next.id, type: next.workspaceType }); @@ -397,6 +584,7 @@ export function createWorkspaceStore(options: { let resolvedDirectory = next.directory?.trim() ?? ""; let workspaceInfo: OpenworkWorkspaceInfo | null = null; let resolvedAuth: OpencodeAuth | undefined = undefined; + let connectedByDescriptor = false; try { const resolved = await resolveOpenworkHost({ @@ -422,20 +610,23 @@ export function createWorkspaceStore(options: { return false; } - const ok = await connectToServer( - resolvedBaseUrl, - resolvedDirectory || undefined, - { - workspaceId: next.id, - workspaceType: next.workspaceType, - targetRoot: resolvedDirectory ?? "", - reason: "workspace-switch-openwork", - }, - resolvedAuth, - ); + connectedByDescriptor = await connectUsingDescriptor({ reason: "workspace-switch-openwork" }); + if (!connectedByDescriptor) { + const ok = await connectToServer( + resolvedBaseUrl, + resolvedDirectory || undefined, + { + workspaceId: next.id, + workspaceType: next.workspaceType, + targetRoot: resolvedDirectory ?? "", + reason: "workspace-switch-openwork", + }, + resolvedAuth, + ); - if (!ok) { - return false; + if (!ok) { + return false; + } } if (isTauriRuntime()) { @@ -457,7 +648,9 @@ export function createWorkspaceStore(options: { } syncActiveWorkspaceId(id); - setProjectDir(resolvedDirectory || ""); + if (!connectedByDescriptor) { + setProjectDir(resolvedDirectory || ""); + } setWorkspaceConfig(null); setWorkspaceConfigLoaded(true); setAuthorizedDirs([]); @@ -901,19 +1094,23 @@ export function createWorkspaceStore(options: { return false; } - const ok = await connectToServer( - resolvedBaseUrl, - resolvedDirectory || undefined, - { - workspaceType: "remote", - targetRoot: resolvedDirectory ?? "", - reason: "workspace-create-remote", - }, - resolvedAuth, - ); + let connectedByDescriptor = false; + connectedByDescriptor = await connectUsingDescriptor({ reason: "workspace-create-remote" }); + if (!connectedByDescriptor) { + const ok = await connectToServer( + resolvedBaseUrl, + resolvedDirectory || undefined, + { + workspaceType: "remote", + targetRoot: resolvedDirectory ?? "", + reason: "workspace-create-remote", + }, + resolvedAuth, + ); - if (!ok) { - return false; + if (!ok) { + return false; + } } const finalDirectory = options.clientDirectory().trim() || resolvedDirectory || ""; @@ -959,7 +1156,9 @@ export function createWorkspaceStore(options: { syncActiveWorkspaceId(workspaceId); } - setProjectDir(finalDirectory); + if (!connectedByDescriptor) { + setProjectDir(finalDirectory); + } setWorkspaceConfig(null); setWorkspaceConfigLoaded(true); setAuthorizedDirs([]); @@ -1705,6 +1904,9 @@ export function createWorkspaceStore(options: { projectDir, workspaces, activeWorkspaceId, + sandboxes, + activeSandboxId, + activeTargetInfo, authorizedDirs, newAuthorizedDir, workspaceConfig, @@ -1733,14 +1935,20 @@ export function createWorkspaceStore(options: { syncActiveWorkspaceId: syncActiveWorkspaceId, refreshEngine, refreshEngineDoctor, + refreshSandboxes, activateWorkspace, connectToServer, + connectUsingDescriptor, createWorkspaceFlow, createRemoteWorkspaceFlow, forgetWorkspace, pickWorkspaceFolder, exportWorkspaceConfig, importWorkspaceConfig, + createSandbox, + activateSandbox, + archiveSandbox, + deleteSandbox, startHost, stopHost, reloadWorkspaceEngine, diff --git a/packages/app/src/app/lib/openwork-server.ts b/packages/app/src/app/lib/openwork-server.ts index f2b27a18..ba5fc824 100644 --- a/packages/app/src/app/lib/openwork-server.ts +++ b/packages/app/src/app/lib/openwork-server.ts @@ -22,6 +22,9 @@ export type OpenworkServerDiagnostics = { workspaceCount: number; activeWorkspaceId: string | null; workspace: OpenworkWorkspaceInfo | null; + sandboxCount?: number; + activeSandboxId?: string | null; + sandbox?: { id: string; name: string; path: string; status: string } | null; authorizedRoots: string[]; server: { host: string; port: number; configPath?: string | null }; tokenSource: { client: string; host: string }; @@ -53,6 +56,51 @@ export type OpenworkWorkspaceList = { activeId?: string | null; }; +export type OpenworkTargetInfo = { + id: string; + label: string; + type: "local" | "remote"; +}; + +export type OpenworkSandboxInfo = { + id: string; + name: string; + targetId: string; + baseWorkspaceId: string; + path: string; + createdAt: number; + updatedAt: number; + status: "active" | "idle" | "archived"; + sizeBytes?: number; +}; + +export type OpenworkSandboxList = { + items: OpenworkSandboxInfo[]; + activeId?: string | null; +}; + +export type OpenworkConnectDescriptor = { + updatedAt: number; + sandbox: { id: string; name: string; path: string; status: string } | null; + target: OpenworkTargetInfo; + opencode: { + baseUrl?: string; + connectUrl?: string; + directory?: string; + username?: string; + password?: string; + port?: number; + }; + openwork: { + baseUrl: string; + connectUrl: string; + token: string; + hostToken: string; + port: number; + }; + owpenbot: { healthUrl: string; healthPort: number }; +}; + export type OpenworkPluginItem = { spec: string; source: "config" | "dir.project" | "dir.global"; @@ -294,6 +342,38 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s requestJson<{ ok: boolean; version: string; uptimeMs: number }>(baseUrl, "/health", { token, hostToken }), status: () => requestJson(baseUrl, "/status", { token, hostToken }), capabilities: () => requestJson(baseUrl, "/capabilities", { token, hostToken }), + connectActive: () => requestJson(baseUrl, "/connect/active", { token, hostToken }), + listSandboxes: () => requestJson(baseUrl, "/sandboxes", { token, hostToken }), + createSandbox: (payload: { name?: string | null; source?: "base" | "sandbox"; fromSandboxId?: string | null }) => + requestJson<{ activeId: string; sandbox: OpenworkSandboxInfo }>(baseUrl, "/sandboxes", { + token, + hostToken, + method: "POST", + body: payload, + }), + getSandbox: (sandboxId: string) => + requestJson<{ sandbox: OpenworkSandboxInfo }>(baseUrl, `/sandboxes/${encodeURIComponent(sandboxId)}`, { + token, + hostToken, + }), + archiveSandbox: (sandboxId: string) => + requestJson<{ sandbox: OpenworkSandboxInfo }>(baseUrl, `/sandboxes/${encodeURIComponent(sandboxId)}/archive`, { + token, + hostToken, + method: "POST", + }), + deleteSandbox: (sandboxId: string) => + requestJson<{ deleted: boolean; sandbox: OpenworkSandboxInfo }>(baseUrl, `/sandboxes/${encodeURIComponent(sandboxId)}/delete`, { + token, + hostToken, + method: "POST", + }), + activateSandbox: (sandboxId: string) => + requestJson<{ activeId: string; sandbox: OpenworkSandboxInfo }>(baseUrl, `/sandboxes/${encodeURIComponent(sandboxId)}/activate`, { + token, + hostToken, + method: "POST", + }), listWorkspaces: () => requestJson(baseUrl, "/workspaces", { token, hostToken }), activateWorkspace: (workspaceId: string) => requestJson<{ activeId: string; workspace: OpenworkWorkspaceInfo }>( diff --git a/packages/app/src/app/pages/dashboard.tsx b/packages/app/src/app/pages/dashboard.tsx index e47d278b..f6fef6b7 100644 --- a/packages/app/src/app/pages/dashboard.tsx +++ b/packages/app/src/app/pages/dashboard.tsx @@ -10,17 +10,19 @@ import type { ScheduledJob, SkillCard, StartupPreference, + TargetProfile, WorkspaceCommand, View, } from "../types"; import type { McpDirectoryInfo } from "../constants"; -import { formatRelativeTime } from "../utils"; +import { formatBytes, formatRelativeTime } from "../utils"; import type { OpenworkAuditEntry, OpenworkServerCapabilities, OpenworkServerDiagnostics, OpenworkServerSettings, OpenworkServerStatus, + OpenworkSandboxInfo, } from "../lib/openwork-server"; import type { EngineInfo, OpenwrkStatus, OpenworkServerInfo, OwpenbotInfo, WorkspaceInfo } from "../lib/tauri"; @@ -88,6 +90,14 @@ export type DashboardViewProps = { opencodeConnectStatus: OpencodeConnectStatus | null; engineInfo: EngineInfo | null; engineDoctorVersion: string | null; + targets: TargetProfile[]; + activeTargetId: string; + defaultTargetId: string | null; + addTarget: (input: { label: string; baseUrl: string; token?: string | null }) => TargetProfile | null; + updateTarget: (id: string, input: Partial>) => void; + removeTarget: (id: string) => void; + setDefaultTarget: (id: string | null) => void; + setActiveTarget: (id: string) => void; openwrkStatus: OpenwrkStatus | null; owpenbotInfo: OwpenbotInfo | null; updateOpenworkServerSettings: (next: OpenworkServerSettings) => void; @@ -108,6 +118,11 @@ export type DashboardViewProps = { setWorkspacePickerOpen: (open: boolean) => void; connectingWorkspaceId: string | null; workspaces: WorkspaceInfo[]; + sandboxes: OpenworkSandboxInfo[]; + activeSandboxId: string | null; + createSandbox: (input: { name?: string | null; source?: "base" | "sandbox" }) => Promise | boolean; + activateSandbox: (sandboxId: string) => Promise | boolean; + archiveSandbox: (sandboxId: string) => Promise | boolean; filteredWorkspaces: WorkspaceInfo[]; activeWorkspaceId: string; activateWorkspace: (id: string) => Promise | boolean; @@ -282,6 +297,8 @@ export default function DashboardView(props: DashboardViewProps) { const quickCommands = createMemo(() => props.workspaceCommands.slice(0, 3)); const canExportWorkspace = createMemo(() => props.activeWorkspaceDisplay.workspaceType !== "remote"); + const showSandboxes = createMemo(() => props.sandboxes.length > 0); + const activeSandboxId = createMemo(() => props.activeSandboxId); const openSessionFromList = (sessionId: string) => { // Defer view switch to avoid click-through on the same event frame. @@ -667,88 +684,164 @@ export default function DashboardView(props: DashboardViewProps) {
-
-

- Workspaces -

-
- - -
-
+ +
+

+ Workspaces +

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

Sandboxes

+
+ + +
+
+ +
+ + {(sandbox) => ( +
+
+
+
{sandbox.name}
+
+ {sandbox.path} + + + {formatBytes(sandbox.sizeBytes ?? 0)} + - +
+ {sandbox.status} +
+
+ + Active + + + + +
- - {workspace.workspaceType === "remote" ? "Remote" : "Local"} - -
-
- - - Active - - - - -
-
- )} -
-
+ )} + +
+
@@ -992,6 +1085,14 @@ export default function DashboardView(props: DashboardViewProps) { openwrkStatus={props.openwrkStatus} owpenbotInfo={props.owpenbotInfo} engineDoctorVersion={props.engineDoctorVersion} + targets={props.targets} + activeTargetId={props.activeTargetId} + defaultTargetId={props.defaultTargetId} + addTarget={props.addTarget} + updateTarget={props.updateTarget} + removeTarget={props.removeTarget} + setDefaultTarget={props.setDefaultTarget} + setActiveTarget={props.setActiveTarget} updateOpenworkServerSettings={props.updateOpenworkServerSettings} resetOpenworkServerSettings={props.resetOpenworkServerSettings} testOpenworkServerConnection={props.testOpenworkServerConnection} diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index 3c04bcb5..99a1f31a 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -18,6 +18,7 @@ import type { View, WorkspaceCommand, WorkspaceDisplay, + TargetProfile, } from "../types"; import type { WorkspaceInfo } from "../lib/tauri"; @@ -27,9 +28,10 @@ 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 SandboxChip from "../components/sandbox-chip"; import ProviderAuthModal from "../components/provider-auth-modal"; import StatusBar from "../components/status-bar"; -import type { OpenworkServerStatus } from "../lib/openwork-server"; +import type { OpenworkSandboxInfo, OpenworkServerStatus, OpenworkTargetInfo } from "../lib/openwork-server"; import { join } from "@tauri-apps/api/path"; import browserSetupCommandTemplate from "../data/commands/browser-setup.md?raw"; import { opencodeCommandWrite } from "../lib/tauri"; @@ -50,6 +52,8 @@ export type SessionViewProps = { activeWorkspaceRoot: string; workspaces: WorkspaceInfo[]; activeWorkspaceId: string; + activeSandbox: OpenworkSandboxInfo | null; + activeTargetInfo: OpenworkTargetInfo | null; connectingWorkspaceId: string | null; activateWorkspace: (workspaceId: string) => Promise | boolean | void; setWorkspaceSearch: (value: string) => void; @@ -128,6 +132,9 @@ export type SessionViewProps = { commandRegistryItems: () => CommandRegistryItem[]; registerCommand: (command: CommandRegistryItem) => () => void; deleteSession: (sessionId: string) => Promise; + targetOptions: TargetProfile[]; + activeTargetId: string; + onSelectTarget: (targetId: string) => void | Promise; }; type SessionSummary = { id: string; title: string; slug?: string | null }; @@ -1302,11 +1309,23 @@ export default function SessionView(props: SessionViewProps) { - + + } + > + + {props.headerStatus} @@ -1547,6 +1566,9 @@ export default function SessionView(props: SessionViewProps) { isRemoteWorkspace={props.activeWorkspaceDisplay.workspaceType === "remote"} attachmentsEnabled={attachmentsEnabled()} attachmentsDisabledReason={attachmentsDisabledReason()} + targetOptions={props.targetOptions} + activeTargetId={props.activeTargetId} + onSelectTarget={props.onSelectTarget} /> 0}> diff --git a/packages/app/src/app/pages/settings.tsx b/packages/app/src/app/pages/settings.tsx index cd891af9..6fa3d32a 100644 --- a/packages/app/src/app/pages/settings.tsx +++ b/packages/app/src/app/pages/settings.tsx @@ -6,7 +6,7 @@ import Button from "../components/button"; import TextInput from "../components/text-input"; import SettingsKeybinds, { type KeybindSetting } from "../components/settings-keybinds"; import { HardDrive, MessageCircle, PlugZap, RefreshCcw, Shield, Smartphone, X } from "lucide-solid"; -import type { OpencodeConnectStatus, ProviderListItem, SettingsTab, StartupPreference } from "../types"; +import type { OpencodeConnectStatus, ProviderListItem, SettingsTab, StartupPreference, TargetProfile } from "../types"; import { createOpenworkServerClient } from "../lib/openwork-server"; import type { OpenworkAuditEntry, @@ -127,6 +127,14 @@ export type SettingsViewProps = { notionBusy: boolean; connectNotion: () => void; engineDoctorVersion: string | null; + targets: TargetProfile[]; + activeTargetId: string; + defaultTargetId: string | null; + addTarget: (input: { label: string; baseUrl: string; token?: string | null }) => TargetProfile | null; + updateTarget: (id: string, input: Partial>) => void; + removeTarget: (id: string) => void; + setDefaultTarget: (id: string | null) => void; + setActiveTarget: (id: string) => void; }; // Owpenbot Settings Component @@ -910,6 +918,11 @@ export default function SettingsView(props: SettingsViewProps) { const [clientTokenVisible, setClientTokenVisible] = createSignal(false); const [hostTokenVisible, setHostTokenVisible] = createSignal(false); const [copyingField, setCopyingField] = createSignal(null); + const [targetLabel, setTargetLabel] = createSignal(""); + const [targetUrl, setTargetUrl] = createSignal(""); + const [targetToken, setTargetToken] = createSignal(""); + const [targetTokenVisible, setTargetTokenVisible] = createSignal(false); + const [targetError, setTargetError] = createSignal(null); let copyTimeout: number | undefined; createEffect(() => { @@ -946,6 +959,36 @@ export default function SettingsView(props: SettingsViewProps) { } }); + const handleAddTarget = () => { + setTargetError(null); + const label = targetLabel().trim(); + const baseUrl = targetUrl().trim(); + if (!baseUrl) { + setTargetError("Target URL is required."); + return; + } + const added = props.addTarget({ + label: label || baseUrl, + baseUrl, + token: targetToken().trim() || null, + }); + if (!added) { + setTargetError("Enter a valid OpenWork URL."); + return; + } + setTargetLabel(""); + setTargetUrl(""); + setTargetToken(""); + setTargetTokenVisible(false); + }; + + const targetStatusLabel = (target: TargetProfile) => { + if (target.type === "local") return "Local"; + if (target.status === "online") return "Online"; + if (target.status === "offline") return "Offline"; + return "Unknown"; + }; + const reloadAvailabilityReason = createMemo(() => { if (!props.clientConnected) return "Connect to this workspace to reload."; if (!props.canReloadWorkspace) { @@ -1945,6 +1988,125 @@ export default function SettingsView(props: SettingsViewProps) { +
+
+
Execution targets
+
+ Add remote OpenWork targets for the Run on selector. +
+
+ +
+ setTargetLabel(event.currentTarget.value)} + placeholder="Remote build agent" + hint="Optional name for this target." + disabled={props.busy} + /> + setTargetUrl(event.currentTarget.value)} + placeholder="http://10.0.0.12:8787" + hint="Paste the OpenWork server URL for this target." + disabled={props.busy} + /> + +
+ +
+ + +
+ + +
{targetError()}
+
+ +
+ + {(target) => ( +
+
+
{target.label}
+
+ {target.baseUrl ?? (target.type === "local" ? "Local device" : "")} +
+
+ {targetStatusLabel(target)} + + Default + +
+
+
+ + + + + +
+
+ )} +
+
+
+
Engine reload
diff --git a/packages/app/src/app/types.ts b/packages/app/src/app/types.ts index 47b1186b..81ea3d34 100644 --- a/packages/app/src/app/types.ts +++ b/packages/app/src/app/types.ts @@ -284,6 +284,27 @@ export type WorkspaceDisplay = WorkspaceInfo & { name: string; }; +export type TargetProfile = { + id: string; + label: string; + type: "local" | "remote"; + baseUrl?: string | null; + token?: string | null; + lastUsedAt?: number | null; + status?: "online" | "offline" | "unknown"; +}; + +export type SandboxSummary = { + id: string; + name: string; + targetId: string; + path: string; + createdAt: number; + updatedAt: number; + status: "active" | "idle" | "archived"; + sizeBytes?: number; +}; + export type UpdateHandle = { available: boolean; currentVersion: string; diff --git a/packages/desktop/src-tauri/src/openwork_server/mod.rs b/packages/desktop/src-tauri/src/openwork_server/mod.rs index 1fcb5536..5a08449e 100644 --- a/packages/desktop/src-tauri/src/openwork_server/mod.rs +++ b/packages/desktop/src-tauri/src/openwork_server/mod.rs @@ -60,6 +60,10 @@ pub fn start_openwork_server( .first() .map(|path| path.as_str()) .unwrap_or(""); + let (connect_url, mdns_url, lan_url) = build_urls(port); + let target_id = "tgt-local".to_string(); + let target_label = "Local (this device)".to_string(); + let target_type = "local".to_string(); let (mut rx, child) = spawn_openwork_server( app, @@ -77,6 +81,11 @@ pub fn start_openwork_server( opencode_username, opencode_password, owpenbot_health_port, + opencode_base_url, + connect_url.as_deref(), + Some(&target_id), + Some(&target_label), + Some(&target_type), )?; state.child = Some(child); @@ -84,7 +93,6 @@ pub fn start_openwork_server( state.host = Some(host.clone()); state.port = Some(port); state.base_url = Some(format!("http://127.0.0.1:{port}")); - let (connect_url, mdns_url, lan_url) = build_urls(port); state.connect_url = connect_url; state.mdns_url = mdns_url; state.lan_url = lan_url; diff --git a/packages/desktop/src-tauri/src/openwork_server/spawn.rs b/packages/desktop/src-tauri/src/openwork_server/spawn.rs index 15584206..85ccb3a3 100644 --- a/packages/desktop/src-tauri/src/openwork_server/spawn.rs +++ b/packages/desktop/src-tauri/src/openwork_server/spawn.rs @@ -25,6 +25,11 @@ pub fn build_openwork_args( host_token: &str, opencode_base_url: Option<&str>, opencode_directory: Option<&str>, + opencode_connect_url: Option<&str>, + connect_url: Option<&str>, + target_id: Option<&str>, + target_label: Option<&str>, + target_type: Option<&str>, ) -> Vec { let mut args = vec![ "--host".to_string(), @@ -60,6 +65,13 @@ pub fn build_openwork_args( } } + if let Some(connect_url) = opencode_connect_url { + if !connect_url.trim().is_empty() { + args.push("--opencode-connect-url".to_string()); + args.push(connect_url.to_string()); + } + } + if let Some(directory) = opencode_directory { if !directory.trim().is_empty() { args.push("--opencode-directory".to_string()); @@ -67,6 +79,34 @@ pub fn build_openwork_args( } } + if let Some(connect_url) = connect_url { + if !connect_url.trim().is_empty() { + args.push("--connect-url".to_string()); + args.push(connect_url.to_string()); + } + } + + if let Some(target_id) = target_id { + if !target_id.trim().is_empty() { + args.push("--target-id".to_string()); + args.push(target_id.to_string()); + } + } + + if let Some(target_label) = target_label { + if !target_label.trim().is_empty() { + args.push("--target-label".to_string()); + args.push(target_label.to_string()); + } + } + + if let Some(target_type) = target_type { + if !target_type.trim().is_empty() { + args.push("--target-type".to_string()); + args.push(target_type.to_string()); + } + } + args } @@ -82,6 +122,11 @@ pub fn spawn_openwork_server( opencode_username: Option<&str>, opencode_password: Option<&str>, owpenbot_health_port: Option, + opencode_connect_url: Option<&str>, + connect_url: Option<&str>, + target_id: Option<&str>, + target_label: Option<&str>, + target_type: Option<&str>, ) -> Result<(Receiver, CommandChild), String> { let command = match app.shell().sidecar("openwork-server") { Ok(command) => command, @@ -96,6 +141,11 @@ pub fn spawn_openwork_server( host_token, opencode_base_url, opencode_directory, + opencode_connect_url, + connect_url, + target_id, + target_label, + target_type, ); let cwd = workspace_paths .first() diff --git a/packages/headless/src/cli.ts b/packages/headless/src/cli.ts index da00f570..0615541b 100644 --- a/packages/headless/src/cli.ts +++ b/packages/headless/src/cli.ts @@ -1374,6 +1374,9 @@ function printHelp(): void { " --read-only Start OpenWork server in read-only mode", " --cors Comma-separated CORS origins or *", " --connect-host Override LAN host used for pairing URLs", + " --target-id Target id for descriptor", + " --target-label