create-openclaw-bot 5.17.0 → 5.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/web/app.js CHANGED
@@ -10,7 +10,7 @@ const OS_OPTIONS = [
10
10
  // Two deploy modes. Docker isolates the bot in a container; native runs openclaw + 9router
11
11
  // straight on this machine as a managed service (launchd/systemd/schtasks). Native needs no
12
12
  // Docker install, sees the host filesystem directly, and is the only mode where the bot can
13
- // drive apps on the desktop at the cost of the container's isolation.
13
+ // drive apps on the desktop - at the cost of the container's isolation.
14
14
  const MODE_OPTIONS = [
15
15
  { id: 'docker', title: 'Docker', subtitle: 'Isolated containers, safest default', icon: `${SVG_CDN}/docker/default.svg`, badge: 'Recommended' },
16
16
  { id: 'native', title: 'Native', subtitle: 'Runs on this machine, controls apps', icon: `${SVG_CDN}/gnubash/default.svg`, badge: 'Desktop' },
@@ -177,7 +177,7 @@ function confirmModal() {
177
177
  <button class="modal-x" data-confirm-action="cancel" aria-label="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
178
178
  <div class="donate-head"><span aria-hidden="true">${escapeHtml(m.icon || '⚠')}</span><div><p>${escapeHtml(m.eyebrow || t('Xác nhận','Confirm'))}</p><h2>${escapeHtml(m.title)}</h2><small>${escapeHtml(m.message || '')}</small></div></div>
179
179
  ${m.bodyHtml ? `<div class="confirm-body">${m.bodyHtml}</div>` : ''}
180
- <div class="confirm-actions">${m.hideCancel ? '' : `<button class="secondary" data-confirm-action="cancel">${t('Hủy','Cancel')}</button>`}<button class="primary ${m.okDanger === false ? '' : 'danger'}" data-confirm-action="ok">${escapeHtml(m.okText || t('Xóa','Delete'))}</button></div>
180
+ <div class="confirm-actions">${m.hideCancel ? '' : `<button class="secondary" data-confirm-action="cancel">${escapeHtml(m.cancelText || t('Hủy','Cancel'))}</button>`}<button class="primary ${m.okDanger === false ? '' : 'danger'}" data-confirm-action="ok">${escapeHtml(m.okText || t('Xóa','Delete'))}</button></div>
181
181
  </section>
182
182
  </div>`;
183
183
  }
@@ -321,50 +321,128 @@ function openPathModal({ title, message, value = '', placeholder = '', field2 =
321
321
  }
322
322
  /**
323
323
  * Shown right after PC control is granted on a native install: what the bot can now do, and the
324
- * one thing only the operator can do grant the OS screen permissions from the system settings.
324
+ * one thing only the operator can do - grant the OS screen permissions from the system settings.
325
325
  */
326
326
  function openComputerUseModal(r = {}) {
327
- const codex = r.codex || {};
328
- const appOk = codex.app && codex.app.present;
329
- const cli = (r.commands || []).includes('codex');
327
+ const cu = r.computerUse || {};
328
+ const isMac = /mac/i.test(state.system?.os || state.os || '');
330
329
  const line = (icon, text) => `<li><span aria-hidden="true">${icon}</span><div>${text}</div></li>`;
330
+
331
+ // OpenClaw's own `computer` tool is the real thing here: screenshot, move, click, type, drag.
332
+ // Codex is only a fallback for machines that have the ChatGPT app, so it must not lead - the
333
+ // old copy said "mouse/keyboard control is macOS-only", which stopped being true once the
334
+ // cua-computer plugin shipped for Windows.
331
335
  const statusItems = [
332
- cli
333
- ? line('✅', t('Bot giao được việc cho <code>codex</code> Codex tự nhìn màn hình, click, gõ phím rồi trả kết quả về.', 'The bot can hand jobs to <code>codex</code> Codex looks at the screen, clicks and types, then reports back.'))
334
- : line('⚠️', t('Không tìm thấy CLI <code>codex</code> cài app ChatGPT/Codex rồi bật lại quyền này.', 'No <code>codex</code> CLI found install the ChatGPT/Codex app, then re-enable this.')),
335
- codex.pluginInstalled
336
- ? line('✅', t(`Computer Use trong app Codex đã ${codex.installedNow ? 'được cài' : 'sẵn sàng'}${codex.mcpRepaired ? ' (đã sửa khai báo MCP cũ)' : ''}.`, `Computer Use in the Codex app is ${codex.installedNow ? 'installed' : 'ready'}${codex.mcpRepaired ? ' (stale MCP entry repaired)' : ''}.`))
337
- : line('⚠️', t(`Chưa bật được Computer Use trong app Codex: ${escapeHtml(codex.error || 'không rõ lý do')}`, `Could not enable Computer Use in the Codex app: ${escapeHtml(codex.error || 'unknown reason')}`)),
338
- appOk
339
- ? line('✅', t('Đã thấy ứng dụng ChatGPT/Codex — <b>nhớ để app đang chạy</b>.', 'Found the ChatGPT/Codex desktop app — <b>keep it running</b>.'))
340
- : line('⚠️', t('CHƯA thấy ứng dụng ChatGPT/Codex — cài rồi mở lên, không có nó thì không điều khiển GUI được.', 'No ChatGPT/Codex desktop app found — install and open it, GUI control needs it.')),
341
- (r.granted && r.granted.length)
342
- ? line('✅', t(`Đã cấp quyền chạy: ${escapeHtml(r.granted.join(', '))}.`, `Granted: ${escapeHtml(r.granted.join(', '))}.`))
336
+ cu.ok && cu.enabled
337
+ ? line('✅', t('Bot <b>nhìn điều khiển được máy này</b>: chụp màn hình, rê chuột, bấm chuột, gõ phím, kéo thả.', 'Your bot <b>can see and drive this machine</b>: screenshot, move, click, type, drag.'))
338
+ : line('⚠️', t(`Chưa bật được điều khiển máy: ${escapeHtml(cu.error || 'không do')}`, `Could not enable computer control: ${escapeHtml(cu.error || 'unknown reason')}`)),
339
+ // No app list any more: the bot opens things the way a person does - Start menu, type the
340
+ // name, Enter - so it is not limited to whatever we managed to enumerate.
341
+ cu.ok && cu.enabled
342
+ ? line('✅', t('Mở được <b>mọi ứng dụng đang cài</b> trên máy, kể cả app mới cài hôm qua.', 'Can open <b>any installed app</b>, including one installed yesterday.'))
343
343
  : '',
344
344
  ].filter(Boolean).join('');
345
+
346
+ // Windows needs no privacy prompts; macOS does, and only from System Settings.
347
+ const permBlock = isMac ? `
348
+ <h4>${t('Cấp quyền màn hình cho máy','Grant the OS screen permissions')}</h4>
349
+ <p>${t('macOS chỉ cấp <b>Screen Recording</b> và <b>Accessibility</b> từ System Settings. Bấm nút dưới rồi bật cho <code>node</code>.','macOS only grants <b>Screen Recording</b> and <b>Accessibility</b> from System Settings. Click below, then tick <code>node</code>.')}</p>
350
+ <p class="cu-perm-row">
351
+ <button class="secondary cu-btn" type="button" data-host-perm="screen">${t('Chụp/quay màn hình','Screen recording')}</button>
352
+ <button class="secondary cu-btn" type="button" data-host-perm="accessibility">${t('Accessibility (chuột/bàn phím)','Accessibility (mouse/keys)')}</button>
353
+ </p>` : '';
354
+
345
355
  state.confirmModal = {
346
356
  icon: '🖥️',
347
357
  eyebrow: t('Điều khiển máy','PC control'),
348
- title: t('Xong còn 1 bước bạn tự làm','Done one step left for you'),
358
+ title: cu.ok && cu.enabled ? t('Đã bật điều khiển máy','Computer control is on') : t('Bật chưa xong','Not fully enabled'),
349
359
  message: t('Model chính của bot vẫn là smart-route, không đổi.','Your bot keeps smart-route as its primary model.'),
350
360
  bodyHtml: `
351
361
  <ul class="cu-status">${statusItems}</ul>
352
- <h4>${t('Cấp quyền màn hình cho máy','Grant the OS screen permissions')}</h4>
353
- <p>${t('macOS chỉ cấp <b>Screen Recording</b> / <b>Accessibility</b> từ System Settings bấm nút dưới, rồi bật cho <code>node</code> app Codex.','macOS only grants <b>Screen Recording</b> / <b>Accessibility</b> from System Settings click below, then tick <code>node</code> and the Codex app.')}</p>
354
- <p class="cu-perm-row">
355
- <button class="secondary cu-btn" type="button" data-host-perm="screen">${t('Chụp/quay màn hình','Screen recording')}</button>
356
- <button class="secondary cu-btn" type="button" data-host-perm="accessibility">${t('Accessibility (chuột/bàn phím)','Accessibility (mouse/keys)')}</button>
357
- </p>
358
- <p class="cu-note">${t('Cách dùng: nhắn bot bình thường, ví dụ “mở TeamViewer và đọc giúp mật khẩu trên màn hình” — bot tự giao cho Codex rồi báo kết quả về.','How to use it: just ask your bot normally, e.g. “open TeamViewer and read the password on screen” — it hands the job to Codex and reports back.')}</p>
359
- <p class="cu-note">${t('Việc giao cho Codex chạy bằng gói ChatGPT đã đăng nhập (tốn quota gói đó); chat thường vẫn đi qua các model free của <code>smart-route</code>. Điều khiển chuột/bàn phím hiện chỉ có trên macOS.','Jobs handed to Codex run on the signed-in ChatGPT plan (they spend that quota); ordinary chat still uses the free <code>smart-route</code> models. Mouse/keyboard control is macOS-only for now.')}</p>
362
+ ${permBlock}
363
+ <p class="cu-note">${t('Cách dùng: nhắn bot bình thường, dụ “chụp màn hình cho anh xem” hoặc “mở TeamViewer rồi đọc mật khẩu trên màn hình”.','How to use it: just ask your bot normally, e.g. "take a screenshot" or "open TeamViewer and read the password on screen".')}</p>
364
+ <p class="cu-note">${t('Chỉ dùng được trên máy có màn hình. Máy chủ VPS không màn hình thì không áp dụng. Tắt nút này là thu hồi lại toàn bộ quyền trên.','Only works on a machine with a screen. A headless VPS cannot use this. Turning the switch off revokes all of it.')}</p>
365
+ <p class="cu-note">${t('Sau khi khởi động lại máy, bấm <b>"1 - KHOI DONG BOT"</b> là phần điều khiển máy cũng tự bật lại cùng bot.','After restarting the machine, pressing <b>"1 - KHOI DONG BOT"</b> brings computer control back up along with the bot.')}</p>
360
366
  `,
361
367
  okText: t('Đã hiểu','Got it'),
362
368
  okDanger: false,
363
369
  hideCancel: true,
364
370
  onConfirm: () => { state.confirmModal = null; render(); },
365
371
  };
372
+ }
373
+
374
+ /**
375
+ * Offer the move off Docker, once, when we can see the project is still on it.
376
+ *
377
+ * Relying on the operator to press "Update" does not work: nothing on screen says that Update is
378
+ * ALSO the migration, so a machine sits on Docker indefinitely. And it cannot sit there safely -
379
+ * OpenClaw 2026.9 cannot write its own settings through a Docker shared folder on Windows, so the
380
+ * next image rebuild takes the bot down for good. Say that plainly, show what is gained, and make
381
+ * the move one button.
382
+ *
383
+ * Asked once per project, then remembered: a prompt that returns on every page load is a prompt
384
+ * people learn to dismiss without reading.
385
+ */
386
+ function maybeOfferNativeMigration() {
387
+ const s = state.install || {};
388
+ if (s.deployMode !== 'docker') return;
389
+ const dir = s.projectDir || '';
390
+ if (!dir) return;
391
+ const key = 'openclaw-native-offer:' + dir;
392
+ try { if (localStorage.getItem(key)) return; } catch (_) { /* private window: just ask */ }
393
+ if (state.confirmModal) return; // never stack on top of another dialog
394
+ const rows = [
395
+ ['🖥️', t('Bot điều khiển được máy này', 'Your bot can drive this machine'),
396
+ t('Chụp màn hình, rê chuột, bấm, gõ phím. Bản Docker không làm được vì bot nằm trong container, không thấy màn hình.',
397
+ 'Screenshots, mouse, clicks, typing. Docker cannot do this: the bot sits in a container and never sees your screen.')],
398
+ ['🛡️', t('Tránh được lỗi làm chết bot', 'Avoids the fault that kills bots'),
399
+ t('OpenClaw bản mới không ghi được cấu hình qua thư mục chia sẻ của Docker trên Windows. Cứ dựng lại image một lần là bot chết hẳn.',
400
+ 'Recent OpenClaw cannot write its settings through a Docker shared folder on Windows. One image rebuild and the bot is gone.')],
401
+ ['⚡', t('Nhẹ và nhanh hơn', 'Lighter and faster'),
402
+ t('Không cần Docker Desktop chạy nền, bot khởi động nhanh hơn và ăn ít RAM hơn.',
403
+ 'No Docker Desktop running in the background, faster start-up and less RAM.')],
404
+ ['🔒', t('An toàn như cũ', 'Just as safe'),
405
+ t('Bot vẫn chỉ nghe trên máy này, không mở ra ngoài internet. Quyền điều khiển máy vẫn mặc định TẮT.',
406
+ 'The bot still listens on this machine only, never the open internet. Computer control stays OFF by default.')],
407
+ ];
408
+ state.confirmModal = {
409
+ icon: '📦',
410
+ eyebrow: t('Chuyển sang chạy thẳng trên máy', 'Move off Docker'),
411
+ title: t('Bot đang chạy trong Docker', 'This bot still runs in Docker'),
412
+ message: t('Chuyển sang chạy thẳng trên máy được không? Toàn bộ bot, tệp trong thư mục làm việc và phiên đăng nhập Zalo giữ nguyên.',
413
+ 'Move it to run directly on this machine? Every bot, every workspace file and every Zalo login comes with it.'),
414
+ bodyHtml: `
415
+ <ul class="cu-status grant-list">${rows.map(([i, ttl, d]) =>
416
+ `<li><span aria-hidden="true">${i}</span><div><b>${ttl}</b><br>${d}</div></li>`).join('')}</ul>
417
+ <p class="cu-note">${t('Mất khoảng 2-5 phút, bot ngừng trả lời trong lúc đó. Container cũ chỉ được <b>dừng</b> chứ không xoá, nên vẫn có đường quay lui.',
418
+ 'Takes about 2-5 minutes, during which the bot stops replying. The old containers are <b>stopped</b>, not deleted, so there is a way back.')}</p>`,
419
+ okText: t('Chuyển ngay', 'Move now'),
420
+ cancelText: t('Để sau', 'Later'),
421
+ onCancel: () => {
422
+ // "Later" means later, not never - but stop asking on this machine.
423
+ try { localStorage.setItem(key, String(Date.now())); } catch (_) {}
424
+ state.confirmModal = null;
425
+ render();
426
+ },
427
+ onConfirm: async () => {
428
+ try { localStorage.setItem(key, String(Date.now())); } catch (_) {}
429
+ state.confirmModal = null;
430
+ render();
431
+ showToast(t('Đang chuyển sang chạy thẳng trên máy', 'Moving off Docker'),
432
+ t('Giữ cửa sổ này mở. Xem tiến trình ở tab Nhật ký.', 'Keep this window open. Watch the Log tab for progress.'));
433
+ try {
434
+ await api('/api/runtime/update', { method: 'POST', body: { projectDir: dir, target: 'openclaw' } });
435
+ await loadStatus(true);
436
+ showToast(t('Đã chuyển xong', 'Moved'), t('Bot giờ chạy thẳng trên máy này.', 'The bot now runs directly on this machine.'));
437
+ } catch (err) {
438
+ showToast(t('Chuyển không xong', 'Move failed'), err.message, 'error');
439
+ }
440
+ render();
441
+ },
442
+ };
366
443
  render();
367
444
  }
445
+
368
446
  async function pickFolderPathShared() {
369
447
  try {
370
448
  const picked = await api('/api/project/pick-folder', { method: 'POST', body: {} });
@@ -398,7 +476,7 @@ function isVersionNewer(a, b) {
398
476
  }
399
477
 
400
478
  // Build a URL on the SAME host the dashboard is currently viewed from, so "Open" works
401
- // whether you reached it via a localhost tunnel, the server's IP, or a domain and never
479
+ // whether you reached it via a localhost tunnel, the server's IP, or a domain - and never
402
480
  // points at the viewer's own localhost when browsing from another machine. Port comes from
403
481
  // the backend-provided URL (which carries the right port) or the fallback.
404
482
  function sameHostUrl(rawUrl, fallbackPort) {
@@ -409,7 +487,7 @@ function sameHostUrl(rawUrl, fallbackPort) {
409
487
  return `${proto}//${host}:${port}`;
410
488
  }
411
489
 
412
- // "Open from another machine" helper builds a ready SSH-tunnel command (auto-filled
490
+ // "Open from another machine" helper - builds a ready SSH-tunnel command (auto-filled
413
491
  // with the server's public IP + the dashboard/gateway/9router/zalo-mod ports) so any user
414
492
  // on a VPS can reach the web UIs from their own computer without knowing how to set it up.
415
493
  function remoteAccessPanel(s = {}) {
@@ -418,7 +496,7 @@ function remoteAccessPanel(s = {}) {
418
496
  const host = r.host || '<your-server-ip>';
419
497
  const user = r.user || 'root';
420
498
  const portOf = (raw, def) => { try { return new URL(raw).port || def; } catch { return def; } };
421
- // zalo-mod's dashboard is gateway port + 1, not a fixed 18790 the same rule the plugin card's
499
+ // zalo-mod's dashboard is gateway port + 1, not a fixed 18790 - the same rule the plugin card's
422
500
  // "Open" button uses. Hardcoding it meant any project whose gateway is not on 18789 got a tunnel
423
501
  // command missing the dashboard port, and the dashboard then simply failed to load with no clue why.
424
502
  const gwPort = Number(portOf(s.gatewayUrl, 18789));
@@ -426,7 +504,7 @@ function remoteAccessPanel(s = {}) {
426
504
  const cmd = `ssh ${ports.map((p) => `-L ${p}:127.0.0.1:${p}`).join(' ')} ${user}@${host}`;
427
505
  return `<details class="card" style="margin-top:12px;" ${r.headless ? 'open' : ''}>
428
506
  <summary style="cursor:pointer; font-weight:600;">🌐 ${t('Mở từ máy khác (VPS/server)', 'Open from another machine (VPS/server)')}</summary>
429
- <p class="lead" style="margin-top:8px;">${t('Server không có trình duyệt trên MÁY của bạn chạy lệnh này rồi mở', 'No browser on the server on YOUR computer run this, then open')} <code>http://localhost:${r.uiPort || 51789}</code>:</p>
507
+ <p class="lead" style="margin-top:8px;">${t('Server không có trình duyệt - trên MÁY của bạn chạy lệnh này rồi mở', 'No browser on the server - on YOUR computer run this, then open')} <code>http://localhost:${r.uiPort || 51789}</code>:</p>
430
508
  <div style="display:flex; gap:8px;">
431
509
  <input readonly value="${escapeHtml(cmd)}" onclick="this.select()" style="flex:1; font-family:ui-monospace,monospace; font-size:12px; padding:8px 10px; border-radius:8px; border:1px solid rgba(255,255,255,.15); background:rgba(255,255,255,.03); color:inherit;" />
432
510
  <button class="secondary icon-btn2" type="button" data-copy="${escapeHtml(cmd)}">${actionIcon('link')}<span>${t('Copy', 'Copy')}</span></button>
@@ -438,7 +516,7 @@ function remoteAccessPanel(s = {}) {
438
516
  function topbarActionsHtml() {
439
517
  const setupVer = state.system?.versions?.setup;
440
518
  const latestSetupVer = state.system?.versions?.latestSetup;
441
- // Only offer the update when the remote is genuinely newer (not merely different
519
+ // Only offer the update when the remote is genuinely newer (not merely different -
442
520
  // a local/dev build can be ahead of what's published).
443
521
  const hasNewVersion = !!(setupVer && latestSetupVer && isVersionNewer(latestSetupVer, setupVer));
444
522
  return `
@@ -460,7 +538,7 @@ function topbarActionsHtml() {
460
538
  <button class="seg__btn ${state.lang==='en'?'is-active':''}" data-pref="lang" data-value="en">EN</button>
461
539
  </div>
462
540
  ${hasNewVersion ? `
463
- <button class="topbar__btn seg__btn" data-update-setup title="${t('Đang dùng', 'Currently on')} ${setupVer} ${t('bấm để lên', 'click to upgrade to')} ${latestSetupVer}" style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 6px; border: 1px solid var(--ok); background: rgba(46, 230, 166, 0.08); color: var(--ok); font-weight: 600; cursor: pointer; transition: background 0.2s;">
541
+ <button class="topbar__btn seg__btn" data-update-setup title="${t('Đang dùng', 'Currently on')} ${setupVer} - ${t('bấm để lên', 'click to upgrade to')} ${latestSetupVer}" style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 6px; border: 1px solid var(--ok); background: rgba(46, 230, 166, 0.08); color: var(--ok); font-weight: 600; cursor: pointer; transition: background 0.2s;">
464
542
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="width:14px; height:14px;"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
465
543
  <span>${t('Cập nhật', 'Update')} ${latestSetupVer}</span>
466
544
  </button>
@@ -634,13 +712,13 @@ function wireSkillsHandlers(scope = document) {
634
712
  try {
635
713
  const r = await api('/api/browser/start-chrome', { method: 'POST', body: {} });
636
714
  if (r.headless) {
637
- // VPS has no display guide the user to run Chrome on THEIR machine + a reverse tunnel.
715
+ // VPS has no display - guide the user to run Chrome on THEIR machine + a reverse tunnel.
638
716
  state.confirmModal = {
639
717
  icon: '🌐',
640
718
  eyebrow: t('Chrome debug', 'Chrome debug'),
641
719
  title: t('Dùng Chrome trên máy của bạn', 'Use Chrome on your computer'),
642
- message: t('VPS không có màn hình nên không mở Chrome tại đây. Chạy 2 lệnh dưới trên máy của bạn và GIỮ chúng chạy bot sẽ tự dùng Chrome đó (cổng 9222). Không làm cũng không sao: bot vẫn có trình duyệt ẩn (headless) sẵn trong container.', 'This VPS has no display. Run the two commands below on YOUR computer and keep them running the bot will use that Chrome (port 9222). Optional: the bot already has a built-in headless browser.'),
643
- bodyHtml: `<p class="guide-step">1. ${t('Mở Chrome debug trên máy bạn macOS:', 'Start Chrome debug on your machine macOS:')}</p><code class="cmd">${escapeHtml(r.chromeCmdMac)}</code><p class="guide-step">${t('Hoặc Windows:', 'Or Windows:')}</p><code class="cmd">${escapeHtml(r.chromeCmdWin)}</code><p class="guide-step">2. ${t('Tạo tunnel về VPS (cửa sổ terminal khác):', 'Create the reverse tunnel (another terminal):')}</p><code class="cmd">${escapeHtml(r.tunnelCmd)}</code>`,
720
+ message: t('VPS không có màn hình nên không mở Chrome tại đây. Chạy 2 lệnh dưới trên máy của bạn và GIỮ chúng chạy - bot sẽ tự dùng Chrome đó (cổng 9222). Không làm cũng không sao: bot vẫn có trình duyệt ẩn (headless) sẵn trong container.', 'This VPS has no display. Run the two commands below on YOUR computer and keep them running - the bot will use that Chrome (port 9222). Optional: the bot already has a built-in headless browser.'),
721
+ bodyHtml: `<p class="guide-step">1. ${t('Mở Chrome debug trên máy bạn - macOS:', 'Start Chrome debug on your machine - macOS:')}</p><code class="cmd">${escapeHtml(r.chromeCmdMac)}</code><p class="guide-step">${t('Hoặc Windows:', 'Or Windows:')}</p><code class="cmd">${escapeHtml(r.chromeCmdWin)}</code><p class="guide-step">2. ${t('Tạo tunnel về VPS (cửa sổ terminal khác):', 'Create the reverse tunnel (another terminal):')}</p><code class="cmd">${escapeHtml(r.tunnelCmd)}</code>`,
644
722
  okText: t('Đã hiểu', 'Got it'),
645
723
  okDanger: false,
646
724
  hideCancel: true,
@@ -648,10 +726,10 @@ function wireSkillsHandlers(scope = document) {
648
726
  };
649
727
  render();
650
728
  } else {
651
- // The profile is a copy of the operator's own, so their normal Chrome can stay open
729
+ // The profile is a copy of the operator's own, so their normal Chrome can stay open -
652
730
  // say so, because the old flow required closing every Chrome window first.
653
731
  showToast(t('Đã mở Chrome cho bot', 'Chrome opened for the bot'),
654
- `${t('Cổng', 'Port')} ${r.port} ${t('cửa sổ Chrome mới này dùng bản sao profile của bạn (đã đăng nhập sẵn); Chrome thường của bạn cứ để nguyên.', 'the new window runs a copy of your profile (already signed in); your normal Chrome can stay open.')}`, 'success');
732
+ `${t('Cổng', 'Port')} ${r.port} - ${t('cửa sổ Chrome mới này dùng bản sao profile của bạn (đã đăng nhập sẵn); Chrome thường của bạn cứ để nguyên.', 'the new window runs a copy of your profile (already signed in); your normal Chrome can stay open.')}`, 'success');
655
733
  }
656
734
  } catch (err) {
657
735
  showToast(t('Thất bại', 'Failed'), err.message, 'error');
@@ -673,7 +751,7 @@ function content() {
673
751
  return `<div class="log-toolbar"><button class="copy-log" data-copy-log type="button" aria-label="Copy logs">${copyIcon()} ${t('Copy log','Copy log')}</button></div><div class="terminal big">${state.logs.map(l=>`<p>${escapeHtml(l)}</p>`).join('')}</div>`;
674
752
  }
675
753
 
676
- // Danh sách múi giờ phổ biến (IANA) cho dropdown pill. Gọn đủ dùng cho khu vực chính.
754
+ // Danh sách múi giờ phổ biến (IANA) cho dropdown pill. Gọn - đủ dùng cho khu vực chính.
677
755
  const TZ_OPTIONS = [
678
756
  ['Asia/Ho_Chi_Minh', 'Việt Nam (UTC+7)'],
679
757
  ['Asia/Bangkok', 'Bangkok (UTC+7)'],
@@ -1035,7 +1113,7 @@ function botListPanel(bots) {
1035
1113
  function statusBadge(v) { return `<span class="runtime-badge ${v === 'online' ? 'ok' : v === 'unknown' ? 'warn' : 'bad'}">${v || 'offline'}</span>`; }
1036
1114
  function credentialField({ id, label, value = '', editable = false, placeholder = '' }) {
1037
1115
  const empty = !String(value || '').trim();
1038
- return `<label class="cred-field ${empty ? 'is-empty' : ''}"><span>${label}</span><div class="cred-input-wrap"><input id="${id}" name="${id}" type="password" ${editable ? '' : 'readonly'} autocomplete="off" value="${escapeHtml(value)}" placeholder="${escapeHtml(placeholder)}"/><button class="secondary cred-toggle" type="button" data-toggle-secret="${id}">${t('Hiện','Show')}</button></div>${empty && editable ? `<small>${t('Đang trống nhập key rồi lưu.', 'Empty enter key then save.')}</small>` : ''}</label>`;
1116
+ return `<label class="cred-field ${empty ? 'is-empty' : ''}"><span>${label}</span><div class="cred-input-wrap"><input id="${id}" name="${id}" type="password" ${editable ? '' : 'readonly'} autocomplete="off" value="${escapeHtml(value)}" placeholder="${escapeHtml(placeholder)}"/><button class="secondary cred-toggle" type="button" data-toggle-secret="${id}">${t('Hiện','Show')}</button></div>${empty && editable ? `<small>${t('Đang trống - nhập key rồi lưu.', 'Empty - enter key then save.')}</small>` : ''}</label>`;
1039
1117
  }
1040
1118
  function botStatusPanel(s) {
1041
1119
  const c = s.credentials || {};
@@ -1070,7 +1148,7 @@ function joinDisplayPath(root = '', child = '') {
1070
1148
  function projectPathLine(bot = currentBot(), fileName = '') {
1071
1149
  const s = state.install || {};
1072
1150
  // Legacy configs stored the container-absolute workspace; show it relative to the project root
1073
- // (new configs are already relative see bot-config-gen).
1151
+ // (new configs are already relative - see bot-config-gen).
1074
1152
  const workspace = String(bot?.workspace || '').replace(/^\/home\/node\/project\/?/, '');
1075
1153
  const relFile = fileName ? String(fileName).replace(/^\.openclaw[\\/][^\\/]+[\\/]?/, '') : '';
1076
1154
  const full = workspace ? joinDisplayPath(s.projectDir || '', relFile ? `${workspace}/${relFile}` : workspace) : (s.projectDir || '-');
@@ -1122,16 +1200,16 @@ function filesView() { return `<div class="files">${state.files.map(f=>`<article
1122
1200
  function botSkillsPanel() {
1123
1201
  const flags = state.featureFlags || {};
1124
1202
  const skills = [
1125
- { id: 'cron', title: 'Cron', desc: 'Native scheduler (SQLite) cron guide in TOOLS.md' },
1203
+ { id: 'cron', title: 'Cron', desc: 'Native scheduler (SQLite) - cron guide in TOOLS.md' },
1126
1204
  { id: 'image-gen', title: 'Tạo ảnh Infographic', desc: 'Tạo ảnh infographic, poster tự động qua 9Router' },
1127
1205
  { id: 'web-search', title: 'Web Search', desc: 'Tìm kiếm web thời gian thực (DuckDuckGo)' },
1128
1206
  ];
1129
1207
  const plugins = [
1130
- { id: 'zalo-connect', title: 'OpenClaw Zalo Connect', desc: t('Kênh Zalo cá nhân (zca-js) BẮT BUỘC cho bot Zalo. Tự cài khi tạo bot Zalo đầu tiên; bấm Cập nhật để lên bản mới nhất.', 'Personal Zalo channel (zca-js) REQUIRED for Zalo bots. Auto-installed with your first Zalo bot; click Update for the latest version.'), channels: ['zalo-personal'] },
1131
- { id: 'learning-memory', title: 'Siêu Trí Nhớ Dài Hạn (learning-memory)', desc: t('Bộ nhớ always-on: nạp MEMORY.md + USER.md đã chắt lọc vào MỌI lượt kể cả trong nhóm nên bot không quên ngữ cảnh/quy tắc. Tự cài khi tạo bot.', 'Always-on memory: injects a curated MEMORY.md + USER.md into every turn including group chats so the bot stops forgetting context and rules. Auto-installed on bot creation.') },
1208
+ { id: 'zalo-connect', title: 'OpenClaw Zalo Connect', desc: t('Kênh Zalo cá nhân (zca-js) - BẮT BUỘC cho bot Zalo. Tự cài khi tạo bot Zalo đầu tiên; bấm Cập nhật để lên bản mới nhất.', 'Personal Zalo channel (zca-js) - REQUIRED for Zalo bots. Auto-installed with your first Zalo bot; click Update for the latest version.'), channels: ['zalo-personal'] },
1209
+ { id: 'learning-memory', title: 'Siêu Trí Nhớ Dài Hạn (learning-memory)', desc: t('Bộ nhớ always-on: nạp MEMORY.md + USER.md đã chắt lọc vào MỌI lượt - kể cả trong nhóm - nên bot không quên ngữ cảnh/quy tắc. Tự cài khi tạo bot.', 'Always-on memory: injects a curated MEMORY.md + USER.md into every turn - including group chats - so the bot stops forgetting context and rules. Auto-installed on bot creation.') },
1132
1210
  { id: 'openclaw-browser-automation', title: 'openclaw-browser-automation', desc: 'Smart Search + Browser (headless & Chrome thật)' },
1133
1211
  { id: 'openclaw-zalo-mod', title: 'openclaw-zalo-mod', desc: 'Zalo group helpers', channels: ['zalo-personal'], openWebPort: 18790, openWebPath: '/dashboard' },
1134
- { id: 'openclaw-fb-messenger', title: 'openclaw-fb-messenger', desc: t('Kênh Facebook Messenger webhook + Graph API (bắt buộc cho bot Messenger)', 'Facebook Messenger channel webhook + Graph API (required for Messenger bots)'), channels: ['fb-messenger'] },
1212
+ { id: 'openclaw-fb-messenger', title: 'openclaw-fb-messenger', desc: t('Kênh Facebook Messenger - webhook + Graph API (bắt buộc cho bot Messenger)', 'Facebook Messenger channel - webhook + Graph API (required for Messenger bots)'), channels: ['fb-messenger'] },
1135
1213
  { id: 'openclaw-facebook-crawler', title: 'openclaw-facebook-crawler', desc: 'Facebook crawler automation', channels: ['fb-messenger'] },
1136
1214
  { id: 'openclaw-n8n-facebook-poster', title: 'openclaw-n8n-facebook-poster', desc: 'Facebook post automation (n8n)', channels: ['fb-messenger'] },
1137
1215
  ];
@@ -1159,10 +1237,10 @@ function botSkillsPanel() {
1159
1237
  let toggleHtml = '';
1160
1238
  const secs = [];
1161
1239
  if (isInstalled) {
1162
- toggleHtml = `<label class="feature-switch" title="${locked ? t('Bắt buộc cho bot Zalo không thể tắt','Required for Zalo bots cannot be disabled') : ''}"${locked ? ' style="opacity:.4;pointer-events:none;cursor:not-allowed;"' : ''}><input type="checkbox" data-feature-toggle="${key}" ${(flags[key] || locked) ? 'checked' : ''} ${(loading || locked) ? 'disabled' : ''}/><span></span></label>`;
1240
+ toggleHtml = `<label class="feature-switch" title="${locked ? t('Bắt buộc cho bot Zalo - không thể tắt','Required for Zalo bots - cannot be disabled') : ''}"${locked ? ' style="opacity:.4;pointer-events:none;cursor:not-allowed;"' : ''}><input type="checkbox" data-feature-toggle="${key}" ${(flags[key] || locked) ? 'checked' : ''} ${(loading || locked) ? 'disabled' : ''}/><span></span></label>`;
1163
1241
  if (item.openWebPort) {
1164
1242
  // zalo-mod's dashboard is always gateway port + 1 (docker 18790, native 18890, or whatever
1165
- // the SELECTED project's gateway runs on) never the hardcoded default. Derive it from the
1243
+ // the SELECTED project's gateway runs on) - never the hardcoded default. Derive it from the
1166
1244
  // active project's gatewayUrl so every project opens its own dashboard.
1167
1245
  let gwPort = 0;
1168
1246
  try { gwPort = parseInt(new URL(state.install?.gatewayUrl).port, 10) || 0; } catch {}
@@ -1243,7 +1321,11 @@ function wireTab() {
1243
1321
  document.querySelectorAll('[data-confirm-action]').forEach(el => el.onclick = () => withButtonLoading(el, async () => {
1244
1322
  const action = el.dataset.confirmAction;
1245
1323
  const m = state.confirmModal;
1246
- if (action === 'cancel' || !m) { state.confirmModal = null; render(); return; }
1324
+ if (action === 'cancel' || !m) {
1325
+ // Declining can be meaningful - "ask me later" has to remember that it was asked.
1326
+ if (m && typeof m.onCancel === 'function') { await m.onCancel(); return; }
1327
+ state.confirmModal = null; render(); return;
1328
+ }
1247
1329
  if (action === 'ok' && typeof m.onConfirm === 'function') await m.onConfirm();
1248
1330
  }));
1249
1331
  // Screen-recording / accessibility grants: only the OS can give these, so the button opens the
@@ -1252,12 +1334,12 @@ function wireTab() {
1252
1334
  const kind = btn.dataset.hostPerm;
1253
1335
  try {
1254
1336
  const r = await api('/api/host/permissions', { method: 'POST', body: { kind, projectDir: activeProjectDir() } });
1255
- if (!r.opened) return showToast(t('Không mở được','Could not open'), t('Hệ điều hành này không có bảng cài đặt đó cấp quyền thủ công.','This OS has no such settings pane grant it manually.'), 'error');
1337
+ if (!r.opened) return showToast(t('Không mở được','Could not open'), t('Hệ điều hành này không có bảng cài đặt đó - cấp quyền thủ công.','This OS has no such settings pane - grant it manually.'), 'error');
1256
1338
  const granted = r.screen && r.screen.supported ? r.screen.granted : null;
1257
1339
  showToast(t('Đã mở cài đặt quyền','Opened settings'),
1258
1340
  granted === true ? t('Quyền chụp/quay màn hình: đã có. Bật thêm Accessibility nếu cần gõ/click.','Screen recording: already granted. Also enable Accessibility for typing/clicking.')
1259
- : granted === false ? t('Chưa có quyền bật cho "node" (và app Codex) trong danh sách vừa mở, rồi restart bot.','Not granted yet tick "node" (and the Codex app) in the list that just opened, then restart the bot.')
1260
- : t('Bật quyền cho "node" và app Codex trong danh sách vừa mở.','Tick "node" and the Codex app in the list that just opened.'),
1341
+ : granted === false ? t('Chưa có quyền. Bật cho "node" trong danh sách vừa mở, rồi khởi động lại bot.','Not granted yet. Tick "node" in the list that just opened, then restart the bot.')
1342
+ : t('Bật quyền cho "node" trong danh sách vừa mở.','Tick "node" in the list that just opened.'),
1261
1343
  granted === false ? 'error' : 'success', 8000);
1262
1344
  } catch (err) { showToast(t('Thất bại','Failed'), err.message, 'error'); }
1263
1345
  }));
@@ -1305,7 +1387,7 @@ function wireTab() {
1305
1387
  state.install = { ...(state.install || {}), ...result };
1306
1388
  autoSwitchBotChannel();
1307
1389
  render();
1308
- // Background: full refresh (versions, files, flags) UI already shows the new project.
1390
+ // Background: full refresh (versions, files, flags) - UI already shows the new project.
1309
1391
  await loadSystem(true);
1310
1392
  await loadStatus(true);
1311
1393
  autoSwitchBotChannel();
@@ -1390,9 +1472,9 @@ document.querySelectorAll('[data-project-pick-folder]').forEach(btn => btn.oncli
1390
1472
  }));
1391
1473
  document.querySelectorAll('[data-bot-add-mount]').forEach(btn => btn.onclick = () => openPathModal({
1392
1474
  title: t('Cấp quyền ổ đĩa/thư mục','Grant disk/folder access'),
1393
- message: t('Nhập đường dẫn host (vd /Users/ban/Documents hoặc D:/data). Mount cho MỌI bot trong project rồi tự recreate container để áp dụng (mất ~1020s).','Enter a host path (e.g. /Users/you/Documents or D:/data). Mounts for ALL bots in this project, then auto-recreates the container to apply (~1020s).'),
1475
+ message: t('Nhập đường dẫn host (vd /Users/ban/Documents hoặc D:/data). Mount cho MỌI bot trong project rồi tự recreate container để áp dụng (mất ~10-20s).','Enter a host path (e.g. /Users/you/Documents or D:/data). Mounts for ALL bots in this project, then auto-recreates the container to apply (~10-20s).'),
1394
1476
  placeholder: '/Users/ban/Documents',
1395
- field2: { label: t('Tên mount (tùy chọn) sẽ thành /mnt/<tên>. Bỏ trống = tự lấy theo tên thư mục.','Mount name (optional) becomes /mnt/<name>. Empty = derived from folder name.'), placeholder: 'baocao' },
1477
+ field2: { label: t('Tên mount (tùy chọn) - sẽ thành /mnt/<tên>. Bỏ trống = tự lấy theo tên thư mục.','Mount name (optional) - becomes /mnt/<name>. Empty = derived from folder name.'), placeholder: 'baocao' },
1396
1478
  onConfirm: async (val, name) => {
1397
1479
  const hostPath = String(val || '').trim();
1398
1480
  if (!hostPath) return;
@@ -1410,23 +1492,18 @@ document.querySelectorAll('[data-project-pick-folder]').forEach(btn => btn.oncli
1410
1492
  try { cur = await api('/api/host/control' + projectQuery({})); }
1411
1493
  catch (err) { return showToast(t('Thất bại','Failed'), err.message, 'error'); }
1412
1494
  const on = !!cur.enabled;
1413
- const apps = cur.apps || [];
1414
- const grants = cur.grants || [];
1415
- const chips = (items) => items.map((i) => `<code>${escapeHtml(i)}</code>`).join(' ');
1416
- const scripts = grants.filter((g) => ['node', 'npx', 'codex'].includes(g));
1417
- // One row per capability instead of a wall of prose — the operator is granting real access to
1495
+ // One row per capability instead of a wall of prose - the operator is granting real access to
1418
1496
  // their machine and should be able to see, at a glance, exactly what each row means.
1419
- const rows = [
1420
- { icon: '🗂', title: t('Mở ứng dụng','Open apps'), desc: apps.length ? chips(apps) : t('chưa được app nào','no apps detected') },
1421
- ...(cur.native ? [
1422
- { icon: '📸', title: t('Chụp & quay màn hình','Screen capture & recording'), desc: t('bot nhìn được màn hình khi bạn nhờ','the bot can see your screen when you ask') },
1423
- { icon: '🖱', title: t('Điều khiển chuột/bàn phím','Mouse & keyboard control'), desc: t('bot giao việc cho Codex CLI (dùng gói ChatGPT đã đăng nhập); macOS','the bot hands the job to the Codex CLI on your ChatGPT plan; macOS only') },
1424
- { icon: '⚙️', title: t('Chạy script','Run scripts'), desc: `${chips(scripts.length ? scripts : ['node'])} <b>${t('chạy được tuỳ ý trên máy này','arbitrary code on this machine')}</b>` },
1425
- ] : []),
1426
- ];
1497
+ // There is no app allow-list to show: the bot drives the screen, so it reaches anything the
1498
+ // person sitting at this machine could reach. Say that plainly rather than implying limits.
1499
+ const rows = cur.native ? [
1500
+ { icon: '📸', title: t('Nhìn màn hình','See the screen'), desc: t('bot chụp được màn hình khi bạn nhờ','the bot can screenshot your screen when you ask') },
1501
+ { icon: '🖱', title: t('Điều khiển chuột/bàn phím','Mouse & keyboard control'), desc: t('bot chuột, bấm, phím kéo thả trên máy này','the bot moves the mouse, clicks, types and drags on this machine') },
1502
+ { icon: '🗂', title: t('Mở ứng dụng','Open apps'), desc: t('mọi app đang cài, bot thao tác như người ngồi trước máy','any installed app, because it operates the machine like a person would') },
1503
+ ] : [];
1427
1504
  const bodyHtml = on ? '' : `
1428
1505
  <ul class="cu-status grant-list">${rows.map((r) => `<li><span aria-hidden="true">${r.icon}</span><div><b>${r.title}</b><br>${r.desc}</div></li>`).join('')}</ul>
1429
- <p class="cu-note">${t('Danh sách sửa trong <code>.openclaw/host-control.json</code>. Chỉ dùng trên máy có màn hình (không áp dụng VPS headless).','Edit the list in <code>.openclaw/host-control.json</code>. Desktop only (not a headless VPS).')}</p>`;
1506
+ <p class="cu-note">${t('Chỉ dùng trên máy có màn hình (không áp dụng VPS headless).','Desktop only (not a headless VPS).')}</p>`;
1430
1507
  state.confirmModal = {
1431
1508
  icon: '🖥️',
1432
1509
  eyebrow: t('Điều khiển máy','PC control'),
@@ -1439,7 +1516,7 @@ document.querySelectorAll('[data-project-pick-folder]').forEach(btn => btn.oncli
1439
1516
  okDanger: on,
1440
1517
  onConfirm: async () => {
1441
1518
  state.confirmModal = null; render();
1442
- if (!on) showToast(t('Đang bật','Enabling'), t('Đang cấp quyền & cài Computer Use (có thể mất ~30s)…','Granting access & installing Computer Use (may take ~30s)…'), 'success');
1519
+ if (!on) showToast(t('Đang bật','Enabling'), t('Đang cấp quyền khởi động node điều khiển (có thể mất ~30s)…','Granting access and starting the control node (may take ~30s)…'), 'success');
1443
1520
  try {
1444
1521
  const r = await api('/api/host/control', { method: 'POST', body: { enabled: !on, projectDir: activeProjectDir() } });
1445
1522
  if (!on && r.started && r.started.ok === false && r.started.reason) {
@@ -1618,7 +1695,7 @@ document.querySelectorAll('[data-project-pick-folder]').forEach(btn => btn.oncli
1618
1695
  $('#bot-create')?.addEventListener('submit', async (ev) => {
1619
1696
  ev.preventDefault();
1620
1697
  // Facebook Messenger needs the fb-messenger plugin (public on ClawHub). We don't block
1621
- // creation the heads-up modal on channel-select tells the user to install it from the
1698
+ // creation - the heads-up modal on channel-select tells the user to install it from the
1622
1699
  // bot's Plugins section afterwards (installable via the openclaw-fb-messenger card).
1623
1700
  const submitBtn = ev.currentTarget.querySelector('button[type="submit"]');
1624
1701
  if (submitBtn?.classList.contains('is-loading')) return;
@@ -1629,7 +1706,7 @@ document.querySelectorAll('[data-project-pick-folder]').forEach(btn => btn.oncli
1629
1706
  body.channel = state.botChannel || 'telegram';
1630
1707
  body.userTimezone = state.tz;
1631
1708
  // Only the CREATE flow starts a Zalo QR login (the server kicks it off and sets
1632
- // loginStarted). Editing a bot (rename, persona...) must never pop the QR modal
1709
+ // loginStarted). Editing a bot (rename, persona...) must never pop the QR modal -
1633
1710
  // the session is already saved and the PUT endpoint starts no login.
1634
1711
  if (body.channel === 'zalo-personal' && !state.botEditId) {
1635
1712
  state.botModalOpen = false;
@@ -1643,7 +1720,7 @@ document.querySelectorAll('[data-project-pick-folder]').forEach(btn => btn.oncli
1643
1720
  const url = isEdit ? `/api/bot/${encodeURIComponent(state.botEditId)}` : '/api/bot/create';
1644
1721
  const method = isEdit ? 'PUT' : 'POST';
1645
1722
  const result = await api(url, { method, body });
1646
- state.botMessage = `✅ ${isEdit ? t('Đã cập nhật','Updated') : t('Đã tạo','Created')} ${result.agentId}${result.warning ? ' ' + result.warning : ''}`;
1723
+ state.botMessage = `✅ ${isEdit ? t('Đã cập nhật','Updated') : t('Đã tạo','Created')} ${result.agentId}${result.warning ? ' - ' + result.warning : ''}`;
1647
1724
  showToast(isEdit ? t('Đã cập nhật', 'Updated') : t('Đã tạo bot', 'Bot created'), `${isEdit ? t('Đã cập nhật','Updated') : t('Đã tạo','Created')} bot ${result.agentId} thành công!`, 'success');
1648
1725
  state.botChannel = body.channel;
1649
1726
  state.botEditId = '';
@@ -1688,6 +1765,9 @@ async function loadStatus(silent=false){
1688
1765
  state.install = install;
1689
1766
  if (!state.selectedProjectDir && state.install?.projectDir) state.selectedProjectDir = state.install.projectDir;
1690
1767
  if (!silent) render();
1768
+ // Status is the first place we can actually see which runtime this project uses, so this is
1769
+ // where the Docker project gets told there is a better place to be.
1770
+ maybeOfferNativeMigration();
1691
1771
  }
1692
1772
  function autoSwitchBotChannel() {
1693
1773
  const bots = state.install?.bots || [];
@@ -1770,7 +1850,7 @@ function updateZaloHealthDom() {
1770
1850
  });
1771
1851
  }
1772
1852
  // Live status: poll the health endpoint while Zalo bots are on screen (the server caches
1773
- // the ~3s CLI probe with a short TTL, so this stays cheap) and patch the badges in place
1853
+ // the ~3s CLI probe with a short TTL, so this stays cheap) and patch the badges in place -
1774
1854
  // no more pressing "Làm mới" to see a bot drop or come back.
1775
1855
  const ZALO_HEALTH_POLL_MS = 10000;
1776
1856
  let zaloHealthPollBusy = false;
@@ -1811,7 +1811,7 @@ html[data-theme="light"] .bottom .nav.active {
1811
1811
  text-align: right !important;
1812
1812
  }
1813
1813
 
1814
- /* Compact per-account Zalo health shared versions stay in the right status column. */
1814
+ /* Compact per-account Zalo health - shared versions stay in the right status column. */
1815
1815
  .zalo-toolbar{display:flex;gap:8px;align-items:center}.zalo-toolbar .btn-inline{min-height:36px;padding:6px 12px;font-size:13px;gap:7px}.zalo-toolbar svg{width:14px;height:14px;flex:0 0 auto}.bot-item-title{display:flex;align-items:center;gap:8px;min-width:0;padding-right:58px}.bot-item-title b{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.zalo-quick-badge{display:inline-flex;align-items:center;padding:2px 7px;border-radius:999px;border:1px solid rgba(24,194,156,.35);background:rgba(24,194,156,.12);color:#25d6ad;font-size:10px;font-weight:800;white-space:nowrap}.zalo-bot-health{display:grid;gap:6px;margin-top:4px;padding-top:10px;border-top:1px solid rgba(255,255,255,.07)}.zalo-bot-health>div{display:flex;align-items:center;justify-content:space-between;gap:10px}.zalo-bot-health span{color:var(--muted);font-size:11px;font-weight:700}.zalo-bot-health em{font-size:11px;font-style:normal;font-weight:800;text-align:right}.zalo-bot-health em.ok{color:#25d6ad}.zalo-bot-health em.warn{color:#f7bf48}.zalo-bot-health em.bad{color:#ff6574}html[data-theme="light"] .zalo-bot-health{border-top-color:rgba(15,23,42,.08)}@media(max-width:640px){.zalo-toolbar{display:grid;grid-template-columns:1fr 1fr;width:100%}.zalo-toolbar .btn-inline{justify-content:center;padding:6px 8px}.bot-item-title{padding-right:54px}}
1816
1816
 
1817
1817
  /* Ensure bottom navigation bar is visible on tablet and mobile (under 1180px) */
@@ -1901,7 +1901,7 @@ html[data-theme="light"] .bot-create-card:hover {
1901
1901
  @media(max-width:760px){
1902
1902
  .bot-modal-backdrop{padding:12px 12px 80px!important}
1903
1903
  .bot-modal{max-height:calc(100dvh - 96px)!important}
1904
- /* 2-up channel picker on phones less scrolling than a single column */
1904
+ /* 2-up channel picker on phones - less scrolling than a single column */
1905
1905
  .bot-modal .bot-channel-grid{grid-template-columns:repeat(2,minmax(0,1fr))!important}
1906
1906
  }
1907
1907
 
@@ -1944,14 +1944,14 @@ main{transition:margin .2s}
1944
1944
  Mobile UX refinements (user feedback)
1945
1945
  ════════════════════════════════════════════════════════════════════════════ */
1946
1946
 
1947
- /* [Hero] Dashboard already shows its title inside the hero card drop the duplicate
1947
+ /* [Hero] Dashboard already shows its title inside the hero card - drop the duplicate
1948
1948
  page-title header so it doesn't repeat "Dashboard vận hành" twice. */
1949
1949
  body[data-active-tab="dashboard"] .top{display:none}
1950
1950
 
1951
1951
  /* [Modals] While any modal is open, hide the fixed bottom nav so it can't cover the modal's
1952
1952
  lower content (and footer buttons) on mobile. The backdrop owns the screen at that point. */
1953
1953
  body:has(.modal-backdrop) .bottom{display:none!important}
1954
- /* Bot / Cài đặt hero buttons sit side-by-side keep their labels on one line. */
1954
+ /* Bot / Cài đặt hero buttons sit side-by-side - keep their labels on one line. */
1955
1955
  .dash-actions .icon-btn2{white-space:nowrap}
1956
1956
 
1957
1957
  /* [Confirm modal] Optional body block for step-by-step guides (e.g. Chrome-debug on a VPS):
@@ -1994,7 +1994,7 @@ body:has(.modal-backdrop) .bottom{display:none!important}
1994
1994
  /* [Connect project] The action buttons were absolutely positioned and overlapped the
1995
1995
  path text. Put the card into normal flow with full-width buttons below the content.
1996
1996
  The card used overflow:hidden + a fixed min-height and the list capped at 260px with an
1997
- inner scroll all of which clipped the now-in-flow buttons. Let everything grow. */
1997
+ inner scroll - all of which clipped the now-in-flow buttons. Let everything grow. */
1998
1998
  .detected-projects{max-height:none!important;overflow:visible!important}
1999
1999
  .detected-project{padding:12px!important;min-height:0!important;height:auto!important;overflow:visible!important}
2000
2000
  .detected-project__actions{position:static!important;transform:none!important;width:100%;margin-top:4px}
@@ -2031,7 +2031,7 @@ body:has(.modal-backdrop) .bottom{display:none!important}
2031
2031
 
2032
2032
  @media(max-width:520px){
2033
2033
  /* [Setup OS/mode] Shrink the logo cards: keep them 2-up (compact, less scrolling) with
2034
- much smaller icons just large enough to tap comfortably. */
2034
+ much smaller icons - just large enough to tap comfortably. */
2035
2035
  .os-grid,.mode-grid{grid-template-columns:repeat(2,minmax(0,1fr))!important}
2036
2036
  .logo-card{min-height:0!important;gap:8px!important;padding:12px 8px!important;border-radius:14px!important}
2037
2037
  .logo-card .choice-card__icon{width:40px!important;height:40px!important;border-radius:11px!important}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-openclaw-bot",
3
- "version": "5.17.0",
3
+ "version": "5.17.2",
4
4
  "description": "Interactive CLI installer for OpenClaw Bot",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {