From c0bf4ea346a72ae414d0e65b604eba8dfd9366f2 Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 16 Jan 2026 21:26:24 -0800 Subject: [PATCH] Onboarding 1.0: complete workspace-first flow (#31) * docs: add onboarding 1.0 workspace PRD * feat: add folder workspaces onboarding * chore: allow custom release notes in workflow dispatch * feat(onboarding): seed workspace templates, roots UI, and welcome session * feat(onboarding): restore workspace-first onboarding UI --- src/App.tsx | 1517 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 967 insertions(+), 550 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 4e5aafd18..d286ddec6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,10 +51,13 @@ import { relaunch } from "@tauri-apps/plugin-process"; import { getVersion } from "@tauri-apps/api/app"; import Button from "./components/Button"; +import CreateWorkspaceModal from "./components/CreateWorkspaceModal"; import OpenWorkLogo from "./components/OpenWorkLogo"; import PartView from "./components/PartView"; import ThinkingBlock, { type ThinkingStep } from "./components/ThinkingBlock"; import TextInput from "./components/TextInput"; +import WorkspaceChip from "./components/WorkspaceChip"; +import WorkspacePicker from "./components/WorkspacePicker"; import { createClient, unwrap, waitForHealthy } from "./lib/opencode"; import { engineDoctor, @@ -67,11 +70,20 @@ import { pickDirectory, readOpencodeConfig, updaterEnvironment, + + workspaceBootstrap, + workspaceCreate, + workspaceSetActive, + workspaceOpenworkRead, + workspaceOpenworkWrite, + workspaceTemplateDelete, + workspaceTemplateWrite, writeOpencodeConfig, type EngineDoctorResult, type EngineInfo, type OpencodeConfigFile, type UpdaterEnvironment, + type WorkspaceInfo, } from "./lib/tauri"; type Client = ReturnType; @@ -125,6 +137,22 @@ type OnboardingStep = "mode" | "host" | "client" | "connecting"; type DashboardTab = "home" | "sessions" | "templates" | "skills" | "plugins" | "settings"; +type WorkspacePreset = "starter" | "automation" | "minimal"; + +type WorkspaceTemplate = Template & { + scope: "workspace" | "global"; +}; + +type WorkspaceOpenworkConfig = { + version: number; + workspace?: { + name?: string | null; + createdAt?: number | null; + preset?: string | null; + } | null; + authorizedRoots: string[]; +}; + type Template = { id: string; title: string; @@ -452,6 +480,21 @@ function formatRelativeTime(timestampMs: number) { return new Date(timestampMs).toLocaleDateString(); } +function templatePathFromWorkspaceRoot(workspaceRoot: string, templateId: string) { + const root = workspaceRoot.trim().replace(/\/+$/, ""); + const id = templateId.trim(); + if (!root || !id) return null; + return `${root}/.openwork/templates/${id}.json`; +} + +function safeParseJson(raw: string): T | null { + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + function upsertSession(list: Session[], next: Session) { const idx = list.findIndex((s) => s.id === next.id); if (idx === -1) return [...list, next]; @@ -567,9 +610,16 @@ export default function App() { const [engineSource, setEngineSource] = createSignal<"path" | "sidecar">("path"); const [projectDir, setProjectDir] = createSignal(""); + + const [workspaces, setWorkspaces] = createSignal([]); + const [activeWorkspaceId, setActiveWorkspaceId] = createSignal("starter"); + const [authorizedDirs, setAuthorizedDirs] = createSignal([]); const [newAuthorizedDir, setNewAuthorizedDir] = createSignal(""); + const [workspaceConfig, setWorkspaceConfig] = createSignal(null); + const [workspaceConfigLoaded, setWorkspaceConfigLoaded] = createSignal(false); + const [baseUrl, setBaseUrl] = createSignal("http://127.0.0.1:4096"); const [clientDirectory, setClientDirectory] = createSignal(""); @@ -591,11 +641,15 @@ export default function App() { const [prompt, setPrompt] = createSignal(""); const [lastPromptSent, setLastPromptSent] = createSignal(""); - const [templates, setTemplates] = createSignal([]); + const [templates, setTemplates] = createSignal([]); + const [workspaceTemplatesLoaded, setWorkspaceTemplatesLoaded] = createSignal(false); + const [globalTemplatesLoaded, setGlobalTemplatesLoaded] = createSignal(false); + const [templateModalOpen, setTemplateModalOpen] = createSignal(false); const [templateDraftTitle, setTemplateDraftTitle] = createSignal(""); const [templateDraftDescription, setTemplateDraftDescription] = createSignal(""); const [templateDraftPrompt, setTemplateDraftPrompt] = createSignal(""); + const [templateDraftScope, setTemplateDraftScope] = createSignal<"workspace" | "global">("workspace"); const [skills, setSkills] = createSignal([]); const [skillsStatus, setSkillsStatus] = createSignal(null); @@ -609,6 +663,242 @@ export default function App() { const [pluginStatus, setPluginStatus] = createSignal(null); const [activePluginGuide, setActivePluginGuide] = createSignal(null); + const activeWorkspace = createMemo(() => { + const id = activeWorkspaceId(); + return workspaces().find((w) => w.id === id) ?? null; + }); + + const activeWorkspacePath = createMemo(() => activeWorkspace()?.path ?? ""); + + const activeWorkspaceRoot = createMemo(() => { + const ws = activeWorkspace(); + if (!ws) return ""; + const path = ws.path.trim(); + if (!path) return ""; + return path.replace(/\/+$/, ""); + }); + + const defaultWorkspaceTemplates = createMemo(() => [ + { + id: "tmpl_understand_workspace", + title: "Understand this workspace", + description: "Explains local vs global tools", + prompt: + "Explain how this workspace is configured and what tools are available locally. Be concise and actionable.", + createdAt: 0, + scope: "workspace", + }, + { + id: "tmpl_create_skill", + title: "Create a new skill", + description: "Guide to adding capabilities", + prompt: "I want to create a new skill for this workspace. Guide me through it.", + createdAt: 0, + scope: "workspace", + }, + { + id: "tmpl_run_scheduled_task", + title: "Run a scheduled task", + description: "Demo of the scheduler plugin", + prompt: "Show me how to schedule a task to run every morning.", + createdAt: 0, + scope: "workspace", + }, + { + id: "tmpl_task_to_template", + title: "Turn task into template", + description: "Save workflow for later", + prompt: "Help me turn the last task into a reusable template.", + createdAt: 0, + scope: "workspace", + }, + ]); + + const workspaceTemplates = createMemo(() => { + const explicit = templates().filter((t) => t.scope === "workspace"); + if (explicit.length) return explicit; + return workspaceTemplatesLoaded() ? [] : defaultWorkspaceTemplates(); + }); + + const globalTemplates = createMemo(() => templates().filter((t) => t.scope === "global")); + + const activeWorkspaceDisplay = createMemo(() => { + const ws = activeWorkspace(); + if (!ws) { + return { + id: "starter", + name: "Workspace", + path: "", + preset: "starter", + } satisfies WorkspaceInfo; + } + return ws; + }); + + const showWorkspacePicker = createSignal(false); + const showCreateWorkspaceModal = createSignal(false); + + const [workspacePickerOpen, setWorkspacePickerOpen] = showWorkspacePicker; + const [createWorkspaceOpen, setCreateWorkspaceOpen] = showCreateWorkspaceModal; + + const [workspaceSearch, setWorkspaceSearch] = createSignal(""); + + const filteredWorkspaces = createMemo(() => { + const query = workspaceSearch().trim().toLowerCase(); + if (!query) return workspaces(); + + return workspaces().filter((w) => { + const haystack = `${w.name} ${w.path}`.toLowerCase(); + return haystack.includes(query); + }); + }); + + async function activateWorkspace(workspaceId: string) { + const id = workspaceId.trim(); + if (!id) return; + + const next = workspaces().find((w) => w.id === id) ?? null; + if (!next) return; + + setActiveWorkspaceId(id); + setProjectDir(next.path); + + // Load workspace-scoped OpenWork config (authorized roots, metadata). + if (isTauriRuntime()) { + setWorkspaceConfigLoaded(false); + try { + const cfg = await workspaceOpenworkRead({ workspacePath: next.path }); + setWorkspaceConfig(cfg); + setWorkspaceConfigLoaded(true); + + const roots = Array.isArray(cfg.authorizedRoots) ? cfg.authorizedRoots : []; + if (roots.length) { + setAuthorizedDirs(roots); + } else { + setAuthorizedDirs([next.path]); + } + } catch { + setWorkspaceConfig(null); + setWorkspaceConfigLoaded(true); + setAuthorizedDirs([next.path]); + } + + try { + await workspaceSetActive(id); + } catch { + // ignore + } + } else { + // Web runtime: at least keep the current workspace root in memory. + if (!authorizedDirs().includes(next.path)) { + setAuthorizedDirs((current) => { + const merged = current.length ? current.slice() : []; + if (!merged.includes(next.path)) merged.push(next.path); + return merged; + }); + } + } + + await loadWorkspaceTemplates({ workspaceRoot: next.path }).catch(() => undefined); + + if (mode() === "host" && engine()?.running && engine()?.baseUrl) { + // Already connected to an engine; keep current connection for now. + // Future: support multi-workspace host connections. + return; + } + } + + async function loadWorkspaceTemplates(options?: { workspaceRoot?: string; quiet?: boolean }) { + const c = client(); + const root = (options?.workspaceRoot ?? activeWorkspaceRoot()).trim(); + if (!c || !root) return; + + try { + const templatesPath = ".openwork/templates"; + const nodes = unwrap(await c.file.list({ directory: root, path: templatesPath })); + const jsonFiles = nodes + .filter((n) => n.type === "file" && !n.ignored) + .filter((n) => n.name.toLowerCase().endsWith(".json")); + + const loaded: WorkspaceTemplate[] = []; + + for (const node of jsonFiles) { + const content = unwrap(await c.file.read({ directory: root, path: node.path })); + if (content.type !== "text") continue; + + const parsed = safeParseJson & Record>(content.content); + if (!parsed) continue; + + const title = typeof parsed.title === "string" ? parsed.title : "Untitled"; + const prompt = typeof parsed.prompt === "string" ? parsed.prompt : ""; + if (!prompt.trim()) continue; + + loaded.push({ + id: typeof parsed.id === "string" ? parsed.id : node.name.replace(/\.json$/i, ""), + title, + description: typeof parsed.description === "string" ? parsed.description : "", + prompt, + createdAt: typeof parsed.createdAt === "number" ? parsed.createdAt : Date.now(), + scope: "workspace", + }); + } + + const stable = loaded.slice().sort((a, b) => b.createdAt - a.createdAt); + + setTemplates((current) => { + const globals = current.filter((t) => t.scope === "global"); + return [...stable, ...globals]; + }); + setWorkspaceTemplatesLoaded(true); + } catch (e) { + setWorkspaceTemplatesLoaded(true); + if (!options?.quiet) { + setError(e instanceof Error ? e.message : safeStringify(e)); + } + } + } + + async function createWorkspaceFlow(preset: WorkspacePreset) { + if (!isTauriRuntime()) { + setError("Workspace creation requires the Tauri app runtime."); + return; + } + + try { + const selection = await pickDirectory({ title: "Choose workspace folder" }); + const folder = + typeof selection === "string" ? selection : Array.isArray(selection) ? selection[0] : null; + + if (!folder) return; + + setBusy(true); + setBusyLabel("Creating workspace"); + setBusyStartedAt(Date.now()); + setError(null); + + const name = folder.split("/").filter(Boolean).pop() ?? "Workspace"; + const ws = await workspaceCreate({ folderPath: folder, name, preset }); + setWorkspaces(ws.workspaces); + setActiveWorkspaceId(ws.activeId); + + const active = ws.workspaces.find((w) => w.id === ws.activeId) ?? null; + if (active) { + setProjectDir(active.path); + setAuthorizedDirs([active.path]); + await loadWorkspaceTemplates({ workspaceRoot: active.path, quiet: true }).catch(() => undefined); + } + + setWorkspacePickerOpen(false); + setCreateWorkspaceOpen(false); + } catch (e) { + setError(e instanceof Error ? e.message : safeStringify(e)); + } finally { + setBusy(false); + setBusyLabel(null); + setBusyStartedAt(null); + } + } + const [sidebarPluginList, setSidebarPluginList] = createSignal([]); const [sidebarPluginStatus, setSidebarPluginStatus] = createSignal(null); @@ -620,7 +910,6 @@ export default function App() { const [events, setEvents] = createSignal([]); const [developerMode, setDeveloperMode] = createSignal(false); - const [devTapCount, setDevTapCount] = createSignal(0); const [providers, setProviders] = createSignal([]); const [providerDefaults, setProviderDefaults] = createSignal>({}); @@ -1010,6 +1299,11 @@ export default function App() { return true; }); + // Keep this mounted so the reload banner UX remains in the app. + createEffect(() => { + reloadRequired(); + }); + async function reloadEngineInstance() { const c = client(); if (!c) return; @@ -1032,12 +1326,21 @@ export default function App() { await waitForHealthy(c, { timeoutMs: 12_000 }); try { - const cfg = unwrap(await c.config.providers()); - setProviders(cfg.providers); - setProviderDefaults(cfg.default); + const providerList = unwrap(await c.provider.list()); + setProviders(providerList.all as unknown as Provider[]); + setProviderDefaults(providerList.default); + setProviderConnectedIds(providerList.connected); } catch { - setProviders([]); - setProviderDefaults({}); + try { + const cfg = unwrap(await c.config.providers()); + setProviders(cfg.providers); + setProviderDefaults(cfg.default); + setProviderConnectedIds([]); + } catch { + setProviders([]); + setProviderDefaults({}); + setProviderConnectedIds([]); + } } await refreshPlugins().catch(() => undefined); @@ -1184,22 +1487,9 @@ export default function App() { if (!isTauriRuntime()) return; try { - const result = await engineDoctor({ preferSidecar: engineSource() === "sidecar" }); + const result = await engineDoctor(); setEngineDoctorResult(result); setEngineDoctorCheckedAt(Date.now()); - - if (developerMode()) { - const details = [ - `found=${result.found}`, - `inPath=${result.inPath}`, - `resolvedPath=${result.resolvedPath ?? ""}`, - `version=${result.version ?? ""}`, - `supportsServe=${result.supportsServe}`, - `serveHelpStatus=${result.serveHelpStatus ?? ""}`, - ]; - const notes = result.notes?.length ? "\nnotes:\n" + result.notes.join("\n") : ""; - setEngineInstallLogs(details.join("\n") + notes); - } } catch (e) { setEngineDoctorResult(null); setEngineDoctorCheckedAt(Date.now()); @@ -1263,6 +1553,48 @@ export default function App() { setMessages([]); setTodos([]); + // Auto-create a first-run onboarding session in the active workspace. + try { + if (isTauriRuntime() && activeWorkspaceRoot().trim()) { + const wsRoot = activeWorkspaceRoot().trim(); + const storedKey = `openwork.welcomeSessionCreated:${wsRoot}`; + + let already = false; + try { + already = window.localStorage.getItem(storedKey) === "1"; + } catch { + // ignore + } + + if (!already) { + const session = unwrap(await nextClient.session.create({ directory: wsRoot, title: "Welcome to OpenWork" })); + await nextClient.session.promptAsync({ + directory: wsRoot, + sessionID: session.id, + model: defaultModel(), + variant: modelVariant() ?? undefined, + parts: [ + { + type: "text", + text: + "Load the `workspace_guide` skill from this workspace and explain, in plain language, what lives in this folder (skills/plugins/templates) and what’s global. Then suggest 2 quick next actions the user can do in OpenWork.", + }, + ], + }); + + try { + window.localStorage.setItem(storedKey, "1"); + } catch { + // ignore + } + + await loadSessions(nextClient).catch(() => undefined); + } + } + } catch { + // ignore onboarding session failures + } + setView("dashboard"); setTab("home"); refreshSkills().catch(() => undefined); @@ -1279,20 +1611,20 @@ export default function App() { } } - async function startHost() { + async function startHost(options?: { workspacePath?: string }) { if (!isTauriRuntime()) { setError("Host mode requires the Tauri app runtime. Use `pnpm dev`."); return false; } - const dir = projectDir().trim(); + const dir = (options?.workspacePath ?? activeWorkspacePath() ?? projectDir()).trim(); if (!dir) { - setError("Pick a folder path to start OpenCode in."); + setError("Pick a workspace folder to start OpenCode in."); return false; } try { - const result = await engineDoctor({ preferSidecar: engineSource() === "sidecar" }); + const result = await engineDoctor(); setEngineDoctorResult(result); setEngineDoctorCheckedAt(Date.now()); @@ -1304,12 +1636,7 @@ export default function App() { } if (!result.supportsServe) { - const details = developerMode() - ? `\n\nResolved: ${result.resolvedPath ?? "(unknown)"}\nExit: ${result.serveHelpStatus ?? "(unknown)"}\n${result.serveHelpStderr ? `\n${result.serveHelpStderr}` : ""}` - : ""; - setError( - `OpenCode CLI is installed, but \`opencode serve\` is unavailable. Update OpenCode and retry.${details}`, - ); + setError("OpenCode CLI is installed, but `opencode serve` is unavailable. Update OpenCode and retry."); return false; } } catch (e) { @@ -1322,8 +1649,14 @@ export default function App() { setBusyStartedAt(Date.now()); try { - const info = await engineStart(dir, { preferSidecar: engineSource() === "sidecar" }); - setEngine(info); + // Keep legacy state in sync for now. + setProjectDir(dir); + if (!authorizedDirs().length) { + setAuthorizedDirs([dir]); + } + + const info = await engineStart(dir, { preferSidecar: engineSource() === "sidecar" }); + setEngine(info); if (info.baseUrl) { const ok = await connectToServer(info.baseUrl, info.projectDir ?? undefined); @@ -1493,36 +1826,98 @@ export default function App() { setTemplateDraftTitle(seedTitle); setTemplateDraftDescription(""); setTemplateDraftPrompt(seedPrompt); + setTemplateDraftScope("workspace"); setTemplateModalOpen(true); } - function saveTemplate() { + async function saveTemplate() { const title = templateDraftTitle().trim(); const promptText = templateDraftPrompt().trim(); const description = templateDraftDescription().trim(); + const scope = templateDraftScope(); if (!title || !promptText) { setError("Template title and prompt are required."); return; } - const template: Template = { - id: `tmpl_${Date.now()}`, - title, - description, - prompt: promptText, - createdAt: Date.now(), - }; + if (scope === "workspace") { + if (!isTauriRuntime()) { + setError("Workspace templates require the desktop app."); + return; + } + if (!activeWorkspacePath().trim()) { + setError("Pick a workspace folder first."); + return; + } + } - setTemplates((current) => [template, ...current]); - setTemplateModalOpen(false); + setBusy(true); + setBusyLabel(scope === "workspace" ? "Saving workspace template" : "Saving template"); + setBusyStartedAt(Date.now()); + setError(null); + + try { + const template: WorkspaceTemplate = { + id: `tmpl_${Date.now()}`, + title, + description, + prompt: promptText, + createdAt: Date.now(), + scope, + }; + + if (scope === "workspace") { + const workspaceRoot = activeWorkspacePath().trim(); + await workspaceTemplateWrite({ workspacePath: workspaceRoot, template }); + await loadWorkspaceTemplates({ workspaceRoot, quiet: true }); + } else { + setTemplates((current) => [template, ...current]); + setGlobalTemplatesLoaded(true); + } + + setTemplateModalOpen(false); + } catch (e) { + setError(e instanceof Error ? e.message : safeStringify(e)); + } finally { + setBusy(false); + setBusyLabel(null); + setBusyStartedAt(null); + } } - function deleteTemplate(templateId: string) { + async function deleteTemplate(templateId: string) { + const scope = templates().find((t) => t.id === templateId)?.scope; + + if (scope === "workspace") { + if (!isTauriRuntime()) return; + const workspaceRoot = activeWorkspacePath().trim(); + if (!workspaceRoot) return; + + setBusy(true); + setBusyLabel("Deleting template"); + setBusyStartedAt(Date.now()); + setError(null); + + try { + await workspaceTemplateDelete({ workspacePath: workspaceRoot, templateId }); + await loadWorkspaceTemplates({ workspaceRoot, quiet: true }); + } catch (e) { + setError(e instanceof Error ? e.message : safeStringify(e)); + } finally { + setBusy(false); + setBusyLabel(null); + setBusyStartedAt(null); + } + + return; + } + setTemplates((current) => current.filter((t) => t.id !== templateId)); + setGlobalTemplatesLoaded(true); } - async function runTemplate(template: Template) { + async function runTemplate(template: WorkspaceTemplate) { const c = client(); if (!c) return; @@ -1561,7 +1956,7 @@ export default function App() { try { setSkillsStatus(null); - const nodes = unwrap(await c.file.list({ path: ".opencode/skill" })); + const nodes = unwrap(await c.file.list({ directory: activeWorkspaceRoot().trim(), path: ".opencode/skill" })); const dirs = nodes.filter((n) => n.type === "directory" && !n.ignored); const next: SkillCard[] = []; @@ -1570,9 +1965,12 @@ export default function App() { let description: string | undefined; try { - const skillDoc = unwrap( - await c.file.read({ path: `.opencode/skill/${dir.name}/SKILL.md` }), - ); + const skillDoc = unwrap( + await c.file.read({ + directory: activeWorkspaceRoot().trim(), + path: `.opencode/skill/${dir.name}/SKILL.md`, + }), + ); if (skillDoc.type === "text") { const lines = skillDoc.content.split("\n"); @@ -1689,14 +2087,13 @@ export default function App() { $schema: "https://opencode.ai/config.json", plugin: [pluginName], }; - await writeOpencodeConfig(scope, targetDir, `${JSON.stringify(payload, null, 2)}\n`); - markReloadRequired("plugins"); - if (isManualInput) { - setPluginInput(""); - } - await refreshPlugins(scope); - return; - + await writeOpencodeConfig(scope, targetDir, `${JSON.stringify(payload, null, 2)}\n`); + markReloadRequired("plugins"); + if (isManualInput) { + setPluginInput(""); + } + await refreshPlugins(scope); + return; } const parsed = parse(raw) as Record | undefined; @@ -1836,19 +2233,83 @@ export default function App() { } } - function addAuthorizedDir() { + async function respondPermissionAndRemember(requestID: string, reply: "once" | "always" | "reject") { + // Intentional no-op: permission prompts grant session-scoped access only. + // Persistent workspace roots must be managed explicitly via workspace settings. + await respondPermission(requestID, reply); + } + + async function persistAuthorizedRoots(nextRoots: string[]) { + if (!isTauriRuntime()) return; + const root = activeWorkspacePath().trim(); + if (!root) return; + + const existing = workspaceConfig(); + const cfg: WorkspaceOpenworkConfig = { + version: existing?.version ?? 1, + workspace: existing?.workspace ?? null, + authorizedRoots: nextRoots, + }; + + await workspaceOpenworkWrite({ workspacePath: root, config: cfg }); + setWorkspaceConfig(cfg); + } + + function normalizeRoots(list: string[]) { + const out: string[] = []; + for (const entry of list) { + const trimmed = entry.trim().replace(/\/+$/, ""); + if (!trimmed) continue; + if (!out.includes(trimmed)) out.push(trimmed); + } + return out; + } + + async function addAuthorizedDir() { const next = newAuthorizedDir().trim(); if (!next) return; - setAuthorizedDirs((current) => { - if (current.includes(next)) return current; - return [...current, next]; - }); + const roots = normalizeRoots([...authorizedDirs(), next]); + setAuthorizedDirs(roots); setNewAuthorizedDir(""); + + try { + await persistAuthorizedRoots(roots); + } catch (e) { + setError(e instanceof Error ? e.message : safeStringify(e)); + } } - function removeAuthorizedDir(index: number) { - setAuthorizedDirs((current) => current.filter((_, i) => i !== index)); + async function addAuthorizedDirFromPicker(options?: { persistToWorkspace?: boolean }) { + if (!isTauriRuntime()) return; + + try { + const selection = await pickDirectory({ title: "Add folder" }); + const path = + typeof selection === "string" ? selection : Array.isArray(selection) ? selection[0] : null; + + if (!path) return; + + const roots = normalizeRoots([...authorizedDirs(), path]); + setAuthorizedDirs(roots); + + if (options?.persistToWorkspace) { + await persistAuthorizedRoots(roots); + } + } catch (e) { + setError(e instanceof Error ? e.message : safeStringify(e)); + } + } + + async function removeAuthorizedDir(index: number) { + const roots = authorizedDirs().filter((_, i) => i !== index); + setAuthorizedDirs(roots); + + try { + await persistAuthorizedRoots(roots); + } catch (e) { + setError(e instanceof Error ? e.message : safeStringify(e)); + } } onMount(async () => { @@ -1869,8 +2330,9 @@ export default function App() { setClientDirectory(storedClientDir); } + // Legacy: projectDir is now derived from the active workspace. const storedProjectDir = window.localStorage.getItem("openwork.projectDir"); - if (storedProjectDir) { + if (storedProjectDir && !projectDir().trim()) { setProjectDir(storedProjectDir); } @@ -1887,13 +2349,31 @@ export default function App() { } } + // Legacy (pre-workspace templates): normalize any stored templates into global templates. const storedTemplates = window.localStorage.getItem("openwork.templates"); - if (storedTemplates) { - const parsed = JSON.parse(storedTemplates) as unknown; - if (Array.isArray(parsed)) { - setTemplates(parsed as Template[]); - } - } + if (storedTemplates) { + const parsed = JSON.parse(storedTemplates) as unknown; + if (Array.isArray(parsed)) { + const normalized = (parsed as unknown[]) + .filter((v) => v && typeof v === "object") + .map((entry) => { + const record = entry as Record; + return { + id: typeof record.id === "string" ? record.id : `tmpl_${Date.now()}`, + title: typeof record.title === "string" ? record.title : "Untitled", + description: typeof record.description === "string" ? record.description : "", + prompt: typeof record.prompt === "string" ? record.prompt : "", + createdAt: typeof record.createdAt === "number" ? record.createdAt : Date.now(), + scope: "global" as const, + } satisfies WorkspaceTemplate; + }) + .filter((t) => t.prompt.trim().length > 0); + + setTemplates(normalized); + } + } + + setGlobalTemplatesLoaded(true); const storedDefaultModel = window.localStorage.getItem(MODEL_PREF_KEY); const parsedDefaultModel = parseModelRef(storedDefaultModel); @@ -1949,6 +2429,9 @@ export default function App() { // ignore } + // Mark global templates as loaded even if nothing was stored. + setGlobalTemplatesLoaded(true); + try { setUpdateEnv(await updaterEnvironment()); } catch { @@ -1967,13 +2450,46 @@ export default function App() { await refreshEngine(); await refreshEngineDoctor(); - const info = engine(); - if (info?.baseUrl) { - setBaseUrl(info.baseUrl); - } + // Bootstrap workspaces (Host mode only). + if (isTauriRuntime()) { + try { + const ws = await workspaceBootstrap(); + setWorkspaces(ws.workspaces); + setActiveWorkspaceId(ws.activeId); + const active = ws.workspaces.find((w) => w.id === ws.activeId) ?? null; + if (active) { + setProjectDir(active.path); + if (isTauriRuntime()) { + try { + const cfg = await workspaceOpenworkRead({ workspacePath: active.path }); + setWorkspaceConfig(cfg); + setWorkspaceConfigLoaded(true); + const roots = Array.isArray(cfg.authorizedRoots) ? cfg.authorizedRoots : []; + setAuthorizedDirs(roots.length ? roots : [active.path]); + } catch { + setWorkspaceConfig(null); + setWorkspaceConfigLoaded(true); + setAuthorizedDirs([active.path]); + } + } else if (!authorizedDirs().length) { + setAuthorizedDirs([active.path]); + } + + await loadWorkspaceTemplates({ workspaceRoot: active.path, quiet: true }).catch(() => undefined); + } + } catch { + // ignore + } + } + + const info = engine(); + if (info?.baseUrl) { + setBaseUrl(info.baseUrl); + } + + // Auto-continue based on saved preference. + if (!modePref) return; - // Auto-continue based on saved preference. - if (!modePref) return; if (modePref === "host") { setMode("host"); @@ -1988,23 +2504,23 @@ export default function App() { return; } - if (isTauriRuntime() && projectDir().trim()) { - if (!authorizedDirs().length && projectDir().trim()) { - setAuthorizedDirs([projectDir().trim()]); - } + if (isTauriRuntime() && activeWorkspacePath().trim()) { + if (!authorizedDirs().length && activeWorkspacePath().trim()) { + setAuthorizedDirs([activeWorkspacePath().trim()]); + } - setOnboardingStep("connecting"); - const ok = await startHost(); - if (!ok) { - setOnboardingStep("host"); - } - return; - } + setOnboardingStep("connecting"); + const ok = await startHost({ workspacePath: activeWorkspacePath().trim() }); + if (!ok) { + setOnboardingStep("host"); + } + return; + } - // Missing required info; take them directly to Host setup. - setOnboardingStep("host"); - return; - } + // Missing required info; take them directly to Host setup. + setOnboardingStep("host"); + return; + } // Client preference. setMode("client"); @@ -2050,6 +2566,7 @@ export default function App() { createEffect(() => { if (typeof window === "undefined") return; + // Legacy key: keep for backwards compatibility. try { window.localStorage.setItem("openwork.projectDir", projectDir()); } catch { @@ -2064,14 +2581,11 @@ export default function App() { } catch { // ignore } - - if (isTauriRuntime()) { - void refreshEngineDoctor(); - } }); createEffect(() => { if (typeof window === "undefined") return; + // Legacy persistence; workspace config is authoritative in the desktop app. try { window.localStorage.setItem("openwork.authorizedDirs", JSON.stringify(authorizedDirs())); } catch { @@ -2081,8 +2595,21 @@ export default function App() { createEffect(() => { if (typeof window === "undefined") return; + if (!globalTemplatesLoaded()) return; + try { - window.localStorage.setItem("openwork.templates", JSON.stringify(templates())); + const payload = templates() + .filter((t) => t.scope === "global") + .map((t) => ({ + id: t.id, + title: t.title, + description: t.description, + prompt: t.prompt, + createdAt: t.createdAt, + scope: t.scope, + })); + + window.localStorage.setItem("openwork.templates", JSON.stringify(payload)); } catch { // ignore } @@ -2413,103 +2940,66 @@ export default function App() {
-
- +
+
-

{ - if (developerMode()) return; - setDevTapCount((n) => { - const next = n + 1; - if (next >= 7) { - setDeveloperMode(true); - setDevTapCount(0); - setEngineInstallLogs( - "Developer mode enabled. You can now view engine diagnostics and switch engine source.", - ); - return 0; - } - return next; - }); - }} - > - Authorized Workspaces -

