mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
Prepare v0.1.0 release
This commit is contained in:
87
README.md
87
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/<skill-name>`
|
||||
|
||||
## 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 <free-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/<skill-name>`.
|
||||
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 <package>
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@different-ai/openwork",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -2014,7 +2014,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "openwork"
|
||||
version = "0.0.0"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openwork"
|
||||
version = "0.0.0"
|
||||
version = "0.1.0"
|
||||
description = "OpenWork"
|
||||
authors = ["Different AI"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -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",
|
||||
|
||||
74
src/App.tsx
74
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<string | null>(null);
|
||||
const [busyStartedAt, setBusyStartedAt] = createSignal<number | null>(null);
|
||||
const [error, setError] = createSignal<string | null>(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 (
|
||||
<Switch>
|
||||
@@ -1406,6 +1455,7 @@ export default function App() {
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const content = () => (
|
||||
<Switch>
|
||||
<Match when={tab() === "home"}>
|
||||
@@ -1420,7 +1470,8 @@ export default function App() {
|
||||
</div>
|
||||
<Button
|
||||
onClick={createSessionAndOpen}
|
||||
disabled={busy()}
|
||||
disabled={newTaskDisabled()}
|
||||
title={newTaskDisabled() ? busyHint() ?? "Busy" : ""}
|
||||
class="w-full md:w-auto py-3 px-6 text-base"
|
||||
>
|
||||
<Play size={18} />
|
||||
@@ -1816,10 +1867,13 @@ export default function App() {
|
||||
</div>
|
||||
<h1 class="text-lg font-medium">{title()}</h1>
|
||||
<span class="text-xs text-zinc-600">{headerStatus()}</span>
|
||||
<Show when={busyHint()}>
|
||||
<span class="text-xs text-zinc-500">· {busyHint()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={tab() === "home" || tab() === "sessions"}>
|
||||
<Button onClick={createSessionAndOpen} disabled={busy()}>
|
||||
<Button onClick={createSessionAndOpen} disabled={newTaskDisabled()} title={newTaskDisabled() ? busyHint() ?? "Busy" : ""}>
|
||||
<Play size={16} />
|
||||
New Task
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user