@rozek/nanoclaw 0.0.16 → 0.0.18
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 +13 -2
- package/README.md +3 -5
- package/container/skills/slack-formatting/SKILL.md +94 -0
- package/dist/channels/registry.test.js.map +1 -1
- package/dist/channels/web.js +170 -15
- package/dist/channels/web.js.map +1 -1
- package/dist/container-runner.d.ts +1 -1
- package/dist/container-runner.d.ts.map +1 -1
- package/dist/container-runner.js +11 -2
- package/dist/container-runner.js.map +1 -1
- package/dist/container-runtime.d.ts.map +1 -1
- package/dist/container-runtime.js +4 -2
- package/dist/container-runtime.js.map +1 -1
- package/dist/container-runtime.test.js +3 -3
- package/dist/container-runtime.test.js.map +1 -1
- package/dist/group-queue.js +1 -1
- package/dist/group-queue.js.map +1 -1
- package/dist/group-queue.test.js +3 -3
- package/dist/group-queue.test.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/remote-control.test.js +2 -2
- package/dist/remote-control.test.js.map +1 -1
- package/dist/routing.test.js.map +1 -1
- package/dist/sender-allowlist.test.js.map +1 -1
- package/package.json +8 -1
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
|
|
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
|
|
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> •
|
|
11
11
|
<a href="README_zh.md">中文</a> •
|
|
12
|
+
<a href="README_ja.md">日本語</a> •
|
|
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> •
|
|
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](
|
|
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,
|
|
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"}
|
package/dist/channels/web.js
CHANGED
|
@@ -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
|
|
227
|
-
#sidebar-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
796
|
-
const
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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 => {
|
|
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);
|