mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
feat: auto-connect web sessions and sidebar workspace hub (#438)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@ packages/*/node_modules/
|
||||
out/
|
||||
dist/
|
||||
packages/*/dist/
|
||||
tmp/
|
||||
|
||||
# Tauri/Rust
|
||||
packages/desktop/src-tauri/target/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
160
scripts/dev-headless-web.ts
Normal 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));
|
||||
Reference in New Issue
Block a user