+

Create your first workspace

- OpenWork runs locally. Select which folders it is allowed to access. + A workspace is a folder with its own skills, plugins, and templates.

-
-
-
Project folder
+
+
Workspace
+ +
+
Starter Workspace
+
+ OpenWork will create a ready-to-run folder and start OpenCode inside it. +
+
{activeWorkspacePath() || "(initializing...)"}
-
- setProjectDir(e.currentTarget.value)} - /> - - - -
-
- {isTauriRuntime() - ? "Engine will start in this folder." - : "Host mode requires the Tauri app runtime."} + +
+
What you get
+
+
+
+ Scheduler plugin (workspace-scoped) +
+
+
+ Starter templates ("Understand this workspace", etc.) +
+
+
+ You can add more folders when prompted +
+
+ + +
+ Authorized folders live in .opencode/openwork.json and can be updated here anytime. +
+
- - {(folder, idx) => ( -
-
- - {folder} -
- -
- )} -
- - -
- No authorized folders yet. Add at least your project folder. -
-
-
- -
- -
-
Engine source
-
- - -
-
- PATH uses your installed OpenCode. Sidecar uses a bundled binary when available. -
-
-
- -
-
-
-
OpenCode CLI
- - - Dev - - -
-
- - Checking install…} - > - Not found. Install to run Host mode.} - > - - {engineDoctorResult()?.version ?? "Installed"} - - - · - - {engineDoctorResult()?.resolvedPath} - - - - -
-
- - -
- - -
-
- {engineDoctorResult()?.supportsServe === false - ? "OpenCode was found, but it does not support `opencode serve`. Install/upgrade and retry, or switch to Sidecar." - : "Install one of these:"} -
- -
- brew install anomalyco/tap/opencode -
-
- curl -fsSL https://opencode.ai/install | bash -
- -
+ +
+ + {(dir, idx) => ( +
+
{dir}
- -
-
-
- - -
{engineInstallLogs()}
-
- - -
- Last checked {new Date(engineDoctorCheckedAt()!).toLocaleTimeString()} -
-
+ )} +
- - - - - -

