Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ test-results/
e2e-install/cache/
scripts/nm-dispatcher-failover.sh
.claude/

# Local debug scripts (per-session sandbox)
.scratch/
1 change: 1 addition & 0 deletions Clawbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ You control the entire OS through the **MCP server** (`mcp/clawbox-mcp.ts`). You
### ⚙️ Preferences

- `preferences_get` / `preferences_set` — language, layout, etc.
- **Remember the user's name.** When the user introduces themselves ("I'm Krasi", "my name is Maya", "call me Sam") or you otherwise learn their preferred name, persist it immediately with `preferences_set('{"ui_user_name": "<name>"}')`. The mascot reads this for occasional name-greetings; the Settings → Appearance "Your name" field reads it too and refreshes within ~5 s. Only set it for *the actual person at the desk* — don't write a name they mentioned in passing about someone else, and don't infer one from email metadata. If they later say "actually call me X instead", overwrite it. If they ask to be anonymous again, `preferences_set('{"ui_user_name": ""}')`. Never echo back the stored name as confirmation unless they ask — silently doing the right thing is the vibe.

### 📋 Tasks & sub-agents

Expand Down
19 changes: 19 additions & 0 deletions config/clawbox-workspace-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,22 @@ The Chromium window is visible on the ClawBox desktop (accessible via the VNC vi
- The ClawBox project dir is `/home/clawbox/clawbox/`. User data is in `data/`. Skills are in the OpenClaw workspace (see Skills above).
- The gateway's own config is `~/.openclaw/openclaw.json`. Don't edit it directly — use the `openclaw config set` CLI or let ClawBox's setup routes manage it.
- The device exposes itself at `http://clawbox.local` / `http://<LAN-IP>` / `http://10.42.0.1` (AP mode).

---

## Remember the user's name

When the user introduces themselves ("I'm Krasi", "my name is Maya", "call me Sam"), or you otherwise learn their preferred name during the conversation, persist it immediately:

```text
preferences_set('{"ui_user_name": "<name>"}')
```

The mascot reads `ui_user_name` for occasional name-greetings; the desktop's Settings → Appearance "Your name" field reads it too and refreshes within ~5 s without a manual reload.

Edge cases:
- **Only set it for the actual person at the desk.** Don't write a name they mentioned in passing about someone else (kids, pets, colleagues) — the field is "*your* name", not "names mentioned in conversation".
- **Don't infer from email metadata** or signatures. Wait for a direct introduction.
- **Overwrite on rename.** If they later say "actually, call me X instead", call `preferences_set` again with the new value.
- **Clear on anonymity request.** If they ask not to be named, or want it reset, `preferences_set('{"ui_user_name": ""}')`.
- **Don't echo back the stored name as confirmation** unless they explicitly ask. Silently doing the right thing is the vibe.
23 changes: 13 additions & 10 deletions e2e/chat-popup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,17 @@ test("chat popup lets you switch to Local AI when it is configured", async ({ pa
await page.getByRole("button", { name: "Chat" }).click();
await expect(page.getByText("Hello from the fake gateway")).toBeVisible();

const sourceSelect = page.getByLabel("Chat model");
await expect(sourceSelect).toBeVisible();
await expect(sourceSelect).toHaveValue("clawai/deepseek-r1");
await sourceSelect.click();
await expect(sourceSelect).toBeFocused();
await page.keyboard.press("Escape");

await sourceSelect.selectOption("llamacpp/gemma4-e2b-it-q4_0");
await expect(sourceSelect).toHaveValue("llamacpp/gemma4-e2b-it-q4_0");
// Provider dropdown is a custom popover (HeaderDropdown), not a
// native <select>. Open via click on the trigger, pick the option
// by accessible name. The previous Escape sanity-check was native-
// select-specific (to confirm the browser's built-in dropdown
// dismissed) — for the popover, Escape is a closer-of-popover-AND-
// close-of-chat-popup ambiguity since the chat panel also handles
// Escape, so we just exercise the open/select flow that users hit.
const providerTrigger = page.getByRole("button", { name: "Chat provider" });
await expect(providerTrigger).toBeVisible();
await providerTrigger.click();
await page.getByRole("option", { name: /Gemma 4 Local/ }).click();
await expect(page.getByText("Switched chat to llamacpp/gemma4-e2b-it-q4_0.")).toBeVisible();
});

Expand Down Expand Up @@ -225,7 +227,8 @@ test("chat popup opens Local AI settings when local AI is not configured", async
await page.getByRole("button", { name: "Chat" }).click();
await expect(page.getByText("Hello from the fake gateway")).toBeVisible();

await page.getByLabel("Chat model").selectOption({ label: "Local AI - Set up in Settings" });
await page.getByRole("button", { name: "Chat provider" }).click();
await page.getByRole("option", { name: "Local AI - Set up in Settings" }).click();

const settingsWindow = page.getByTestId("chrome-window-settings");
await expect(settingsWindow).toBeVisible();
Expand Down
17 changes: 12 additions & 5 deletions e2e/helpers/clawbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -775,25 +775,32 @@ export async function installClawboxMocks(page: Page, options: MockOptions = {})
}

if (path === "/setup-api/ai-models/status") {
// ClawKeep / Remote Desktop / chat-popup gates check `clawaiTier`
// for paid-plan entitlement. The shared mock now claims Pro tier
// (`flash`) so existing e2e tests that drive the paired ClawKeep
// flow or the chat dropdown don't trip the Free-user upgrade card.
// Tests that need the Free path can override this route inline.
// ClawKeep / Remote Desktop / chat-popup gates check tier-related
// fields for paid-plan entitlement. The shared mock claims Pro
// tier (flash) for both the active chat provider (`clawaiTier`)
// AND the account-level tier (`clawaiAccountTier`) so existing
// e2e tests that drive the paired ClawKeep flow or the chat
// dropdown don't trip the Free-user upgrade card. Tests that
// need the Free or no-clawai-account paths can override this
// route inline.
await fulfillJson(route, setupState.ai_model_configured
? {
connected: true,
provider: "clawai",
providerLabel: "ClawBox AI",
model: "clawai/deepseek-r1",
clawaiTier: "flash",
clawaiAccountTier: "flash",
clawaiConfigured: true,
}
: {
connected: false,
provider: null,
providerLabel: null,
model: null,
clawaiTier: null,
clawaiAccountTier: null,
clawaiConfigured: false,
});
return;
}
Expand Down
140 changes: 140 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,146 @@ h6 {
box-shadow: 0 8px 30px rgba(249, 115, 22, 0.35);
}

/* Chat-header pills row — three independent rounded pills with a small
* gap between them. Each pill has its own background + border so they
* read as separate controls, not as one segmented bar. The rounded-bar
* variant felt heavy and pinched the labels on narrow chat panels. */
.chat-header-pills {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}

/* Custom popover dropdown — replaces native <select> for the chat
* header pills so we can keep the trigger compact (truncating with
* "...") while the open menu shows full labels + hints in a wider
* card that floats above the chat content. */
.header-dropdown-trigger {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #fff;
padding: 5px 24px 5px 12px;
font-size: 11px;
font-weight: 600;
outline: none;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
display: inline-flex;
align-items: center;
position: relative;
min-width: 0;
border-radius: 999px;
}
.header-dropdown-trigger:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.09);
border-color: rgba(255, 255, 255, 0.16);
}
.header-dropdown-trigger:focus-visible {
outline: 2px solid rgba(249, 115, 22, 0.6);
outline-offset: -2px;
}
.header-dropdown-trigger:disabled {
cursor: default;
opacity: 0.55;
}
.header-dropdown-trigger-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.header-dropdown-trigger-chevron {
position: absolute;
right: 4px;
font-size: 16px;
color: rgba(255, 255, 255, 0.55);
transition: transform 0.15s ease;
pointer-events: none;
}

