diff --git a/README.md b/README.md index ec7069d..07906c4 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,12 @@

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

- CI - npm npm downloads + Reddit MIT

@@ -19,65 +18,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`. +[![Star History Chart](https://api.star-history.com/svg?repos=iClawApp/iClaw&type=Date)](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 +53,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 - -[![Star History Chart](https://api.star-history.com/svg?repos=iClawApp/iClaw&type=Date)](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). diff --git a/public/css/style.css b/public/css/style.css index d84f4da..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; @@ -5324,6 +5339,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 +8315,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 799d321..244c942 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; @@ -169,6 +191,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; @@ -183,12 +211,20 @@ ); 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'); } 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(); } /** @@ -230,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'); } }); } @@ -457,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. @@ -1677,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 = @@ -5314,8 +5373,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 +5398,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,11 +5412,34 @@ 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(() => {}); })(); + // ── 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); @@ -6591,10 +6688,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..318c85b 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(), - defaultChatMode: defaultComposerMode(), + chatModes: listComposerModes(), + defaultChatMode: defaultComposerMode(!agentsError), 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..010bf5c 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,8 +169,8 @@ indexRouter.get('/', async (req, res) => { cloudShareBaseUrl, workingIds: chatStatus.workingIds(), sendHintShow: shouldShowSendHint(), - chatModes: listSelectableModes(), - defaultChatMode: defaultComposerMode(), + chatModes: listComposerModes(), + 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 466368f..b5625c8 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,19 @@ 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'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 { - return isSelectableMode(DEFAULT_COMPOSER_MODE) ? DEFAULT_COMPOSER_MODE : DEFAULT_MODE; +export function defaultComposerMode(gatewayReachable = false): ChatMode { + if (gatewayReachable) return 'execute'; + if (openRouterEnabled()) return DEFAULT_COMPOSER_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 (chatMessages.length === 0) { %> + <% if (!Array.isArray(allProjects) || allProjects.length === 0) { %> + <%- include('partials/welcomeCard') %> + <% } else { %>

iClaw - the safer way to work with powerful AI

+ <% } %> <% } else { %> <% chatMessages.forEach(function (m) { %>
diff --git a/views/index.ejs b/views/index.ejs index fe01531..6e77fd2 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -38,15 +38,16 @@ <% const _firstChat = typeof isFirstChat !== 'undefined' && isFirstChat; %> + <% const _noProjects = !Array.isArray(allProjects) || allProjects.length === 0; %>
+ <%- (_firstChat || _noProjects) ? 'data-auto-none="1"' : '' %> >
- <% 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). %>

Choose a project

+

Press Space to select No project

- <% } %>
<% if (_firstChat) { %> -
- -
-

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('partials/welcomeCard') %> <% } else { %> <% } %> @@ -117,11 +101,9 @@
- <% if (!agentsError || openRouterReady) { %> - <% } %>
<% if (cloudShareBaseUrl) { %> diff --git a/views/partials/composer.ejs b/views/partials/composer.ejs index b2769b1..5b9611e 100644 --- a/views/partials/composer.ejs +++ b/views/partials/composer.ejs @@ -52,8 +52,11 @@ // each chat's own mode instead of resetting to the default. const _chatCurrentMode = typeof chatCurrentMode !== 'undefined' && chatCurrentMode ? chatCurrentMode : ''; + const _curModeDef = _modes.find(function (m) { return m.id === _chatCurrentMode; }); + // Don't pre-select a locked mode (e.g. a chat last used in Work after the key + // was removed) — fall back to the default, which is always usable. const _initialMode = - _modes.find(function (m) { return m.id === _chatCurrentMode; }) ? _chatCurrentMode : _defaultMode; + _curModeDef && _curModeDef.available !== false ? _chatCurrentMode : _defaultMode; const _initialModeDef = _modes.find(function (m) { return m.id === _initialMode; }) || _defaultModeDef; %> @@ -173,7 +176,7 @@
+ +<%- 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. %> + 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) { %>
+ <% } %>