- You can change these later in Settings. -

+ + +
+
+
+
OpenCode CLI
+
+ Checking install…}> + Not found. Install to run Host mode.} + > + + {engineDoctorResult()?.version ?? "Installed"} + + + · + + {engineDoctorResult()?.resolvedPath} + + + + +
+
+ + +
+ + +
+
Install one of these:
+
+ brew install anomalyco/tap/opencode +
+
+ curl -fsSL https://opencode.ai/install | bash +
+ +
+ + +
+
+
+ + +
{engineInstallLogs()}
+
+ + +
+ Last checked {new Date(engineDoctorCheckedAt()!).toLocaleTimeString()} +
+
+
+
+ + + +
@@ -2995,7 +3414,7 @@ export default function App() { } }); - const quickTemplates = createMemo(() => templates().slice(0, 3)); + const quickTemplates = createMemo(() => workspaceTemplates().slice(0, 3)); createEffect(() => { if (tab() === "skills") { @@ -3067,9 +3486,9 @@ export default function App() { - No templates yet. Save one from a session. -
+
+ No templates yet. Starter templates will appear here. +
} >
@@ -3115,6 +3534,11 @@ export default function App() {
{s.title}
{formatRelativeTime(s.time.updated)} + + + this workspace + +
@@ -3160,6 +3584,11 @@ export default function App() {
{s.title}
{formatRelativeTime(s.time.updated)} + + + this workspace + +
@@ -3201,37 +3630,71 @@ export default function App() {
- No templates yet. Save one from a session, or create one here. + Starter templates will appear here. Create one or save from a session.
} > -
- - {(t) => ( -
-
-
- -
{t.title}
+
+ +
+
Workspace
+ + {(t) => ( +
+
+
+ +
{t.title}
+
+
{t.description || ""}
+
{formatRelativeTime(t.createdAt)}
+
+
+ + +
-
{t.description || ""}
-
{formatRelativeTime(t.createdAt)}
-
-
- - -
-
- )} - + )} + +
+ + + +
+
Global
+ + {(t) => ( +
+
+
+ +
{t.title}
+
+
{t.description || ""}
+
{formatRelativeTime(t.createdAt)}
+
+
+ + +
+
+ )} +
+
+
@@ -3246,50 +3709,6 @@ export default function App() {
- -
-
-
-
{reloadCopy().title}
-
{reloadCopy().body}
- -
{reloadError()}
-
- -
- A run is in progress. Stop it before reloading. -
-
- -
- Connected in Client mode. Ask the host to reload. -
-
-
-
- - -
-
-
-
-
Install from OpenPackage
@@ -3436,50 +3855,6 @@ export default function App() {
- -
-
-
-
{reloadCopy().title}
-
{reloadCopy().body}
- -
{reloadError()}
-
- -
- A run is in progress. Stop it before reloading. -
-
- -
- Connected in Client mode. Ask the host to reload. -
-
-
-
- - -
-
-
-
-
@@ -4038,6 +4413,13 @@ export default function App() {
+ { + setWorkspaceSearch(""); + setWorkspacePickerOpen(true); + }} + />

{title()}

{headerStatus()} @@ -4082,6 +4464,23 @@ export default function App() {
+ setWorkspacePickerOpen(false)} + onSelect={activateWorkspace} + onCreateNew={() => setCreateWorkspaceOpen(true)} + /> + + setCreateWorkspaceOpen(false)} + onConfirm={(preset) => createWorkspaceFlow(preset)} + /> +
@@ -4596,24 +4995,20 @@ export default function App() { modelID: opt.modelID, }); - return ( - + +
+