From 9f1d22ba0b116e2c38cb16f4fc6649ec4df74a48 Mon Sep 17 00:00:00 2001
From: Vlad
Date: Sun, 7 Jun 2026 23:58:13 +0200
Subject: [PATCH 1/6] docs(readme): trim to minimalist layout, add Reddit +
GitHub stars badges
Co-Authored-By: Claude Opus 4.8
---
README.md | 81 +++++++++++--------------------------------------------
1 file changed, 16 insertions(+), 65 deletions(-)
diff --git a/README.md b/README.md
index ec7069d..028c08f 100644
--- a/README.md
+++ b/README.md
@@ -5,13 +5,15 @@
iClaw
- Chat UI for OpenClaw Gateway — runs on your machine, stores history locally.
+ Minimalist agent UI - give it a project, it works only within it. Isolated memory, isolated folders
+
+
@@ -19,65 +21,33 @@
## Install
-Requires [Node.js 20+](https://nodejs.org) and a running [OpenClaw Gateway](https://docs.openclaw.ai).
+Requires [Node.js 20+](https://nodejs.org)
```bash
npx @iclawapp/iclaw
```
-The terminal shows a short status line and **press `g`** to open the UI in your browser. It picks the next free port if `3000` is busy, and exits without starting a second copy if iClaw is already running.
+Press **`g`** in the terminal to open the UI in your browser
-Your chat history is saved to `~/.iclaw/data/iclaw.db`.
-
-Optional env vars: `PORT` (preferred port, default `3000`), `ICLAW_OPEN_BROWSER=1` (also open the browser tab on start).
-
-## Encrypted chat sharing (optional)
-
-Hit **Share** in any chat to get an encrypted link. The chat is encrypted in your browser (AES-256-GCM); the server stores ciphertext only. Supports password protection, burn-after-read, and TTL.
+## Star history
-Powered by [iClaw-cloud](https://github.com/iClawApp/iClaw-cloud) — defaults to `https://app.iclaw.digital`.
+[](https://www.star-history.com/#iClawApp/iClaw&Date)
## Chat modes
-The composer has a small mode selector. The selected mode is stored per message
-(`messages.mode`) and rides the `frontend → WS → chatRunner` path. Missing/unknown
-modes fall back to `execute`, so old chats and older clients keep working. Modes
-are config-driven in [`src/services/chatModes.ts`](src/services/chatModes.ts) (the
-catalog also lists disabled placeholders so new modes can be added without
-touching call sites or the DB — the column is plain `TEXT`).
-
| Mode | What it does | Backend |
| --- | --- | --- |
-| **Full Power** (default) | The full OpenClaw agent — files, tools, shell, browser. | OpenClaw Gateway |
| **Work** | AI edits files in folders you pick; you approve every change. | iClaw runtime |
-| **Safe work & Internet research** | Locked Docker sandbox — run untrusted code and research the web, isolated from your system. | iClaw runtime |
+| **Safe work & Internet research** | Locked Docker sandbox — run untrusted code and research the web. | iClaw runtime |
+| **Full Power** (default) | The full OpenClaw agent — files, tools, shell, browser. | OpenClaw Gateway |
| **Incognito** | Private, read-only research — reads files & the web, never writes, nothing saved. | iClaw runtime |
-**Full Power** routes to the gateway exactly as before. The other three run on
-the bundled **iClaw runtime** (`packages/iclaw-runtime`): the agent loop runs on
-the host and reaches [OpenRouter](https://openrouter.ai), while tool/shell
-execution is isolated in a per-turn Docker sandbox. They therefore need **Docker**
-running and an **OpenRouter key** (Settings); without either they're unavailable
-and the composer falls back to Full Power. See [AGENTS.md](AGENTS.md) for the
-runtime architecture.
-
-## Remote Access (alpha)
-
-Open the iClaw UI from another device through an **iclaw-relay** tunnel (Settings → Remote Access).
-
-**Security model (summary):**
-
-- **Relay access token** — blocks visitors who only guess the subdomain.
-- **OPAQUE login** — passphrase is not sent in plaintext over the tunnel.
-- **Encrypted HTTP/WebSocket** (E2E alpha) — payloads are encrypted between the browser and **local** iClaw; the relay forwards encrypted frames only.
-- **Device sessions** — trusted browsers can reconnect without retyping the passphrase.
-- **Alpha** — not externally audited. The relay still sees metadata (subdomain, timing, sizes, E2E endpoint paths).
+The three runtime modes need **Docker** running and an **OpenRouter key** (Settings); without either the composer falls back to Full Power. Architecture: [AGENTS.md](AGENTS.md).
-Two setups: **local mode** (iClaw + OpenClaw on the same laptop) and **host mode** (always-on machine, browse from phone/laptop). See [docs/REMOTE_ACCESS.md](../docs/REMOTE_ACCESS.md).
+## More
-Env on the iClaw host: `ICLAW_RELAY_URL` and `OPAQUE_SERVER_SETUP` (required when Remote Access is enabled).
-
----
+- **Encrypted chat sharing** — hit **Share** in any chat for an end-to-end encrypted link (AES-256-GCM; password, burn-after-read, TTL). Powered by [iClaw-cloud](https://github.com/iClawApp/iClaw-cloud).
+- **Remote Access** (alpha) — reach the UI from another device over an iclaw-relay tunnel (Settings → Remote Access). Not externally audited yet — see [docs/REMOTE_ACCESS.md](docs/REMOTE_ACCESS.md).
## For developers
@@ -86,28 +56,9 @@ git clone https://github.com/iClawApp/iClaw.git
cd iClaw && npm install && npm run dev
```
-| | Default |
-| --- | --- |
-| Web UI | http://localhost:3000 |
-| Gateway | http://127.0.0.1:18789 (`OPENCLAW_BASE_URL`) |
-
-Optional env vars: [.env.example](.env.example).
-Architecture + coding rules: [AGENTS.md](AGENTS.md).
-Remote Access alpha: [docs/REMOTE_ACCESS.md](../docs/REMOTE_ACCESS.md), smoke [docs/REMOTE_ACCESS_SMOKE.md](../docs/REMOTE_ACCESS_SMOKE.md).
-
-```bash
-npm run test:ra-smoke # E2E adversarial smoke (vitest)
-npm run scan:relay-capture -- frames.ndjson # scan relay frame capture
-```
-
-## Star history
-
-[](https://www.star-history.com/#iClawApp/iClaw&Date)
-
-## Contributing
-
-See [CONTRIBUTING.md](CONTRIBUTING.md), [ROADMAP.md](ROADMAP.md), [CHANGELOG.md](CHANGELOG.md). Bug reports and small PRs welcome — for bigger changes open an issue first.
+Web UI on http://localhost:3000, Gateway on http://127.0.0.1:18789 (`OPENCLAW_BASE_URL`). Env vars: [.env.example](.env.example) · Architecture & coding rules: [AGENTS.md](AGENTS.md).
-## License
+## License and contributing
+[CONTRIBUTING.md](CONTRIBUTING.md) · [ROADMAP.md](ROADMAP.md) · [CHANGELOG.md](CHANGELOG.md)
MIT — see [LICENSE](LICENSE).
From f66d096be6def0bc786dc05ae2324b6f4b231ed8 Mon Sep 17 00:00:00 2001
From: Vlad
Date: Mon, 8 Jun 2026 00:01:06 +0200
Subject: [PATCH 2/6] docs(readme): drop CI, GitHub stars, and npm version
badges
Co-Authored-By: Claude Opus 4.8
---
README.md | 3 ---
1 file changed, 3 deletions(-)
diff --git a/README.md b/README.md
index 028c08f..07906c4 100644
--- a/README.md
+++ b/README.md
@@ -9,9 +9,6 @@
-
-
-
From 71e777b6f5bb1564cfe158eae069ce8780d300f6 Mon Sep 17 00:00:00 2001
From: Vlad
Date: Mon, 8 Jun 2026 00:31:15 +0200
Subject: [PATCH 3/6] fix(sidebar): show "Start OpenClaw" banner only in Full
Power mode
The sidebar gateway banner appeared whenever OpenClaw was offline, even in Work / Safe work / Incognito modes that never touch the gateway. Gate it on the same getComposerMode() === 'execute' check the composer overlay uses, and refresh it on both gateway-status and mode changes.
Co-Authored-By: Claude Opus 4.8
---
public/js/iclaw.js | 32 ++++++++++++++++++++++++++++----
1 file changed, 28 insertions(+), 4 deletions(-)
diff --git a/public/js/iclaw.js b/public/js/iclaw.js
index 799d321..0961a6d 100644
--- a/public/js/iclaw.js
+++ b/public/js/iclaw.js
@@ -169,6 +169,12 @@
return true; // no signal → don't block
})();
+ // The sidebar "Start OpenClaw" banner only makes sense when the user actually
+ // wants Full Power — the runtime modes (Work / Safe work / Incognito) never
+ // touch the gateway. Tracked here so BOTH a gateway-status change and a mode
+ // change re-evaluate the banner (see refreshGatewayOfflineBanner).
+ let gatewayOffline = false;
+
/** True only when OpenClaw is reachable for Full Power (Execute). */
function isExecuteAvailable() {
return gatewayOk;
@@ -189,6 +195,9 @@
composerExecMsg.setAttribute('aria-hidden', blocked ? 'false' : 'true');
}
refreshComposerInputDisabled();
+ // The mode may have just changed — the sidebar "Start OpenClaw" banner is
+ // gated on Full Power too, so keep it in sync from the same funnel.
+ refreshGatewayOfflineBanner();
}
/**
@@ -5314,8 +5323,23 @@
const gatewayBannerStart = document.getElementById('sidebar-gateway-start');
let gatewayStatusPollTimer = null;
- function setGatewayOfflineBannerVisible(visible) {
- if (gatewayBanner) gatewayBanner.hidden = !visible;
+ function setGatewayOffline(offline) {
+ gatewayOffline = offline;
+ refreshGatewayOfflineBanner();
+ }
+
+ /**
+ * Show the "Start OpenClaw" banner only when the gateway is offline AND the
+ * user actually wants Full Power. Work / Safe work / Incognito run on the
+ * iclaw-runtime and never touch the gateway, so nudging them to start it is
+ * just noise — same `execute` gate the composer overlay uses. Queries the DOM
+ * directly (not the closure const) so it's safe to call from the early
+ * mode-change funnel before the gateway-banner const is initialised.
+ */
+ function refreshGatewayOfflineBanner() {
+ const banner = document.getElementById('sidebar-gateway-banner');
+ if (!banner) return;
+ banner.hidden = !(gatewayOffline && getComposerMode() === 'execute');
}
function applyGatewayStatus(status, detail) {
@@ -5324,7 +5348,7 @@
// "Start OpenClaw" banner is reserved for a genuinely-offline gateway —
// there's nothing to "start" when it's already running.
const offline = status === 'down' || status === 'shutdown';
- setGatewayOfflineBannerVisible(offline);
+ setGatewayOffline(offline);
// Keep Full Power gating current on every page.
gatewayOk = status === 'ok';
@@ -5338,7 +5362,7 @@
void fetch('/api/gateway/status', { headers: { Accept: 'application/json' } })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
- if (data && data.up !== true) setGatewayOfflineBannerVisible(true);
+ if (data && data.up !== true) setGatewayOffline(true);
})
.catch(() => {});
})();
From ea0fdadcb5f0a6ae15da255ac7df1a00fb0e95b1 Mon Sep 17 00:00:00 2001
From: Vlad
Date: Mon, 8 Jun 2026 10:18:12 +0200
Subject: [PATCH 4/6] feat(onboarding): OpenClaw-aware default mode, connect
chooser, composer/sidebar UX
- Default mode after onboarding keys off whether the OpenClaw CLI is on the
device: Full Power if installed, otherwise Work. New cached probe
(openclawInstall.ts), primed at startup.
- Mode selector always renders; runtime modes show locked with a 'needs
OpenRouter' hint when no key, and clicking one opens the connect chooser.
New listComposerModes() feeds per-mode availability to the composer.
- Connect-a-model chooser (connectModal.ejs) replaces the post-Skip 'OpenClaw
off' dead-end: composer stays usable and the chooser fires on first send (or
on clicking a locked mode) when no backend is reachable.
- Sidebar 'Start OpenClaw' banner gated on Full Power being the selected mode.
- Sidebar declutter: Search after 15 chats, Projects after 5 (or once a project
exists); the draft skips the project picker when there are no projects.
- Empty-state copy reworded; welcome card (scenarios + about iClaw) extracted to
a partial and shown on empty chats while no project exists.
Co-Authored-By: Claude Opus 4.8
---
public/css/style.css | 58 ++++++++++++++++++++++++
public/js/iclaw.js | 79 ++++++++++++++++++++++++++++++---
src/index.ts | 5 +++
src/routes/chats.ts | 7 ++-
src/routes/index.ts | 4 +-
src/services/chatModes.ts | 25 ++++++++---
src/services/openclawInstall.ts | 42 ++++++++++++++++++
views/chat.ejs | 4 ++
views/index.ejs | 31 +++----------
views/partials/composer.ejs | 9 +++-
views/partials/connectModal.ejs | 47 ++++++++++++++++++++
views/partials/sidebar.ejs | 10 ++++-
views/partials/welcomeCard.ejs | 22 +++++++++
13 files changed, 298 insertions(+), 45 deletions(-)
create mode 100644 src/services/openclawInstall.ts
create mode 100644 views/partials/connectModal.ejs
create mode 100644 views/partials/welcomeCard.ejs
diff --git a/public/css/style.css b/public/css/style.css
index d84f4da..58b834f 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -5324,6 +5324,13 @@ code {
font-weight: 400;
color: var(--muted);
}
+/* Last so it wins for Safe work (needs both Docker and a key) — the key is the
+ * blocker to surface first. */
+.composer-mode-menu-item.is-unavailable[data-requires-key="1"] .menu-item__title::after {
+ content: ' · needs OpenRouter';
+ font-weight: 400;
+ color: var(--muted);
+}
/* ---------- Message attachments (persisted user uploads) ---------- */
@@ -8293,6 +8300,57 @@ body.incognito-mode .composer-mode-btn__label {
}
.onb-skip:hover { color: var(--text); text-decoration: underline; }
+/* Connect-a-model chooser modal — shown when a user who skipped onboarding
+ (no OpenRouter key, no reachable OpenClaw) tries to send. Reuses .onb-* rows. */
+.connect-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-6);
+}
+.connect-modal[hidden] { display: none; }
+.connect-modal__backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.55);
+}
+.connect-modal__card {
+ position: relative;
+ z-index: 1;
+ margin: 0;
+ text-align: center;
+ animation: onb-fade 0.18s ease;
+}
+.connect-modal__close {
+ position: absolute;
+ top: var(--space-5);
+ right: var(--space-5);
+ width: 1.75rem;
+ height: 1.75rem;
+ display: grid;
+ place-items: center;
+ background: none;
+ border: none;
+ border-radius: var(--radius-lg);
+ color: var(--muted);
+ font-size: 1.4rem;
+ line-height: 1;
+ cursor: pointer;
+}
+.connect-modal__close:hover {
+ color: var(--text);
+ background: color-mix(in srgb, var(--text) 8%, transparent);
+}
+.connect-modal__note {
+ margin: var(--space-6) 0 0;
+ font-size: var(--text-sm);
+ color: var(--muted);
+ line-height: 1.45;
+}
+
.onb-back {
position: absolute;
top: var(--space-7);
diff --git a/public/js/iclaw.js b/public/js/iclaw.js
index 0961a6d..df43092 100644
--- a/public/js/iclaw.js
+++ b/public/js/iclaw.js
@@ -77,6 +77,23 @@
: [composerModeDefault];
let selectedComposerMode = composerModeDefault;
+ // True when an OpenRouter key is configured. Derived from the rendered menu:
+ // locked (needs-key) items are only emitted when there's no key. Drives the
+ // connect chooser + the Full Power "switch mode" overlay (the runtime modes
+ // are the only fallback for a dead gateway, and they need the key).
+ const openRouterReady =
+ !!composerModeMenu &&
+ !composerModeMenu.querySelector('.composer-mode-menu-item[data-requires-key="1"]');
+
+ /** True when a mode is shown but locked behind a missing OpenRouter key. */
+ function isModeLocked(id) {
+ if (!composerModeMenu) return false;
+ const el = composerModeMenu.querySelector(
+ '.composer-mode-menu-item[data-mode="' + id + '"]',
+ );
+ return !!el && el.dataset.requiresKey === '1';
+ }
+
/** Currently selected send mode (always one of the rendered, enabled ids). */
function getComposerMode() {
return composerModeIds.includes(selectedComposerMode)
@@ -85,7 +102,12 @@
}
function setComposerMode(mode, opts) {
- const next = composerModeIds.includes(mode) ? mode : composerModeDefault;
+ let next = composerModeIds.includes(mode) ? mode : composerModeDefault;
+ // Don't land on a locked mode that isn't the default (e.g. a chat last used
+ // in a runtime mode after the key was removed) — fall back to the default.
+ // The default itself MAY be a locked Work (no OpenClaw + no key); that's
+ // intended, and the connect chooser fires on first send.
+ if (isModeLocked(next)) next = composerModeDefault;
// Remember the mode we leave when entering Incognito, so the × can restore it.
if (next === 'incognito' && selectedComposerMode !== 'incognito') {
incognitoReturnMode = selectedComposerMode;
@@ -189,7 +211,12 @@
);
if (execItem) execItem.classList.toggle('is-unavailable', !avail);
}
- const blocked = !avail && getComposerMode() === 'execute';
+ // Full Power with the gateway down is only a "switch mode below" situation
+ // when the runtime modes are actually usable — i.e. an OpenRouter key is
+ // set. Without a key those modes are locked, so don't disable the input
+ // here; the submit handler surfaces the connect chooser instead.
+ const blocked =
+ !avail && getComposerMode() === 'execute' && openRouterReady;
if (form) form.classList.toggle('is-exec-disabled', blocked);
if (composerExecMsg) {
composerExecMsg.setAttribute('aria-hidden', blocked ? 'false' : 'true');
@@ -239,7 +266,8 @@
if (composerModeMenu) {
composerModeMenu.querySelectorAll('.composer-mode-menu-item').forEach((el) => {
if (el.dataset.requiresDocker === '1') {
- el.classList.toggle('is-unavailable', missing);
+ // A key-locked mode (no OpenRouter) stays locked regardless of Docker.
+ el.classList.toggle('is-unavailable', missing || el.dataset.requiresKey === '1');
}
});
}
@@ -466,6 +494,13 @@
composerModeMenu.addEventListener('click', (e) => {
const item = e.target.closest('.composer-mode-menu-item');
if (!item) return;
+ // Locked behind a missing OpenRouter key — re-offer connecting instead of
+ // switching into a mode that can't run.
+ if (item.dataset.requiresKey === '1') {
+ closeComposerModeMenu();
+ openConnectChooser();
+ return;
+ }
// Incognito is a separate ephemeral surface, not a flag on the current
// chat: open a fresh blank chat instead of converting this one. Remember
// where we came from so the × can bring us back.
@@ -5367,6 +5402,29 @@
.catch(() => {});
})();
+ // ── Connect-a-model chooser ───────────────────────────────────────────────
+ // Shown when someone who skipped onboarding (no OpenRouter key, no reachable
+ // OpenClaw) actually tries to send — re-offers the onboarding choice instead
+ // of a dead end. The submit handler calls openConnectChooser(); this is a
+ // function declaration so it's callable regardless of definition order.
+ function openConnectChooser() {
+ const modal = document.getElementById('connect-modal');
+ if (modal) modal.hidden = false;
+ }
+ (function initConnectChooser() {
+ const modal = document.getElementById('connect-modal');
+ if (!modal) return;
+ const close = () => { modal.hidden = true; };
+ modal.querySelectorAll('[data-connect-close]').forEach((el) => {
+ el.addEventListener('click', close);
+ });
+ const pick = document.getElementById('connect-pick-openrouter');
+ if (pick) pick.addEventListener('click', () => { window.location.href = '/welcome'; });
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && !modal.hidden) close();
+ });
+ })();
+
function stopGatewayStatusPoll() {
if (gatewayStatusPollTimer != null) {
clearInterval(gatewayStatusPollTimer);
@@ -6615,10 +6673,17 @@
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (startedOnDraft && !draftProjectLocked) return;
- // Full Power needs OpenClaw — don't send into a dead gateway. The input is
- // already disabled in this state; this is the belt-and-braces guard.
- if (getComposerMode() === 'execute' && !isExecuteAvailable()) {
- syncExecuteAvailability();
+ // Don't send into a dead end. Full Power needs a reachable OpenClaw; the
+ // runtime modes (incl. a Work default with no OpenClaw on the device) need
+ // an OpenRouter key. When neither is usable, re-offer connecting a model.
+ const _sendMode = getComposerMode();
+ const _modeRunnable =
+ _sendMode === 'execute' ? isExecuteAvailable() : openRouterReady;
+ if (!_modeRunnable) {
+ // Full Power with a key has usable runtime fallbacks → nudge to switch
+ // mode; otherwise there's no working backend → re-offer connecting.
+ if (_sendMode === 'execute' && openRouterReady) syncExecuteAvailability();
+ else openConnectChooser();
return;
}
// If the schedule menu was just opened by a long-press, the bubbling
diff --git a/src/index.ts b/src/index.ts
index 304f389..0697140 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,6 +7,7 @@ import { scheduler } from './services/scheduler';
import { gatewayEvents } from './services/gatewayEvents';
import { remoteAccess, setRemoteAccessQuiet } from './services/remoteAccess';
import { runtimeProcess } from './services/runtimeProcess';
+import { probeOpenClawInstalled } from './services/openclawInstall';
import { setBoundLocalAddress } from './services/localAddress';
import {
findAvailablePort,
@@ -118,6 +119,10 @@ async function main(): Promise {
scheduler.start();
runtimeProcess.start();
gatewayEvents.start();
+ // Prime the OpenClaw install check before serving — the composer's default
+ // mode (Full Power vs Work) keys off it. Fast: present → quick; absent →
+ // ENOENT immediately. Never fatal.
+ await probeOpenClawInstalled().catch(() => {});
const stop = () => gracefulShutdown(server, 'SIGINT');
process.on('SIGINT', () => stop());
diff --git a/src/routes/chats.ts b/src/routes/chats.ts
index a075201..08ddb41 100644
--- a/src/routes/chats.ts
+++ b/src/routes/chats.ts
@@ -33,7 +33,7 @@ import {
defaultComposerMode,
isSelectableMode,
isEphemeralMode,
- listSelectableModes,
+ listComposerModes,
normalizeChatMode,
} from '../services/chatModes';
import type { ChatMode } from '../types';
@@ -339,10 +339,13 @@ chatsRouter.get('/:id', async (req, res, next) => {
scheduledList: scheduledMessages.listByChat(id),
queueList: queuedMessages.listByChat(id),
sendHintShow: shouldShowSendHint(),
- chatModes: listSelectableModes(),
+ chatModes: listComposerModes(),
defaultChatMode: defaultComposerMode(),
chatCurrentMode,
sttEnabled: openRouterEnabled(),
+ // Lets the composer lock the runtime modes (and the connect chooser fire)
+ // when no key is configured.
+ openRouterReady: openRouterEnabled(),
// Full Power (Execute) needs the gateway; agents.list succeeding implies it's
// reachable. Seeds the composer's Full Power gating (no badge on this page).
gatewayOk: !agentsError,
diff --git a/src/routes/index.ts b/src/routes/index.ts
index 2fe74d5..6f5005d 100644
--- a/src/routes/index.ts
+++ b/src/routes/index.ts
@@ -6,7 +6,7 @@ import { openclaw, cloudShareBaseUrl } from '../services/openclaw';
import { probeGateway } from '../services/gatewayProbe';
import { chatStatus } from '../services/chatStatus';
import { shouldShowSendHint } from '../services/sendHint';
-import { defaultComposerMode, listSelectableModes } from '../services/chatModes';
+import { defaultComposerMode, listComposerModes } from '../services/chatModes';
import { openRouterEnabled, transcribeAudio, isOpenRouterFailure } from '../services/openRouter';
import { isOnboardingDone, setOnboardingDone } from '../services/config';
import { startOnboardingPrep, getOnboardingEnv } from '../services/onboardingEnv';
@@ -169,7 +169,7 @@ indexRouter.get('/', async (req, res) => {
cloudShareBaseUrl,
workingIds: chatStatus.workingIds(),
sendHintShow: shouldShowSendHint(),
- chatModes: listSelectableModes(),
+ chatModes: listComposerModes(),
defaultChatMode: defaultComposerMode(),
sttEnabled: openRouterEnabled(),
// With an OpenRouter key the runtime modes (Work / Safe work / Incognito)
diff --git a/src/services/chatModes.ts b/src/services/chatModes.ts
index 466368f..34003fa 100644
--- a/src/services/chatModes.ts
+++ b/src/services/chatModes.ts
@@ -30,6 +30,7 @@
import type { ChatMode } from '../types';
import { openRouterEnabled } from './openRouter';
+import { isOpenClawInstalled } from './openclawInstall';
export interface ChatModeDef {
/** Wire/DB value. */
@@ -138,11 +139,24 @@ function modeAvailable(def: ChatModeDef): boolean {
return true;
}
-/** Available modes only — feeds the composer selector (EJS locals / client). */
+/** Available modes only — backend source of truth for what may actually run. */
export function listSelectableModes(): ChatModeDef[] {
return CHAT_MODES.filter(modeAvailable);
}
+/**
+ * Every ENABLED mode, each tagged with whether its backend is reachable right
+ * now. The composer always shows the full selector (so users discover the
+ * modes) and locks the ones that aren't usable yet — e.g. the runtime modes
+ * before an OpenRouter key is added.
+ */
+export function listComposerModes(): Array {
+ return CHAT_MODES.filter((m) => m.enabled).map((m) => ({
+ ...m,
+ available: modeAvailable(m),
+ }));
+}
+
function findMode(id: string): ChatModeDef | undefined {
return CHAT_MODES.find((m) => m.id === id);
}
@@ -154,12 +168,13 @@ export function isSelectableMode(id: string): boolean {
}
/**
- * UI default for the composer, with a graceful fallback: Work is runtime-backed,
- * so when it isn't currently selectable (e.g. no OpenRouter key) we fall back to
- * the always-available DEFAULT_MODE instead of pre-selecting a hidden mode.
+ * UI default for the composer after onboarding. Keyed on what's on the device:
+ * Full Power when OpenClaw is installed (it can be started on demand), otherwise
+ * Work — the OpenRouter runtime path. Work may still be locked until a key is
+ * added; the composer surfaces the connect chooser on first send in that case.
*/
export function defaultComposerMode(): ChatMode {
- return isSelectableMode(DEFAULT_COMPOSER_MODE) ? DEFAULT_COMPOSER_MODE : DEFAULT_MODE;
+ return isOpenClawInstalled() ? 'execute' : DEFAULT_COMPOSER_MODE;
}
/**
diff --git a/src/services/openclawInstall.ts b/src/services/openclawInstall.ts
new file mode 100644
index 0000000..f27d3ef
--- /dev/null
+++ b/src/services/openclawInstall.ts
@@ -0,0 +1,42 @@
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+
+const execFileAsync = promisify(execFile);
+
+/**
+ * Whether the `openclaw` CLI is installed on this device.
+ *
+ * Probed once at startup and cached — `openclaw --version` is a subprocess, far
+ * too heavy for the per-render callers (e.g. defaultComposerMode). Distinct from
+ * "gateway reachable": OpenClaw can be installed but stopped, and Full Power
+ * starts it on demand — so the default-mode choice keys off INSTALL, not health.
+ *
+ * Defaults to `false` until the probe resolves, so a device without OpenClaw
+ * never momentarily defaults into a dead Full Power. A machine that HAS it is
+ * primed at startup (and onboarding's human-time gap covers any race).
+ */
+let installed = false;
+let probed = false;
+
+export async function probeOpenClawInstalled(): Promise {
+ try {
+ // Not installed → spawn rejects immediately (ENOENT); a present binary
+ // returns fast. The timeout only guards a pathological hang.
+ await execFileAsync('openclaw', ['--version'], { timeout: 3_000 });
+ installed = true;
+ } catch {
+ installed = false;
+ }
+ probed = true;
+ return installed;
+}
+
+/** Cached result of the startup probe. `false` until {@link probeOpenClawInstalled} runs. */
+export function isOpenClawInstalled(): boolean {
+ return installed;
+}
+
+/** True once the startup probe has completed at least once. */
+export function openClawInstallProbed(): boolean {
+ return probed;
+}
diff --git a/views/chat.ejs b/views/chat.ejs
index 49f4ee5..49d9ba8 100644
--- a/views/chat.ejs
+++ b/views/chat.ejs
@@ -97,7 +97,11 @@
>
- <% if (agentsError && !openRouterReady) { %>
- <%- include('partials/gatewayError', { agentsError, gatewayUp, openclawBaseUrl }) %>
- <% } else { %>
+ <%# No model connected (skipped onboarding, no OpenClaw) is no longer a
+ dead end here — we show the normal picker + composer and re-offer the
+ connect choice when the user actually tries to send (connectModal). %>
- <% } %>
<% if (_firstChat) { %>
-
-
iC
-
-
Hi - I’m iClaw
-
From now on, you don’t have to choose between convenience, power and safety
-
-
Unlike other agents, I go online inside an isolated space - so a dangerous site can’t do any harm
-
Other agents want access to your whole computer. I only work where you let me
-
And our developers are 70% coffee - so we built a really efficient system that saves your tokens and your money
+
+<%- include('connectModal') %>
diff --git a/views/partials/connectModal.ejs b/views/partials/connectModal.ejs
new file mode 100644
index 0000000..5e916ad
--- /dev/null
+++ b/views/partials/connectModal.ejs
@@ -0,0 +1,47 @@
+<%# Connect-a-model chooser. Shown when someone who skipped onboarding (no
+ OpenRouter key, no reachable OpenClaw) actually tries to send — instead of a
+ dead-end error we re-offer the onboarding choice. Toggled by iclaw.js
+ (openConnectChooser) from the composer submit guard. %>
+
+
+
+ ×
+
+
Connect a model to start
+
iClaw needs an AI model to reply — connect one to begin.
+
+
+
+ Connect OpenRouter
+
+ Pay as you go - about $5 lasts a long time, and we’ll guide you
+
+
+ ›
+
+
+
+
+
+ Use iClaw credits
+ Coming soon
+
+
+ No setup - we handle the AI for you
+
+
+
+
+
+ Already running OpenClaw? Switch to Full Power and start it
+ from the sidebar.
+
+
+
diff --git a/views/partials/sidebar.ejs b/views/partials/sidebar.ejs
index 917e101..7bba4b6 100644
--- a/views/partials/sidebar.ejs
+++ b/views/partials/sidebar.ejs
@@ -22,6 +22,8 @@
New chat
+ <% /* Search only earns its place once there are many chats — declutter for new users. */ %>
+ <% if (chats.length >= 15) { %>
+ <% } %>
+ <% /* Projects surfaces once a user is established (>=5 chats) or already has one. */ %>
+ <% if (chats.length >= 5 || _projectsList.length > 0) { %>
Projects
+ <% } %>
<% if (typeof hasAnyTasks !== 'undefined' && hasAnyTasks) {
const _taskSignals = typeof taskStatusSignals !== 'undefined' ? taskStatusSignals : {};
const _taskDotHuman = _taskSignals.needsHuman;
@@ -142,9 +148,9 @@
<% } %>
<% if (chats.length === 0 && _projectsList.length === 0) { %>
-
No projects or chats yet.
+
Your chats will appear here
<% } else if (chats.length === 0) { %>
-
No chats yet. Click + New chat and pick a project on the home screen.
+
Your chats will appear here — hit + New chat to start
<% } %>
diff --git a/views/partials/welcomeCard.ejs b/views/partials/welcomeCard.ejs
new file mode 100644
index 0000000..529328c
--- /dev/null
+++ b/views/partials/welcomeCard.ejs
@@ -0,0 +1,22 @@
+<%# Conversational welcome + scenario chips. Shown to users who haven't set up
+ any project yet (first chat, or any empty chat while no project exists) in
+ place of the terse one-liner. Carries `empty-state` so clearEmptyState()
+ removes it on the first message; the chips are wired by initWelcomeChips. %>
+
+
iC
+
+
Hi - I’m iClaw
+
From now on, you don’t have to choose between convenience, power and safety
+
+
Unlike other agents, I go online inside an isolated space - so a dangerous site can’t do any harm
+
Other agents want access to your whole computer. I only work where you let me
+
And our developers are 70% coffee - so we built a really efficient system that saves your tokens and your money
+
+
+ Open an unknown website safely
+ Check a ZIP file safely, isolated from my computer
+ Check this GitHub repo in a sandbox
+ Summarize this 2-hour YouTube video for $0.0005
+
+
+
From 7043505c7e81172129a112f043a467203ab88df5 Mon Sep 17 00:00:00 2001
From: Vlad
Date: Mon, 8 Jun 2026 10:43:47 +0200
Subject: [PATCH 5/6] fix(composer): default mode by gateway reachability, not
just install
A new chat now defaults to whatever's usable right now: Full Power when the
OpenClaw gateway is reachable; Work when it's off but an OpenRouter key is set
(don't drop the user into a dead Full Power); Full Power when off+no key but
OpenClaw is installed (startable on demand); otherwise Work.
Fixes two linked symptoms when OpenClaw is installed-but-off with a key: new
chats defaulted to Full Power, and the sidebar 'Start OpenClaw' banner showed
(it's gated on mode === 'execute'). Both now resolve to Work.
Co-Authored-By: Claude Opus 4.8
---
src/routes/chats.ts | 2 +-
src/routes/index.ts | 2 +-
src/services/chatModes.ts | 16 +++++++++++-----
3 files changed, 13 insertions(+), 7 deletions(-)
diff --git a/src/routes/chats.ts b/src/routes/chats.ts
index 08ddb41..318c85b 100644
--- a/src/routes/chats.ts
+++ b/src/routes/chats.ts
@@ -340,7 +340,7 @@ chatsRouter.get('/:id', async (req, res, next) => {
queueList: queuedMessages.listByChat(id),
sendHintShow: shouldShowSendHint(),
chatModes: listComposerModes(),
- defaultChatMode: defaultComposerMode(),
+ defaultChatMode: defaultComposerMode(!agentsError),
chatCurrentMode,
sttEnabled: openRouterEnabled(),
// Lets the composer lock the runtime modes (and the connect chooser fire)
diff --git a/src/routes/index.ts b/src/routes/index.ts
index 6f5005d..010bf5c 100644
--- a/src/routes/index.ts
+++ b/src/routes/index.ts
@@ -170,7 +170,7 @@ indexRouter.get('/', async (req, res) => {
workingIds: chatStatus.workingIds(),
sendHintShow: shouldShowSendHint(),
chatModes: listComposerModes(),
- defaultChatMode: defaultComposerMode(),
+ defaultChatMode: defaultComposerMode(gatewayStatus === 'ok'),
sttEnabled: openRouterEnabled(),
// With an OpenRouter key the runtime modes (Work / Safe work / Incognito)
// work without OpenClaw — so a missing gateway must NOT block starting a chat.
diff --git a/src/services/chatModes.ts b/src/services/chatModes.ts
index 34003fa..b5625c8 100644
--- a/src/services/chatModes.ts
+++ b/src/services/chatModes.ts
@@ -168,12 +168,18 @@ export function isSelectableMode(id: string): boolean {
}
/**
- * UI default for the composer after onboarding. Keyed on what's on the device:
- * Full Power when OpenClaw is installed (it can be started on demand), otherwise
- * Work — the OpenRouter runtime path. Work may still be locked until a key is
- * added; the composer surfaces the connect chooser on first send in that case.
+ * UI default for the composer's mode selector on a new chat — picks whatever is
+ * actually usable right now:
+ * - OpenClaw gateway reachable → Full Power.
+ * - gateway off but an OpenRouter key is set → Work (works immediately; don't
+ * drop the user into a dead Full Power).
+ * - gateway off, no key, OpenClaw installed → Full Power (startable on demand).
+ * - otherwise → Work (locked until a key is added; the composer then surfaces
+ * the connect chooser on first send).
*/
-export function defaultComposerMode(): ChatMode {
+export function defaultComposerMode(gatewayReachable = false): ChatMode {
+ if (gatewayReachable) return 'execute';
+ if (openRouterEnabled()) return DEFAULT_COMPOSER_MODE;
return isOpenClawInstalled() ? 'execute' : DEFAULT_COMPOSER_MODE;
}
From a7684edacc7d248b4edf8ecb54806e0024b35d87 Mon Sep 17 00:00:00 2001
From: Vlad
Date: Mon, 8 Jun 2026 10:49:33 +0200
Subject: [PATCH 6/6] feat(draft): Space selects "No project" in the project
picker
Quick skip past project selection: pressing Space on the "Choose a project"
stage commits the draft with No project, with a hint under the title. Guarded to
the active picking stage and ignored while a field is focused; Enter still
activates a focused project card.
Co-Authored-By: Claude Opus 4.8
---
public/css/style.css | 17 ++++++++++++++++-
public/js/iclaw.js | 15 +++++++++++++++
views/index.ejs | 1 +
3 files changed, 32 insertions(+), 1 deletion(-)
diff --git a/public/css/style.css b/public/css/style.css
index 58b834f..4e7bfbe 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -3690,11 +3690,26 @@ code {
background: transparent;
}
.project-pick-title {
- margin: 0 0 22px;
+ margin: 0 0 6px;
font-size: 1.05rem;
font-weight: 650;
letter-spacing: -0.02em;
}
+.project-pick-hint {
+ margin: 0 0 22px;
+ font-size: var(--text-sm);
+ color: var(--muted);
+}
+.project-pick-hint kbd {
+ font: inherit;
+ font-size: 0.8em;
+ padding: 1px 6px;
+ border: 1px solid var(--border);
+ border-bottom-width: 2px;
+ border-radius: 6px;
+ background: color-mix(in srgb, var(--text) 5%, var(--surface));
+ color: var(--text);
+}
.project-pick-grid {
display: flex;
flex-wrap: wrap;
diff --git a/public/js/iclaw.js b/public/js/iclaw.js
index df43092..244c942 100644
--- a/public/js/iclaw.js
+++ b/public/js/iclaw.js
@@ -1721,6 +1721,21 @@
void commitDraftFromCard(card);
});
+ // Space = "No project" — a quick skip past project selection. Guarded to the
+ // active picking stage so it never fires afterwards, and ignored while a text
+ // field is focused. commitDraftFromCard's re-entry guard makes this safe even
+ // if a card button also has focus.
+ document.addEventListener('keydown', (e) => {
+ if (e.key !== ' ' && e.code !== 'Space') return;
+ if (draftProjectLocked || !draftBody.classList.contains('is-picking')) return;
+ const t = e.target;
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
+ const none = projectPickEl.querySelector('.project-pick-card--none');
+ if (!none) return;
+ e.preventDefault();
+ void commitDraftFromCard(none);
+ });
+
const initSel = (projectPickEl.dataset.initialProjectId || '').trim();
if (initSel !== '') {
const esc =
diff --git a/views/index.ejs b/views/index.ejs
index 695bc14..6e77fd2 100644
--- a/views/index.ejs
+++ b/views/index.ejs
@@ -56,6 +56,7 @@
data-initial-project-id="<%= preselectedProject ? String(preselectedProject.id) : '' %>"
>