.header-dropdown-popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 100;
background: #1a1f2e;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
padding: 4px;
display: flex;
flex-direction: column;
max-height: 320px;
overflow-y: auto;
/* Lock-in pop animation. */
animation: header-dropdown-pop 0.12s ease-out;
}
@keyframes header-dropdown-pop {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.header-dropdown-option {
appearance: none;
-webkit-appearance: none;
background: transparent;
border: none;
color: #fff;
text-align: left;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
transition: background 0.12s ease;
}
.header-dropdown-option:hover:not(:disabled) {
background: rgba(249, 115, 22, 0.12);
}
.header-dropdown-option:focus-visible {
outline: 2px solid rgba(249, 115, 22, 0.6);
outline-offset: -2px;
}
.header-dropdown-option:disabled {
cursor: default;
opacity: 0.4;
}
.header-dropdown-option.is-active {
background: rgba(249, 115, 22, 0.18);
}
.header-dropdown-option.is-active:hover {
background: rgba(249, 115, 22, 0.24);
}
.header-dropdown-option-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-weight: 600;
}
.header-dropdown-option-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-dropdown-option-check {
font-size: 16px;
color: #f97316;
flex-shrink: 0;
}
.header-dropdown-option-hint {
font-size: 11px;
color: rgba(255, 255, 255, 0.55);
font-weight: 400;
white-space: normal;
line-height: 1.3;
}

