@rozek/nanoclaw 0.0.16 → 0.0.19

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/CLAUDE.md CHANGED
@@ -19,10 +19,17 @@ Single Node.js process with skill-based channel system. Channels (WhatsApp, Tele
19
19
  | `src/task-scheduler.ts` | Runs scheduled tasks |
20
20
  | `src/db.ts` | SQLite operations |
21
21
  | `groups/{name}/CLAUDE.md` | Per-group memory (isolated) |
22
- | `container/skills/agent-browser.md` | Browser automation tool (available to all agents via Bash) |
22
+ | `container/skills/` | Skills loaded inside agent containers (browser, status, formatting) |
23
23
 
24
24
  ## Skills
25
25
 
26
+ Four types of skills exist in NanoClaw. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxonomy and guidelines.
27
+
28
+ - **Feature skills** — merge a `skill/*` branch to add capabilities (e.g. `/add-telegram`, `/add-slack`)
29
+ - **Utility skills** — ship code files alongside SKILL.md (e.g. `/claw`)
30
+ - **Operational skills** — instruction-only workflows, always on `main` (e.g. `/setup`, `/debug`)
31
+ - **Container skills** — loaded inside agent containers at runtime (`container/skills/`)
32
+
26
33
  | Skill | When to Use |
27
34
  |-------|-------------|
28
35
  | `/setup` | First-time installation, authentication, service configuration |
@@ -32,6 +39,10 @@ Single Node.js process with skill-based channel system. Channels (WhatsApp, Tele
32
39
  | `/qodo-pr-resolver` | Fetch and fix Qodo PR review issues interactively or in batch |
33
40
  | `/get-qodo-rules` | Load org- and repo-level coding rules from Qodo before code tasks |
34
41
 
42
+ ## Contributing
43
+
44
+ Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, SKILL.md format rules, PR requirements, and the pre-submission checklist (searching for existing PRs/issues, testing, description format).
45
+
35
46
  ## Development
36
47
 
37
48
  Run commands directly—don't tell the user to run them.
@@ -57,7 +68,7 @@ systemctl --user restart nanoclaw
57
68
 
58
69
  ## Troubleshooting
59
70
 
60
- **WhatsApp not connecting after upgrade:** WhatsApp is now a separate channel fork, not bundled in core. Run `/add-whatsapp` (or `git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git && git fetch whatsapp main && (git merge whatsapp/main || { git checkout --theirs package-lock.json && git add package-lock.json && git merge --continue; }) && npm run build`) to install it. Existing auth credentials and groups are preserved.
71
+ **WhatsApp not connecting after upgrade:** WhatsApp is now a separate skill, not bundled in core. Run `/add-whatsapp` (or `npx tsx scripts/apply-skill.ts .claude/skills/add-whatsapp && npm run build`) to install it. Existing auth credentials and groups are preserved.
61
72
 
62
73
  ## Container Build Cache
63
74
 
package/README.md CHANGED
@@ -9,6 +9,7 @@
9
9
  <p align="center">
10
10
  <a href="https://nanoclaw.dev">nanoclaw.dev</a>&nbsp; • &nbsp;
11
11
  <a href="README_zh.md">中文</a>&nbsp; • &nbsp;
12
+ <a href="README_ja.md">日本語</a>&nbsp; • &nbsp;
12
13
  <a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
13
14
  <a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
14
15
  </p>
@@ -112,7 +113,7 @@ Then run `/setup`. Claude Code handles everything: dependencies, authentication,
112
113
  - **Main channel** - Your private channel (self-chat) for admin control; every group is completely isolated
113
114
  - **Scheduled tasks** - Recurring jobs that run Claude and can message you back
114
115
  - **Web access** - Search and fetch content from the Web
115
- - **Container isolation** - Agents are sandboxed in [Docker Sandboxes](https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes) (micro VM isolation), Apple Container (macOS), or Docker (macOS/Linux)
116
+ - **Container isolation** - Agents are sandboxed in Docker (macOS/Linux), [Docker Sandboxes](docs/docker-sandboxes.md) (micro VM isolation), or Apple Container (macOS)
116
117
  - **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks
117
118
  - **Optional integrations** - Add Gmail (`/add-gmail`) and more via skills
118
119
 
@@ -161,9 +162,6 @@ Skills we'd like to see:
161
162
  **Communication Channels**
162
163
  - `/add-signal` - Add Signal as a channel
163
164
 
164
- **Session Management**
165
- - `/clear` - Add a `/clear` command that compacts the conversation (summarizes context while preserving critical information in the same session). Requires figuring out how to trigger compaction programmatically via the Claude Agent SDK.
166
-
167
165
  ## Requirements
168
166
 
169
167
  - macOS or Linux
@@ -198,7 +196,7 @@ Key files:
198
196
 
199
197
  **Why Docker?**
200
198
 
201
- Docker provides cross-platform support (macOS, Linux and even Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime.
199
+ Docker provides cross-platform support (macOS, Linux and even Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
202
200
 
203
201
  **Can I run this on Linux?**
204
202
 
@@ -0,0 +1,94 @@
1
+ ---
2
+ name: slack-formatting
3
+ description: Format messages for Slack using mrkdwn syntax. Use when responding to Slack channels (folder starts with "slack_" or JID contains slack identifiers).
4
+ ---
5
+
6
+ # Slack Message Formatting (mrkdwn)
7
+
8
+ When responding to Slack channels, use Slack's mrkdwn syntax instead of standard Markdown.
9
+
10
+ ## How to detect Slack context
11
+
12
+ Check your group folder name or workspace path:
13
+ - Folder starts with `slack_` (e.g., `slack_engineering`, `slack_general`)
14
+ - Or check `/workspace/group/` path for `slack_` prefix
15
+
16
+ ## Formatting reference
17
+
18
+ ### Text styles
19
+
20
+ | Style | Syntax | Example |
21
+ |-------|--------|---------|
22
+ | Bold | `*text*` | *bold text* |
23
+ | Italic | `_text_` | _italic text_ |
24
+ | Strikethrough | `~text~` | ~strikethrough~ |
25
+ | Code (inline) | `` `code` `` | `inline code` |
26
+ | Code block | ` ```code``` ` | Multi-line code |
27
+
28
+ ### Links and mentions
29
+
30
+ ```
31
+ <https://example.com|Link text> # Named link
32
+ <https://example.com> # Auto-linked URL
33
+ <@U1234567890> # Mention user by ID
34
+ <#C1234567890> # Mention channel by ID
35
+ <!here> # @here
36
+ <!channel> # @channel
37
+ ```
38
+
39
+ ### Lists
40
+
41
+ Slack supports simple bullet lists but NOT numbered lists:
42
+
43
+ ```
44
+ • First item
45
+ • Second item
46
+ • Third item
47
+ ```
48
+
49
+ Use `•` (bullet character) or `- ` or `* ` for bullets.
50
+
51
+ ### Block quotes
52
+
53
+ ```
54
+ > This is a block quote
55
+ > It can span multiple lines
56
+ ```
57
+
58
+ ### Emoji
59
+
60
+ Use standard emoji shortcodes: `:white_check_mark:`, `:x:`, `:rocket:`, `:tada:`
61
+
62
+ ## What NOT to use
63
+
64
+ - **NO** `##` headings (use `*Bold text*` for headers instead)
65
+ - **NO** `**double asterisks**` for bold (use `*single asterisks*`)
66
+ - **NO** `[text](url)` links (use `<url|text>` instead)
67
+ - **NO** `1.` numbered lists (use bullets with numbers: `• 1. First`)
68
+ - **NO** tables (use code blocks or plain text alignment)
69
+ - **NO** `---` horizontal rules
70
+
71
+ ## Example message
72
+
73
+ ```
74
+ *Daily Standup Summary*
75
+
76
+ _March 21, 2026_
77
+
78
+ • *Completed:* Fixed authentication bug in login flow
79
+ • *In Progress:* Building new dashboard widgets
80
+ • *Blocked:* Waiting on API access from DevOps
81
+
82
+ > Next sync: Monday 10am
83
+
84
+ :white_check_mark: All tests passing | <https://ci.example.com/builds/123|View Build>
85
+ ```
86
+
87
+ ## Quick rules
88
+
89
+ 1. Use `*bold*` not `**bold**`
90
+ 2. Use `<url|text>` not `[text](url)`
91
+ 3. Use `•` bullets, avoid numbered lists
92
+ 4. Use `:emoji:` shortcodes
93
+ 5. Quote blocks with `>`
94
+ 6. Skip headings — use bold text instead
@@ -1 +1 @@
1
- {"version":3,"file":"registry.test.js","sourceRoot":"","sources":["../../src/channels/registry.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAc,MAAM,QAAQ,CAAC;AAE1D,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,yBAAyB,GAC1B,MAAM,eAAe,CAAC;AAEvB,0EAA0E;AAC1E,6DAA6D;AAC7D,yEAAyE;AACzE,2DAA2D;AAE3D,QAAQ,CAAC,kBAAkB,EAAE,GAAG,EAAE;IAChC,mEAAmE;IACnE,6DAA6D;IAE7D,EAAE,CAAC,yDAAyD,EAAE,GAAG,EAAE;QACjE,MAAM,CAAC,iBAAiB,CAAC,aAAa,CAAC,CAAC,CAAC,aAAa,EAAE,CAAC;IAC3D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC1D,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC;QAC3B,eAAe,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC;QACzC,MAAM,CAAC,iBAAiB,CAAC,cAAc,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC1D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,wDAAwD,EAAE,GAAG,EAAE;QAChE,eAAe,CAAC,iBAAiB,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;QAC/C,MAAM,KAAK,GAAG,yBAAyB,EAAE,CAAC;QAC1C,MAAM,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,cAAc,CAAC,CAAC;QACxC,MAAM,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,iBAAiB,CAAC,CAAC;IAC7C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2CAA2C,EAAE,GAAG,EAAE;QACnD,MAAM,QAAQ,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC;QAC5B,MAAM,QAAQ,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC;QAC5B,eAAe,CAAC,gBAAgB,EAAE,QAAQ,CAAC,CAAC;QAC5C,eAAe,CAAC,gBAAgB,EAAE,QAAQ,CAAC,CAAC;QAC5C,MAAM,CAAC,iBAAiB,CAAC,gBAAgB,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC7D,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"registry.test.js","sourceRoot":"","sources":["../../src/channels/registry.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAE9C,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,yBAAyB,GAC1B,MAAM,eAAe,CAAC;AAEvB,0EAA0E;AAC1E,6DAA6D;AAC7D,yEAAyE;AACzE,2DAA2D;AAE3D,QAAQ,CAAC,kBAAkB,EAAE,GAAG,EAAE;IAChC,mEAAmE;IACnE,6DAA6D;IAE7D,EAAE,CAAC,yDAAyD,EAAE,GAAG,EAAE;QACjE,MAAM,CAAC,iBAAiB,CAAC,aAAa,CAAC,CAAC,CAAC,aAAa,EAAE,CAAC;IAC3D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC1D,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC;QAC3B,eAAe,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC;QACzC,MAAM,CAAC,iBAAiB,CAAC,cAAc,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC1D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,wDAAwD,EAAE,GAAG,EAAE;QAChE,eAAe,CAAC,iBAAiB,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;QAC/C,MAAM,KAAK,GAAG,yBAAyB,EAAE,CAAC;QAC1C,MAAM,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,cAAc,CAAC,CAAC;QACxC,MAAM,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,iBAAiB,CAAC,CAAC;IAC7C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2CAA2C,EAAE,GAAG,EAAE;QACnD,MAAM,QAAQ,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC;QAC5B,MAAM,QAAQ,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC;QAC5B,eAAe,CAAC,gBAAgB,EAAE,QAAQ,CAAC,CAAC;QAC5C,eAAe,CAAC,gBAAgB,EAAE,QAAQ,CAAC,CAAC;QAC5C,MAAM,CAAC,iBAAiB,CAAC,gBAAgB,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC7D,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -203,6 +203,12 @@ const HTML = `<!DOCTYPE html>
203
203
  <link rel="icon" type="image/png" href="/favicon.png">
204
204
  <link rel="icon" href="/favicon.ico" sizes="any">
205
205
  <link rel="apple-touch-icon" href="/apple-touch-icon.png">
206
+ <link rel="manifest" href="/manifest.json">
207
+ <meta name="theme-color" content="#2563eb">
208
+ <meta name="mobile-web-app-capable" content="yes">
209
+ <meta name="apple-mobile-web-app-capable" content="yes">
210
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
211
+ <meta name="apple-mobile-web-app-title" content="NanoClaw">
206
212
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
207
213
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
208
214
  <style>
@@ -221,10 +227,15 @@ const HTML = `<!DOCTYPE html>
221
227
  #main-area { display: flex; flex: 1; overflow: hidden; }
222
228
 
223
229
  /* Sidebar */
224
- #sidebar { width: 220px; min-width: 220px; background: #fff; border-right: 1px solid #e0e0e0; display: flex; flex-direction: column; transition: width 0.2s, min-width 0.2s; overflow: hidden; }
225
- #sidebar.collapsed { width: 0; min-width: 0; }
226
- #sidebar-header { padding: 10px 12px; border-bottom: 1px solid #e0e0e0; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
227
- #sidebar-title { font-weight: 600; font-size: 13px; color: #555; text-transform: uppercase; letter-spacing: 0.05em; }
230
+ #sidebar { width: var(--sidebar-width, 220px); min-width: var(--sidebar-width, 220px); background: #fff; border-right: 1px solid #e0e0e0; display: flex; flex-direction: column; transition: width 0.2s, min-width 0.2s; overflow: hidden; }
231
+ #sidebar.collapsed { width: 0 !important; min-width: 0 !important; border-right: none; }
232
+ #sidebar.dragging { transition: none; }
233
+ #sidebar-header { padding: 10px 8px 10px 12px; border-bottom: 1px solid #e0e0e0; display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
234
+ /* Resize grip — sits at right edge of the header, same width as the scrollbar below */
235
+ #sidebar-grip { width: 14px; min-width: 14px; align-self: stretch; cursor: col-resize; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: #bbb; font-size: 12px; letter-spacing: -2px; user-select: none; border-radius: 3px; transition: color 0.15s, background-color 0.15s; }
236
+ #sidebar-grip:hover, #sidebar-grip.active { color: #2563eb; background-color: rgba(37,99,235,0.08); }
237
+ @media (max-width: 640px) { #sidebar-grip { display: none; } }
238
+ #sidebar-title { font-weight: 600; font-size: 13px; color: #555; text-transform: uppercase; letter-spacing: 0.05em; flex: 1; }
228
239
  #new-session-btn { background: none; border: 1px solid #d0d0d0; color: #555; width: 24px; height: 24px; border-radius: 4px; cursor: pointer; font-size: 18px; display: flex; align-items: center; justify-content: center; line-height: 1; flex-shrink: 0; }
229
240
  #new-session-btn:hover { background: #f0f0f0; border-color: #aaa; }
230
241
  #session-list { flex: 1; overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 2px; }
@@ -324,6 +335,7 @@ const HTML = `<!DOCTYPE html>
324
335
  <div id="sidebar-header">
325
336
  <span id="sidebar-title">Chats</span>
326
337
  <button id="new-session-btn" title="Neuen Chat starten">+</button>
338
+ <div id="sidebar-grip" title="Breite anpassen">⋮⋮</div>
327
339
  </div>
328
340
  <div id="session-list"></div>
329
341
  </div>
@@ -459,7 +471,10 @@ const HTML = `<!DOCTYPE html>
459
471
 
460
472
  let typingEl = null;
461
473
  let statusEl = null; // live tool-use status element (shown below typing indicator)
462
- let sessionId = sessionStorage.getItem('sid');
474
+ let lastSseActivity = Date.now(); // updated on SSE open + ping; used to detect stale connections
475
+ // sessionStorage is cleared on Android when a tab is evicted from memory and reloaded.
476
+ // Fall back to localStorage so the active session survives page reloads on mobile.
477
+ let sessionId = sessionStorage.getItem('sid') || localStorage.getItem('lastSid');
463
478
  let es = null; // EventSource
464
479
  let sseGeneration = 0; // incremented each setupSSE() call; guards against stale events
465
480
  let currentTyping = false; // last-known typing state from SSE
@@ -493,9 +508,27 @@ const HTML = `<!DOCTYPE html>
493
508
 
494
509
  // ── Sidebar toggle ────────────────────────────────────────────────────
495
510
  const isMobile = window.innerWidth <= 640;
511
+ // Touch devices (phones, foldables, tablets): avoid auto-focusing the input after
512
+ // session switches so the virtual keyboard doesn't pop up unexpectedly.
513
+ // pointer:coarse is more reliable than maxTouchPoints on Samsung/Android devices.
514
+ const hasTouchscreen = window.matchMedia('(pointer: coarse)').matches || navigator.maxTouchPoints > 0;
515
+
516
+ // ── Sidebar width (desktop only) ──────────────────────────────────────
517
+ const SIDEBAR_DEFAULT = 220;
518
+ const SIDEBAR_MIN = 140;
519
+ const SIDEBAR_MAX = 520;
520
+ function applySidebarWidth(w) {
521
+ sidebar.style.setProperty('--sidebar-width', w + 'px');
522
+ }
523
+ if (!isMobile) {
524
+ const saved = parseInt(localStorage.getItem('sidebarWidth') || '', 10);
525
+ applySidebarWidth(isNaN(saved) ? SIDEBAR_DEFAULT : saved);
526
+ }
527
+
496
528
  // On mobile: always open initially (overlay mode); on desktop: respect saved pref
497
529
  let sidebarOpen = isMobile ? true : (localStorage.getItem('sidebarOpen') !== 'false');
498
530
  const backdrop = document.getElementById('sidebar-backdrop');
531
+ const sidebarResizeEl = document.getElementById('sidebar-grip');
499
532
  function setSidebar(open) {
500
533
  sidebarOpen = open;
501
534
  // Only persist state on desktop — on mobile sidebar is always an overlay
@@ -509,6 +542,35 @@ const HTML = `<!DOCTYPE html>
509
542
  // Tap backdrop to close sidebar on mobile
510
543
  if (backdrop) backdrop.addEventListener('click', () => setSidebar(false));
511
544
 
545
+ // ── Sidebar resize by dragging ────────────────────────────────────────
546
+ if (sidebarResizeEl && !isMobile) {
547
+ function startSidebarResize(startX) {
548
+ const startWidth = sidebar.offsetWidth;
549
+ sidebar.classList.add('dragging');
550
+ sidebarResizeEl.classList.add('active');
551
+ function onMove(e) {
552
+ const x = e.touches ? e.touches[0].clientX : e.clientX;
553
+ const w = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, startWidth + (x - startX)));
554
+ applySidebarWidth(w);
555
+ }
556
+ function onEnd() {
557
+ sidebar.classList.remove('dragging');
558
+ sidebarResizeEl.classList.remove('active');
559
+ localStorage.setItem('sidebarWidth', String(sidebar.offsetWidth));
560
+ document.removeEventListener('mousemove', onMove);
561
+ document.removeEventListener('mouseup', onEnd);
562
+ document.removeEventListener('touchmove', onMove);
563
+ document.removeEventListener('touchend', onEnd);
564
+ }
565
+ document.addEventListener('mousemove', onMove);
566
+ document.addEventListener('mouseup', onEnd);
567
+ document.addEventListener('touchmove', onMove, { passive: true });
568
+ document.addEventListener('touchend', onEnd);
569
+ }
570
+ sidebarResizeEl.addEventListener('mousedown', e => { e.preventDefault(); startSidebarResize(e.clientX); });
571
+ sidebarResizeEl.addEventListener('touchstart', e => { startSidebarResize(e.touches[0].clientX); }, { passive: true });
572
+ }
573
+
512
574
  // ── Header ────────────────────────────────────────────────────────────
513
575
  function updateHeader(cwd) {
514
576
  headerTitle.textContent = 'NanoClaw \u2014 ' + serverAddress;
@@ -529,6 +591,7 @@ const HTML = `<!DOCTYPE html>
529
591
  function renderMsg(text, cls, msgId) {
530
592
  const row = document.createElement('div');
531
593
  row.className = 'msg-row ' + (cls.startsWith('bot') || cls === 'status' ? 'bot' : 'user');
594
+ if (msgId) row.dataset.msgId = msgId;
532
595
  const d = document.createElement('div');
533
596
  d.className = 'msg ' + cls;
534
597
  if (cls === 'bot') {
@@ -594,7 +657,11 @@ const HTML = `<!DOCTYPE html>
594
657
  row.appendChild(delBtn);
595
658
  }
596
659
  msgsEl.appendChild(row);
597
- msgsEl.scrollTop = msgsEl.scrollHeight;
660
+ // Only auto-scroll when the user is already near the bottom (≤150 px away).
661
+ // This prevents interrupting the user when they scroll up to read history.
662
+ if (msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight <= 150) {
663
+ msgsEl.scrollTop = msgsEl.scrollHeight;
664
+ }
598
665
  return row;
599
666
  }
600
667
 
@@ -640,7 +707,9 @@ const HTML = `<!DOCTYPE html>
640
707
  msgsEl.appendChild(statusEl);
641
708
  }
642
709
  statusEl.textContent = label;
643
- msgsEl.scrollTop = msgsEl.scrollHeight;
710
+ if (msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight <= 150) {
711
+ msgsEl.scrollTop = msgsEl.scrollHeight;
712
+ }
644
713
  }
645
714
 
646
715
  function restoreHistory(sid) {
@@ -789,14 +858,15 @@ const HTML = `<!DOCTYPE html>
789
858
  const maxOrder = serverOrder.length;
790
859
  const prevOrder = local.map(s => s.id).join(',');
791
860
  local.sort((a, b) => {
792
- // isOwn sessions not yet confirmed in serverOrder stay above all server-ordered sessions
793
861
  const rawIa = orderMap.get(a.id);
794
862
  const rawIb = orderMap.get(b.id);
795
- const ia = rawIa !== undefined ? rawIa : (a.isOwn ? -1 : maxOrder);
796
- const ib = rawIb !== undefined ? rawIb : (b.isOwn ? -1 : maxOrder);
797
- if (ia !== ib) return ia - ib;
798
- // Sessions not in the order list: sort by newest first
799
- return (b.createdAt || 0) - (a.createdAt || 0);
863
+ const inOrderA = rawIa !== undefined;
864
+ const inOrderB = rawIb !== undefined;
865
+ // Sessions not in server order (new sessions) always appear at the top, newest first
866
+ if (!inOrderA && !inOrderB) return (b.createdAt || 0) - (a.createdAt || 0);
867
+ if (!inOrderA) return -1;
868
+ if (!inOrderB) return 1;
869
+ return rawIa - rawIb;
800
870
  });
801
871
  const newOrder = local.map(s => s.id).join(',');
802
872
  if (newOrder !== prevOrder) changed = true;
@@ -836,6 +906,7 @@ const HTML = `<!DOCTYPE html>
836
906
  if (sseGeneration !== myGen || sessionId !== mySid) return;
837
907
  const wasConnected = sseEverConnected;
838
908
  sseEverConnected = true;
909
+ lastSseActivity = Date.now(); // reset stale-connection timer on (re)connect
839
910
  setConnState('connected');
840
911
  // Push session name on every connect so ensureSession() on the server (which
841
912
  // runs after the SSE handshake and may default to "Web Chat") always sees the
@@ -878,6 +949,10 @@ const HTML = `<!DOCTYPE html>
878
949
  } catch { /* render error — don't block markRead */ }
879
950
  markRead(sessionId); // message arrived in active session → keep it read
880
951
  });
952
+ es.addEventListener('ping', () => {
953
+ if (sseGeneration !== myGen) return;
954
+ lastSseActivity = Date.now(); // server heartbeat received — connection is alive
955
+ });
881
956
  es.addEventListener('typing', e => {
882
957
  if (sseGeneration !== myGen || sessionId !== mySid) return;
883
958
  // Ignore stale "typing: true" from the server's initial SSE state push
@@ -885,6 +960,10 @@ const HTML = `<!DOCTYPE html>
885
960
  // fetchServerHistory has already confirmed agentDone).
886
961
  if (e.data === 'true' && botHasResponded) return;
887
962
  setTyping(e.data === 'true');
963
+ // Fallback: when the agent starts typing it means a user message was just
964
+ // processed. Fetch history immediately so the message appears even if the
965
+ // user_message SSE event was missed (e.g. due to Android throttling).
966
+ if (e.data === 'true') fetchServerHistory(sessionId);
888
967
  });
889
968
  es.addEventListener('status', e => {
890
969
  if (sseGeneration !== myGen || sessionId !== mySid) return;
@@ -896,6 +975,19 @@ const HTML = `<!DOCTYPE html>
896
975
  setStatusDisplay(payload.tool || null, payload.input || null);
897
976
  } catch { /* ignore malformed status event */ }
898
977
  });
