diff --git a/README.md b/README.md index 79ddd628..0c095c8b 100644 --- a/README.md +++ b/README.md @@ -1,96 +1,103 @@ # OpenWork -OpenWork is a native (Tauri) desktop UI for running **OpenCode** with a friendly, non-technical workflow. +OpenWork is an **extensible, open-source “Claude Work” style system for knowledge workers**. -It supports: -- **Host mode**: start `opencode serve` locally in a chosen project folder. +It’s a native desktop app (Tauri) that runs **OpenCode** under the hood, but presents it as a clean, guided workflow: +- pick a workspace +- start a run +- watch progress + plan updates +- approve permissions when needed +- reuse what works (templates + skills) + +The goal: make “agentic work” feel like a product, not a terminal. + +## Why + +Knowledge workers don’t want to learn a CLI, fight config sprawl, or rebuild the same workflows in every repo. +OpenWork is designed to be: +- **Extensible**: skills and workflows are installable modules. +- **Auditable**: show what happened, when, and why. +- **Permissioned**: explicit user approval for risky actions. +- **Portable**: keep logic in prompts/skills, not bespoke code. + +## What’s Included (v0.1) + +- **Host mode**: start `opencode serve` locally in a chosen folder. - **Client mode**: connect to an existing OpenCode server by URL. -- **Sessions**: create/select sessions, send prompts, stream live updates via SSE. +- **Sessions**: create/select sessions and send prompts. +- **Live streaming**: SSE `/event` subscription for realtime updates. - **Execution plan**: render OpenCode todos as a timeline. - **Permissions**: surface permission requests and reply (allow once / always / deny). - **Templates**: save and re-run common workflows (stored locally). -- **Skills manager**: view installed skills and install/import skills via OpenPackage. +- **Skills manager**: + - list installed `.opencode/skill` folders + - install from OpenPackage (`opkg install ...`) + - import a local skill folder into `.opencode/skill/` -## Requirements +## Quick Start + +### Requirements - Node.js + `pnpm` - Rust toolchain (for Tauri): `cargo`, `rustc` - OpenCode CLI installed and available on PATH: `opencode` -## Quick Start - -Install dependencies: +### Install ```bash pnpm install ``` -Run as a desktop app (Tauri): +### Run (Desktop) ```bash pnpm dev ``` -Run the web UI only: +### Run (Web UI only) ```bash pnpm dev:web ``` -## How It Works +## Architecture (high-level) - In **Host mode**, OpenWork spawns: - `opencode serve --hostname 127.0.0.1 --port ` - - with the selected project folder as the process working directory. + - with your selected project folder as the process working directory. - The UI uses `@opencode-ai/sdk/v2/client` to: - connect to the server - list/create sessions - send prompts - - subscribe to `/event` SSE + - subscribe to SSE events - read todos and permission requests ## Folder Picker -The folder picker uses the Tauri dialog plugin. The capability permission file is at: +The folder picker uses the Tauri dialog plugin. +Capability permissions are defined in: - `src-tauri/capabilities/default.json` -## Skills (OpenPackage) +## OpenPackage Notes -The **Skills** screen supports: -- Installing a package (e.g. `github:anthropics/claude-code`) into the current workspace. -- Importing a local skill folder into `.opencode/skill/`. +If `opkg` is not installed globally, OpenWork falls back to: -Notes: -- If `opkg` is not installed globally, OpenWork falls back to `pnpm dlx opkg ...`. +```bash +pnpm dlx opkg install +``` -## Useful Scripts - -Typecheck: +## Useful Commands ```bash pnpm typecheck -``` - -Build web bundle: - -```bash pnpm build:web -``` - -API smoke tests (non-UI): - -```bash pnpm test:e2e -pnpm test:events -pnpm test:sessions -pnpm test:health ``` ## Security Notes -- OpenWork intentionally hides model reasoning/tool metadata by default. -- Permissions are surfaced via OpenCode permission requests (when configured to ask). -- Host mode is local by default (`127.0.0.1`). +- OpenWork hides model reasoning and sensitive tool metadata by default. +- Host mode binds to `127.0.0.1` by default. ## License diff --git a/package.json b/package.json index 38d9b773..381ff422 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@different-ai/openwork", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", "scripts": { "dev": "tauri dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 57206604..2776501a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2014,7 +2014,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openwork" -version = "0.0.0" +version = "0.1.0" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4a64d697..b6d6ad7c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openwork" -version = "0.0.0" +version = "0.1.0" description = "OpenWork" authors = ["Different AI"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d58e4958..8a6d1954 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenWork", - "version": "0.0.0", + "version": "0.1.0", "identifier": "com.differentai.openwork", "build": { "beforeDevCommand": "pnpm dev:web", diff --git a/src/App.tsx b/src/App.tsx index a7c85314..3b134455 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -290,8 +290,29 @@ export default function App() { const [developerMode, setDeveloperMode] = createSignal(false); const [busy, setBusy] = createSignal(false); + const [busyLabel, setBusyLabel] = createSignal(null); + const [busyStartedAt, setBusyStartedAt] = createSignal(null); const [error, setError] = createSignal(null); + const busySeconds = createMemo(() => { + const start = busyStartedAt(); + if (!start) return 0; + return Math.max(0, Math.round((Date.now() - start) / 1000)); + }); + + const newTaskDisabled = createMemo(() => { + const label = busyLabel(); + // Allow creating a new session even while a run is in progress. + if (busy() && label === "Running") return false; + + // Otherwise, block during engine / connection transitions. + if (busy() && (label === "Connecting" || label === "Starting engine" || label === "Disconnecting")) { + return true; + } + + return busy(); + }); + const selectedSession = createMemo(() => { const id = selectedSessionId(); if (!id) return null; @@ -351,6 +372,8 @@ export default function App() { async function connectToServer(nextBaseUrl: string, directory?: string) { setError(null); setBusy(true); + setBusyLabel("Connecting"); + setBusyStartedAt(Date.now()); setSseConnected(false); try { @@ -375,10 +398,12 @@ export default function App() { } catch (e) { setClient(null); setConnectedVersion(null); - setError(e instanceof Error ? e.message : "Unknown error"); + setError(e instanceof Error ? e.message : safeStringify(e)); return false; } finally { setBusy(false); + setBusyLabel(null); + setBusyStartedAt(null); } } @@ -396,6 +421,8 @@ export default function App() { setError(null); setBusy(true); + setBusyLabel("Starting engine"); + setBusyStartedAt(Date.now()); try { const info = await engineStart(dir); @@ -407,15 +434,21 @@ export default function App() { } return true; - } catch (e) { - setError(e instanceof Error ? e.message : safeStringify(e)); - } - + } catch (e) { + setError(e instanceof Error ? e.message : safeStringify(e)); + return false; + } finally { + setBusy(false); + setBusyLabel(null); + setBusyStartedAt(null); + } } async function stopHost() { setError(null); setBusy(true); + setBusyLabel("Disconnecting"); + setBusyStartedAt(Date.now()); try { if (isTauriRuntime()) { @@ -437,9 +470,11 @@ export default function App() { setOnboardingStep("mode"); setView("onboarding"); } catch (e) { - setError(e instanceof Error ? e.message : "Unknown error"); + setError(e instanceof Error ? e.message : safeStringify(e)); } finally { setBusy(false); + setBusyLabel(null); + setBusyStartedAt(null); } } @@ -471,6 +506,8 @@ export default function App() { if (!c) return; setBusy(true); + setBusyLabel("Creating session"); + setBusyStartedAt(Date.now()); setError(null); try { @@ -479,9 +516,11 @@ export default function App() { await selectSession(session.id); setView("session"); } catch (e) { - setError(e instanceof Error ? e.message : "Unknown error"); + setError(e instanceof Error ? e.message : safeStringify(e)); } finally { setBusy(false); + setBusyLabel(null); + setBusyStartedAt(null); } } @@ -494,6 +533,8 @@ export default function App() { if (!content) return; setBusy(true); + setBusyLabel("Running"); + setBusyStartedAt(Date.now()); setError(null); try { @@ -517,9 +558,11 @@ export default function App() { await loadSessions(c); } catch (e) { - setError(e instanceof Error ? e.message : "Unknown error"); + setError(e instanceof Error ? e.message : safeStringify(e)); } finally { setBusy(false); + setBusyLabel(null); + setBusyStartedAt(null); } } @@ -998,6 +1041,12 @@ export default function App() { return bits.join(" · "); }); + const busyHint = createMemo(() => { + if (!busy() || !busyLabel()) return null; + const seconds = busySeconds(); + return seconds > 0 ? `${busyLabel()} · ${seconds}s` : busyLabel(); + }); + function OnboardingView() { return ( @@ -1406,6 +1455,7 @@ export default function App() { ); }; + const content = () => ( @@ -1420,7 +1470,8 @@ export default function App() {