/* Card surface — matching openclawhardware.dev dark sections */
.card-surface {
background: var(--surface-card);
Expand Down
36 changes: 24 additions & 12 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,33 +169,45 @@ function InstalledAppIcon({ iconUrl, appId, name, size = "w-6 h-6" }: { iconUrl?
return <span className="material-symbols-rounded text-white" style={{ fontSize: px }}>extension</span>;
}

function isClawAiProvider(provider: unknown): boolean {
if (typeof provider !== "string") return false;
const normalized = provider.trim().toLowerCase();
return normalized === "clawai" || normalized === "deepseek";
}

function ChromeDesktopInner() {
const { t } = useT();
const resolveAppName = (app: AppDef) => t(app.name) || app.name;
const [setupChecked, setSetupChecked] = useState(false);
const [setupRequired, setSetupRequired] = useState(false);
const [showClawAiOfferNotification, setShowClawAiOfferNotification] = useState(false);
const [clawAiAuthenticated, setClawAiAuthenticated] = useState(false);
// Live tier (Free → null, Pro → "flash", Max → "pro"). Drives the
// ClawKeep shield colour and any other paid-only desktop affordance.
// Account-level "is ClawBox AI configured on this device?" — drives
// the shelf shield (colour + click target) and the offer-notification
// visibility. Sourced from useClawboxLogin (which now polls
// /setup-api/ai-models/status and exposes `clawaiConfigured` via its
// `loggedIn` field), not from /setup-api/setup/status's
// `ai_model_provider` — that's the *active chat provider* and would
// falsely flip to false the moment a Max subscriber switches the
// chat header dropdown to OpenAI, leaving them with a red shield
// that opens AI Settings instead of ClawKeep.
const clawboxLogin = useClawboxLogin();
const clawAiAuthenticated = clawboxLogin.loggedIn;
const clawkeepEntitled = clawboxLogin.tier !== null;

const syncSetupStatus = useCallback(async () => {
const data = await fetch("/setup-api/setup/status").then((r) => r.json());
setSetupRequired(!data.setup_complete);
const hasClawAi = isClawAiProvider(data.ai_model_provider) && !!data.ai_model_configured;
setClawAiAuthenticated(hasClawAi);
setShowClawAiOfferNotification(!!data.setup_complete && !hasClawAi);
return data;
}, []);

// The "Free backup with ClawBox AI" offer-notification asks the user
// to add ClawBox AI as a *desktop backup provider* — that's an
// account-level question, not "is clawai the active chat provider
// right now?". Source it from useClawboxLogin so a Max subscriber
// chatting via OpenAI doesn't get nagged to add an account they
// already have. Only show after setup is complete, the hook has
// settled (avoid a transient pop on refresh), and no clawai
// profile is configured.
useEffect(() => {
if (setupRequired) { setShowClawAiOfferNotification(false); return; }
if (clawboxLogin.loading) return;
setShowClawAiOfferNotification(!clawboxLogin.loggedIn);
}, [setupRequired, clawboxLogin.loading, clawboxLogin.loggedIn]);

// One-shot cleanup of stale chat localStorage from older builds.
useEffect(() => { purgeLegacyChatCaches() }, []);

Expand Down
Loading
Loading