978
+ es.addEventListener('user_message', e => {
979
+ if (sseGeneration !== myGen || sessionId !== mySid) return;
980
+ try {
981
+ const payload = JSON.parse(e.data);
982
+ const text = typeof payload === 'string' ? payload : payload.text;
983
+ const msgId = typeof payload === 'object' && payload ? payload.id : null;
984
+ // Reset so typing/status indicators work for this new exchange on all devices
985
+ botHasResponded = false;
986
+ // Skip if this device already rendered the message (sender shows it immediately)
987
+ if (msgId && msgsEl.querySelector('[data-msg-id="' + msgId + '"]')) return;
988
+ addMsg(text, 'user', msgId);
989
+ } catch { /* ignore malformed user_message event */ }
990
+ });
899
991
  es.addEventListener('cwd', e => {
900
992
  if (sseGeneration !== myGen || sessionId !== mySid) return;
901
993
  try {
@@ -919,6 +1011,7 @@ const HTML = `<!DOCTYPE html>
919
1011
  sessionId = newId; // set before markRead so renderSessions sees correct active session
920
1012
  markRead(newId); // clear unread indicator (uses updated sessionId)
921
1013
  sessionStorage.setItem('sid', newId);
1014
+ localStorage.setItem('lastSid', newId);
922
1015
  msgsEl.innerHTML = '';
923
1016
  typingEl = null; // reset before setTyping so it doesn't try to .remove() a stale element
924
1017
  statusEl = null; // reset before setStatusDisplay for the same reason
@@ -936,7 +1029,7 @@ const HTML = `<!DOCTYPE html>
936
1029
  updateHeader(cwd);
937
1030
  setupSSE();
938
1031
  renderSessions();
939
- if (newId !== 'cron') inputEl.focus();
1032
+ if (newId !== 'cron' && !hasTouchscreen) inputEl.focus();
940
1033
  }
941
1034
 
942
1035
  function pushNameToServer(sid, name, nameUpdatedAt) {
@@ -1060,7 +1153,12 @@ const HTML = `<!DOCTYPE html>
1060
1153
  delBtn.className = 'session-btn';
1061
1154
  delBtn.title = 'Löschen';
1062
1155
  delBtn.textContent = '×';
1063
- delBtn.addEventListener('click', e => { e.stopPropagation(); deleteSession(s.id); });
1156
+ delBtn.addEventListener('click', e => {
1157
+ e.stopPropagation();
1158
+ const label = s.name || sessionLabel(new Date(s.createdAt || Date.now()));
1159
+ if (!confirm('Session "' + label + '" wirklich löschen?')) return;
1160
+ deleteSession(s.id);
1161
+ });
1064
1162
 
1065
1163
  actions.append(renameBtn, delBtn);
1066
1164
  item.append(dot, nameSpan, actions);
@@ -1119,6 +1217,10 @@ const HTML = `<!DOCTYPE html>
1119
1217
  if (!sessionId) {
1120
1218
  sessionId = uuid();
1121
1219
  sessionStorage.setItem('sid', sessionId);
1220
+ localStorage.setItem('lastSid', sessionId);
1221
+ } else {
1222
+ // Ensure sessionStorage is in sync (may have been empty after tab eviction)
1223
+ sessionStorage.setItem('sid', sessionId);
1122
1224
  }
1123
1225
  const isNewSession = ensureSessionInList(sessionId);
1124
1226
  // For new sessions: push name to server immediately.
@@ -1160,6 +1262,23 @@ const HTML = `<!DOCTYPE html>
1160
1262
  setInterval(checkAndSyncSessions, 1000);
1161
1263
  // Server polling: picks up sessions from other browsers/devices
1162
1264
  setInterval(mergeServerSessions, 5000);
1265
+ // Stale-connection detector: Android may freeze SSE without triggering 'error'.
1266
+ // If no ping was received in >35 s while the connection appears connected, force reconnect.
1267
+ setInterval(() => {
1268
+ if (connDot.className === 'connected' && Date.now() - lastSseActivity > 35000) {
1269
+ setupSSE();
1270
+ fetchServerHistory(sessionId);
1271
+ }
1272
+ }, 10000);
1273
+ // On mobile/foldable the browser may freeze or drop the SSE connection while the
1274
+ // tab is in the background (folded, screen off, etc.). When the tab becomes visible
1275
+ // again, immediately re-sync history and reconnect SSE if the connection is gone.
1276
+ document.addEventListener('visibilitychange', () => {
1277
+ if (document.visibilityState !== 'visible') return;
1278
+ fetchServerHistory(sessionId);
1279
+ mergeServerSessions();
1280
+ if (connDot.className === 'disconnected') setupSSE();
1281
+ });
1163
1282
 
1164
1283
  // ── Send message ──────────────────────────────────────────────────────
1165
1284
  async function sendMsg() {
@@ -1640,6 +1759,8 @@ class WebChannel {
1640
1759
  try {
1641
1760
  storeMessage(msg); // persist to DB for cross-browser history
1642
1761
  storeChatMetadata(jid, msg.timestamp); // keep chats.last_message_time current for unread detection
1762
+ // Broadcast to all SSE clients so other devices show the message immediately
1763
+ broadcastToSession(sessionId, 'user_message', JSON.stringify({ text: content.trim(), id: msgId }));
1643
1764
  }
1644
1765
  catch { }
1645
1766
  this.onMessage(jid, msg);
@@ -1718,6 +1839,28 @@ class WebChannel {
1718
1839
  }, MAX_UPLOAD_BODY_SIZE);
1719
1840
  return;
1720
1841
  }
1842
+ // GET /manifest.json — Web App Manifest for PWA installability
1843
+ if (req.method === 'GET' && req.url?.split('?')[0] === '/manifest.json') {
1844
+ const manifest = {
1845
+ name: 'NanoClaw',
1846
+ short_name: 'NanoClaw',
1847
+ description: 'NanoClaw — AI Chat',
1848
+ start_url: '/',
1849
+ display: 'standalone',
1850
+ background_color: '#f5f5f5',
1851
+ theme_color: '#2563eb',
1852
+ icons: [
1853
+ { src: '/favicon.png', sizes: '192x192', type: 'image/png' },
1854
+ { src: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' },
1855
+ ],
1856
+ };
1857
+ res.writeHead(200, {
1858
+ 'Content-Type': 'application/manifest+json',
1859
+ 'Cache-Control': 'max-age=3600',
1860
+ });
1861
+ res.end(JSON.stringify(manifest));
1862
+ return;
1863
+ }
1721
1864
  // Static assets: favicon.ico and apple-touch-icon.png from group folder
1722
1865
  const staticFiles = {
1723
1866
  '/favicon.ico': 'image/x-icon',
@@ -1750,6 +1893,18 @@ class WebChannel {
1750
1893
  });
1751
1894
  this.connected = true;
1752
1895
  logger.info({ port: PORT }, 'Web chat channel listening');
1896
+ // Heartbeat: send a named 'ping' event to every SSE client every 20 seconds.
1897
+ // Prevents Android Chrome from throttling or freezing idle SSE connections.
1898
+ setInterval(() => {
1899
+ for (const clients of sseClients.values()) {
1900
+ for (const client of clients) {
1901
+ try {
1902
+ client.write('event: ping\ndata: \n\n');
1903
+ }
1904
+ catch { }
1905
+ }
1906
+ }
1907
+ }, 20000);
1753
1908
  // Ensure dedicated cron session exists (low timestamp so user renames always win)
1754
1909
  try {
1755
1910
  updateChatName(WEB_JID_PREFIX + CRON_SESSION_ID, CRON_SESSION_NAME, 1);