total-recall-brain 3.18.2 → 3.19.0

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.
Files changed (83) hide show
  1. package/frontend/dist/assets/{index-D5xRw5uq.js → index-B6AfuKv9.js} +126 -134
  2. package/frontend/dist/index.html +1 -1
  3. package/frontend/src/api/config.ts +16 -2
  4. package/frontend/src/api/keys.ts +111 -0
  5. package/frontend/src/api/notifications.ts +54 -6
  6. package/frontend/src/api/update.ts +41 -5
  7. package/frontend/src/components/BrainSelector.spec.tsx +71 -47
  8. package/frontend/src/components/BrainSelector.tsx +112 -35
  9. package/frontend/src/components/MobilePairing.spec.tsx +44 -14
  10. package/frontend/src/components/MobilePairing.tsx +229 -22
  11. package/frontend/src/pages/HealthPage.tsx +12 -16
  12. package/frontend/src/pages/NotificationsPage.tsx +6 -3
  13. package/frontend/src/pages/RulesPage.spec.tsx +46 -15
  14. package/frontend/src/pages/RulesPage.tsx +41 -9
  15. package/frontend/src/pages/SecretsPage.tsx +316 -3
  16. package/frontend/src/pages/SettingsPage.spec.tsx +30 -6
  17. package/frontend/src/pages/SettingsPage.tsx +228 -86
  18. package/package.json +1 -1
  19. package/src/cli/agent-dir.mjs +17 -2
  20. package/src/cli/chat.mjs +1 -1
  21. package/src/cli/daemon.mjs +2 -1
  22. package/src/cli/dream.mjs +14 -10
  23. package/src/cli/forget.mjs +14 -2
  24. package/src/cli/friction.mjs +1 -1
  25. package/src/cli/import-rules.mjs +8 -3
  26. package/src/cli/ingest.mjs +3 -2
  27. package/src/cli/lint.mjs +9 -6
  28. package/src/cli/rebuild.mjs +9 -4
  29. package/src/cli/remember.mjs +21 -15
  30. package/src/cli/secret.mjs +184 -4
  31. package/src/cli/share.mjs +5 -3
  32. package/src/cli/skill-projection.mjs +21 -2
  33. package/src/cli/snapshot.mjs +10 -8
  34. package/src/cli/update.mjs +2 -1
  35. package/src/core/clarity-rewriter.mjs +31 -12
  36. package/src/core/config.mjs +10 -4
  37. package/src/core/daemon-loop.mjs +33 -6
  38. package/src/core/embeddings.mjs +51 -3
  39. package/src/core/memory-title.mjs +70 -0
  40. package/src/core/notifications.mjs +204 -34
  41. package/src/core/package-auto-update.mjs +99 -3
  42. package/src/core/pairing.mjs +168 -0
  43. package/src/core/provider-account-sync.mjs +1145 -0
  44. package/src/core/provider-catalog.mjs +11 -4
  45. package/src/core/research.mjs +98 -24
  46. package/src/core/runtime.mjs +11 -1
  47. package/src/core/scheduler.mjs +31 -9
  48. package/src/core/schema.mjs +23 -0
  49. package/src/core/secret-integration-research.mjs +325 -0
  50. package/src/core/secrets-store.mjs +284 -14
  51. package/src/core/secrets-sync.mjs +1 -1
  52. package/src/core/skills-registry.mjs +12 -0
  53. package/src/core/source-adapters.mjs +247 -22
  54. package/src/core/surface.mjs +4 -4
  55. package/src/core/task-executors.mjs +161 -14
  56. package/src/core/throttled-fetch.mjs +5 -0
  57. package/src/core/vault.mjs +7 -1
  58. package/src/core/webhook-handlers.mjs +123 -14
  59. package/src/server/auth.mjs +6 -0
  60. package/src/server/index.mjs +56 -0
  61. package/src/server/rest.mjs +2 -0
  62. package/src/server/route-manifest.json +40 -0
  63. package/src/server/routes/_shared.mjs +33 -1
  64. package/src/server/routes/config.mjs +64 -23
  65. package/src/server/routes/context.mjs +14 -9
  66. package/src/server/routes/dashboard.mjs +39 -18
  67. package/src/server/routes/docs.mjs +27 -13
  68. package/src/server/routes/dream.mjs +10 -11
  69. package/src/server/routes/export.mjs +43 -12
  70. package/src/server/routes/field.mjs +11 -9
  71. package/src/server/routes/graph.mjs +31 -13
  72. package/src/server/routes/import.mjs +35 -16
  73. package/src/server/routes/instructions.mjs +41 -5
  74. package/src/server/routes/memory.mjs +61 -21
  75. package/src/server/routes/mesh.mjs +2 -1
  76. package/src/server/routes/notifications.mjs +103 -0
  77. package/src/server/routes/rules.mjs +75 -20
  78. package/src/server/routes/secrets.mjs +110 -4
  79. package/src/server/routes/share.mjs +2 -1
  80. package/src/server/routes/ssss.mjs +11 -3
  81. package/src/server/routes/system.mjs +20 -1
  82. package/src/server/routes/update.mjs +33 -7
  83. package/src/server/routes/vault.mjs +53 -28
@@ -1,10 +1,10 @@
1
1
  import { useState, useEffect, useRef } from 'react'
2
- import { getApiBase } from '../api'
2
+ import { apiFetch, getApiBase } from '../api'
3
3
 
4
4
  interface BrainInfo {
5
5
  id: string
6
6
  name: string
7
- layer: 'global' | 'project'
7
+ layer: 'global' | 'project' | 'tenant' | string
8
8
  path: string
9
9
  project_root?: string
10
10
  exists: boolean
@@ -21,24 +21,46 @@ interface BrainSelectorProps {
21
21
  function layerDot(layer: string, multi = false): string {
22
22
  if (multi) return 'linear-gradient(135deg, #60a5fa, #34d399)'
23
23
  if (layer === 'global') return 'linear-gradient(135deg, #60a5fa, #2563eb)'
24
+ if (layer === 'tenant') return 'linear-gradient(135deg, #c084fc, #7c3aed)'
24
25
  return 'linear-gradient(135deg, #34d399, #059669)'
25
26
  }
26
27
 
28
+ function layerBadge(layer: string): string {
29
+ if (layer === 'global') return 'G'
30
+ if (layer === 'tenant') return 'T'
31
+ return 'P'
32
+ }
33
+
27
34
  export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSelectorProps) {
28
35
  const [brains, setBrains] = useState<BrainInfo[]>([])
29
36
  const [expanded, setExpanded] = useState(false)
30
37
  const [loading, setLoading] = useState(true)
38
+ const [error, setError] = useState<string | null>(null)
31
39
  const selectorRef = useRef<HTMLDivElement>(null)
32
40
 
41
+ const loadBrains = async () => {
42
+ setLoading(true)
43
+ setError(null)
44
+ try {
45
+ const res = await apiFetch(`${getApiBase()}/api/brains`)
46
+ if (!res.ok) {
47
+ const body = await res.json().catch(() => ({}))
48
+ throw new Error(body.error || `Brains API ${res.status}`)
49
+ }
50
+ const data = await res.json()
51
+ const list: BrainInfo[] = Array.isArray(data.brains) ? data.brains : []
52
+ setBrains(list)
53
+ } catch (err) {
54
+ console.error(err)
55
+ setError(err instanceof Error ? err.message : 'Failed to load brains')
56
+ setBrains([])
57
+ } finally {
58
+ setLoading(false)
59
+ }
60
+ }
61
+
33
62
  useEffect(() => {
34
- const base = getApiBase()
35
- fetch(`${base}/api/brains`, { credentials: 'include' })
36
- .then((r) => r.json())
37
- .then((data) => {
38
- setBrains(data.brains || [])
39
- setLoading(false)
40
- })
41
- .catch(() => setLoading(false))
63
+ void loadBrains()
42
64
  }, [])
43
65
 
44
66
  useEffect(() => {
@@ -51,42 +73,91 @@ export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSel
51
73
  return () => document.removeEventListener('mousedown', handleClickOutside)
52
74
  }, [])
53
75
 
76
+ // Normalize empty / missing selection to global when list is ready
77
+ useEffect(() => {
78
+ if (loading || brains.length === 0) return
79
+ const selected = activeBrainId.split(',').filter(Boolean)
80
+ const known = new Set(brains.map((b) => b.id))
81
+ const valid = selected.filter((id) => known.has(id))
82
+ if (valid.length === 0) {
83
+ const fallback = brains.find((b) => b.id === 'global')?.id || brains[0]?.id
84
+ if (fallback && fallback !== activeBrainId) onBrainChange(fallback)
85
+ return
86
+ }
87
+ // Drop unknown ids (stale project:foo)
88
+ if (valid.join(',') !== selected.join(',')) {
89
+ onBrainChange(valid.join(','))
90
+ }
91
+ }, [loading, brains, activeBrainId, onBrainChange])
92
+
54
93
  const selectedIds = activeBrainId.split(',').filter(Boolean)
55
94
  const selectedBrains = brains.filter((b) => selectedIds.includes(b.id))
56
- const primary = selectedBrains[0] || brains[0]
95
+ const primary = selectedBrains[0] || brains.find((b) => b.id === 'global') || brains[0]
57
96
  const isMulti = selectedIds.length > 1
58
- const totalNodes = selectedBrains.reduce((sum, b) => sum + (b.node_count || 0), 0) || primary?.node_count || 0
97
+ const totalNodes =
98
+ selectedBrains.reduce((sum, b) => sum + (b.node_count || 0), 0) || primary?.node_count || 0
59
99
 
60
- const label = isMulti
61
- ? `${selectedIds.length} brains`
62
- : primary?.name || 'Brain'
100
+ const label = isMulti ? `${selectedIds.length} brains` : primary?.name || 'Brain'
101
+
102
+ const setSelection = (ids: string[]) => {
103
+ const next = ids.filter(Boolean)
104
+ if (next.length === 0) {
105
+ const fallback = brains.find((b) => b.id === 'global')?.id || brains[0]?.id
106
+ if (fallback) onBrainChange(fallback)
107
+ return
108
+ }
109
+ onBrainChange(next.join(','))
110
+ }
63
111
 
64
112
  const handleToggle = (id: string, e: React.SyntheticEvent) => {
65
113
  e.stopPropagation()
66
114
  if (selectedIds.includes(id)) {
67
- onBrainChange(selectedIds.filter((x) => x !== id).join(','))
115
+ setSelection(selectedIds.filter((x) => x !== id))
68
116
  } else {
69
- onBrainChange([...selectedIds, id].join(','))
117
+ setSelection([...selectedIds, id])
70
118
  }
71
119
  }
72
120
 
121
+ const handleSelectOnly = (id: string) => {
122
+ setSelection([id])
123
+ setExpanded(false)
124
+ }
125
+
73
126
  if (loading) {
74
127
  return (
75
- <div className="brain-selector-trigger" style={{ opacity: 0.6 }}>
128
+ <div className="brain-selector-trigger" data-testid="brain-selector-loading" style={{ opacity: 0.6 }}>
76
129
  <span style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>Brains…</span>
77
130
  </div>
78
131
  )
79
132
  }
80
133
 
81
- if (brains.length === 0) return null
134
+ if (error) {
135
+ return (
136
+ <div data-testid="brain-selector-error" style={{ fontSize: 10, color: '#ef4444', padding: '4px 6px' }}>
137
+ Brains failed{' '}
138
+ <button type="button" onClick={() => void loadBrains()} style={{ cursor: 'pointer', marginLeft: 4 }}>
139
+ Retry
140
+ </button>
141
+ </div>
142
+ )
143
+ }
144
+
145
+ if (brains.length === 0) {
146
+ return (
147
+ <div data-testid="brain-selector-empty" style={{ fontSize: 10, color: 'var(--text-tertiary)', padding: '4px 6px' }}>
148
+ No brains registered
149
+ </div>
150
+ )
151
+ }
82
152
 
83
153
  return (
84
- <div ref={selectorRef} className="brain-selector" style={{ position: 'relative' }}>
154
+ <div ref={selectorRef} className="brain-selector" data-testid="brain-selector" style={{ position: 'relative' }}>
85
155
  <button
86
156
  type="button"
87
157
  className="brain-selector-trigger"
158
+ data-testid="brain-selector-trigger"
88
159
  onClick={() => setExpanded((v) => !v)}
89
- title="Select brain layers"
160
+ title="Select active brain (sidebar only)"
90
161
  style={{
91
162
  width: '100%',
92
163
  display: 'flex',
@@ -128,9 +199,7 @@ export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSel
128
199
  >
129
200
  {label}
130
201
  </span>
131
- <span style={{ fontSize: 10, color: 'var(--text-tertiary)', flexShrink: 0 }}>
132
- {totalNodes}
133
- </span>
202
+ <span style={{ fontSize: 10, color: 'var(--text-tertiary)', flexShrink: 0 }}>{totalNodes}</span>
134
203
  <svg
135
204
  width="10"
136
205
  height="10"
@@ -152,6 +221,7 @@ export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSel
152
221
  {expanded && (
153
222
  <div
154
223
  className="brain-selector-menu"
224
+ data-testid="brain-selector-menu"
155
225
  style={{
156
226
  position: 'absolute',
157
227
  bottom: '100%',
@@ -179,7 +249,7 @@ export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSel
179
249
  borderBottom: '1px solid var(--border)',
180
250
  }}
181
251
  >
182
- Brains · multi-select
252
+ Active brains · checkbox multi · click row = only
183
253
  </div>
184
254
  {brains.map((brain) => {
185
255
  const isSelected = selectedIds.includes(brain.id)
@@ -188,16 +258,13 @@ export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSel
188
258
  key={brain.id}
189
259
  role="button"
190
260
  tabIndex={0}
261
+ data-testid={`brain-option-${brain.id}`}
191
262
  onClick={() => {
192
263
  if (!brain.exists) return
193
- onBrainChange(brain.id)
194
- setExpanded(false)
264
+ handleSelectOnly(brain.id)
195
265
  }}
196
266
  onKeyDown={(e) => {
197
- if (e.key === 'Enter' && brain.exists) {
198
- onBrainChange(brain.id)
199
- setExpanded(false)
200
- }
267
+ if (e.key === 'Enter' && brain.exists) handleSelectOnly(brain.id)
201
268
  }}
202
269
  style={{
203
270
  display: 'flex',
@@ -214,7 +281,9 @@ export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSel
214
281
  if (brain.exists) e.currentTarget.style.background = 'rgba(148, 163, 184, 0.08)'
215
282
  }}
216
283
  onMouseLeave={(e) => {
217
- e.currentTarget.style.background = isSelected ? 'rgba(59, 130, 246, 0.12)' : 'transparent'
284
+ e.currentTarget.style.background = isSelected
285
+ ? 'rgba(59, 130, 246, 0.12)'
286
+ : 'transparent'
218
287
  }}
219
288
  >
220
289
  <input
@@ -223,6 +292,7 @@ export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSel
223
292
  disabled={!brain.exists}
224
293
  onClick={(e) => e.stopPropagation()}
225
294
  onChange={(e) => handleToggle(brain.id, e)}
295
+ aria-label={`Include ${brain.name}`}
226
296
  style={{
227
297
  width: 13,
228
298
  height: 13,
@@ -274,11 +344,18 @@ export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSel
274
344
  background:
275
345
  brain.layer === 'global'
276
346
  ? 'rgba(59, 130, 246, 0.15)'
277
- : 'rgba(16, 185, 129, 0.12)',
278
- color: brain.layer === 'global' ? '#93c5fd' : '#6ee7b7',
347
+ : brain.layer === 'tenant'
348
+ ? 'rgba(168, 85, 247, 0.15)'
349
+ : 'rgba(16, 185, 129, 0.12)',
350
+ color:
351
+ brain.layer === 'global'
352
+ ? '#93c5fd'
353
+ : brain.layer === 'tenant'
354
+ ? '#d8b4fe'
355
+ : '#6ee7b7',
279
356
  }}
280
357
  >
281
- {brain.layer === 'global' ? 'G' : 'P'}
358
+ {layerBadge(brain.layer)}
282
359
  </span>
283
360
  </div>
284
361
  )
@@ -1,24 +1,54 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { render, screen } from '@testing-library/react';
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
3
  import { MobilePairing } from './MobilePairing';
4
4
 
5
+ vi.mock('../api', () => ({
6
+ apiFetch: vi.fn(),
7
+ getApiBase: vi.fn(() => ''),
8
+ }));
9
+
10
+ import { apiFetch } from '../api';
11
+
5
12
  describe('MobilePairing', () => {
6
13
  beforeEach(() => {
7
- // Mock window.location
8
- vi.stubGlobal('location', {
9
- protocol: 'http:',
10
- host: '192.168.1.100:3000'
11
- });
14
+ vi.clearAllMocks();
12
15
  });
13
16
 
14
- afterEach(() => {
15
- vi.unstubAllGlobals();
16
- });
17
+ it('renders QR with preferred LAN/mesh URL from API', async () => {
18
+ vi.mocked(apiFetch).mockResolvedValue({
19
+ ok: true,
20
+ json: async () => ({
21
+ port: 3000,
22
+ protocol: 'http',
23
+ preferred_url: 'http://10.0.0.3:3000',
24
+ listen_hosts: ['127.0.0.1', '100.64.0.3'],
25
+ warnings: ['Brain is not listening on LAN'],
26
+ endpoints: [
27
+ {
28
+ kind: 'lan',
29
+ label: 'Local Wi‑Fi / LAN',
30
+ ip: '10.0.0.3',
31
+ url: 'http://10.0.0.3:3000',
32
+ recommended: false,
33
+ reachable_hint: 'Same Wi‑Fi',
34
+ },
35
+ {
36
+ kind: 'mesh',
37
+ label: 'Tailscale / mesh',
38
+ ip: '100.64.0.3',
39
+ url: 'http://100.64.0.3:3000',
40
+ recommended: true,
41
+ reachable_hint: 'Tailscale',
42
+ },
43
+ ],
44
+ }),
45
+ } as any);
17
46
 
18
- it('renders instructions and QR code', () => {
19
47
  render(<MobilePairing />);
20
- expect(screen.getByText('Mobile Device Pairing')).toBeInTheDocument();
21
- expect(screen.getByText(/Scan this QR code/)).toBeInTheDocument();
22
- expect(screen.getByText('URL: http://192.168.1.100:3000')).toBeInTheDocument();
48
+ await waitFor(() => {
49
+ expect(screen.getByTestId('pairing-url').textContent).toContain('10.0.0.3:3000');
50
+ });
51
+ expect(screen.getByTestId('pairing-qr')).toBeTruthy();
52
+ expect(screen.getByTestId('pairing-warnings')).toBeTruthy();
23
53
  });
24
54
  });
@@ -1,36 +1,243 @@
1
- import { useState, useEffect } from 'react';
1
+ import { useEffect, useState } from 'react';
2
2
  import { QRCodeSVG } from 'qrcode.react';
3
+ import { apiFetch, getApiBase } from '../api';
4
+
5
+ export interface PairingEndpoint {
6
+ kind: string;
7
+ label: string;
8
+ ip: string;
9
+ url: string;
10
+ interface?: string | null;
11
+ recommended: boolean;
12
+ reachable_hint: string;
13
+ }
14
+
15
+ export interface PairingInfo {
16
+ port: number;
17
+ protocol: string;
18
+ preferred_url: string | null;
19
+ endpoints: PairingEndpoint[];
20
+ warnings: string[];
21
+ listen_hosts: string[];
22
+ }
3
23
 
4
24
  export function MobilePairing() {
5
- const [localIpUrl, setLocalIpUrl] = useState<string>('');
25
+ const [info, setInfo] = useState<PairingInfo | null>(null);
26
+ const [selectedUrl, setSelectedUrl] = useState('');
27
+ const [error, setError] = useState<string | null>(null);
28
+ const [loading, setLoading] = useState(true);
29
+ const [copied, setCopied] = useState(false);
30
+
31
+ const load = async () => {
32
+ setLoading(true);
33
+ setError(null);
34
+ try {
35
+ const res = await apiFetch(`${getApiBase()}/api/pairing`);
36
+ if (!res.ok) {
37
+ const body = await res.json().catch(() => ({}));
38
+ throw new Error(body.error || `Pairing API error: ${res.status}`);
39
+ }
40
+ const data = (await res.json()) as PairingInfo;
41
+ setInfo(data);
42
+ const preferred =
43
+ data.preferred_url ||
44
+ data.endpoints?.find((e) => e.recommended)?.url ||
45
+ data.endpoints?.[0]?.url ||
46
+ '';
47
+ setSelectedUrl(preferred);
48
+ } catch (err) {
49
+ console.error(err);
50
+ setError(err instanceof Error ? err.message : 'Failed to load pairing endpoints');
51
+ // Last-resort fallback — still better than nothing, but labeled
52
+ const fallback = `${window.location.protocol}//${window.location.host}`;
53
+ setSelectedUrl(fallback);
54
+ setInfo({
55
+ port: Number(window.location.port || 3000),
56
+ protocol: window.location.protocol.replace(':', '') || 'http',
57
+ preferred_url: fallback,
58
+ endpoints: [
59
+ {
60
+ kind: 'browser',
61
+ label: 'This browser URL (may not work on phone)',
62
+ ip: window.location.hostname,
63
+ url: fallback,
64
+ recommended: true,
65
+ reachable_hint: 'Fallback when pairing API is unavailable.',
66
+ },
67
+ ],
68
+ warnings: ['Could not load live network endpoints from the brain.'],
69
+ listen_hosts: [],
70
+ });
71
+ } finally {
72
+ setLoading(false);
73
+ }
74
+ };
6
75
 
7
76
  useEffect(() => {
8
- // Attempt to guess the local LAN IP, or just use window.location.host
9
- const protocol = window.location.protocol;
10
- const host = window.location.host;
11
-
12
- // In a real implementation we might fetch the actual LAN IP from the backend
13
- // Since the backend knows its bind address.
14
- setLocalIpUrl(`${protocol}//${host}`);
77
+ load();
15
78
  }, []);
16
79
 
80
+ const selected = info?.endpoints?.find((e) => e.url === selectedUrl) || null;
81
+ const isLoopback =
82
+ selected?.kind === 'loopback' ||
83
+ selectedUrl.includes('127.0.0.1') ||
84
+ selectedUrl.includes('localhost');
85
+
86
+ const copyUrl = async () => {
87
+ if (!selectedUrl) return;
88
+ try {
89
+ await navigator.clipboard.writeText(selectedUrl);
90
+ setCopied(true);
91
+ setTimeout(() => setCopied(false), 2000);
92
+ } catch {
93
+ setError('Clipboard copy failed — select the URL text manually.');
94
+ }
95
+ };
96
+
17
97
  return (
18
- <div className="mobile-pairing" style={{ padding: '20px', background: 'var(--bg-secondary)', borderRadius: '12px', border: '1px solid var(--border)' }}>
19
- <h3>Mobile Device Pairing</h3>
98
+ <div
99
+ className="mobile-pairing"
100
+ data-testid="mobile-pairing"
101
+ style={{
102
+ padding: '20px',
103
+ background: 'var(--bg-secondary)',
104
+ borderRadius: '12px',
105
+ border: '1px solid var(--border)',
106
+ }}
107
+ >
108
+ <h3 style={{ margin: '0 0 8px' }}>Mobile Device Pairing</h3>
20
109
  <p style={{ color: 'var(--text-tertiary)', fontSize: '13px', marginBottom: '16px' }}>
21
- Scan this QR code with your phone's camera to connect to this Total Recall instance. Ensure your phone is on the same Wi-Fi network.
110
+ Scan with your phone to open this Total Recall dashboard. Prefer <strong>LAN</strong> on the same
111
+ Wi‑Fi, or <strong>Tailscale/mesh</strong> if the phone is on your tailnet.
22
112
  </p>
23
- <div style={{ background: '#fff', padding: '16px', borderRadius: '8px', display: 'inline-block' }}>
24
- {localIpUrl ? (
25
- <QRCodeSVG value={localIpUrl} size={150} level="H" />
26
- ) : (
27
- <div style={{ width: 150, height: 150, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
28
- Loading...
113
+
114
+ {loading && (
115
+ <div data-testid="pairing-loading" style={{ color: 'var(--text-tertiary)', fontSize: 13 }}>
116
+ Detecting reachable addresses…
117
+ </div>
118
+ )}
119
+
120
+ {error && (
121
+ <div
122
+ role="alert"
123
+ data-testid="pairing-error"
124
+ style={{
125
+ marginBottom: 12,
126
+ padding: 10,
127
+ borderRadius: 8,
128
+ border: '1px solid #ef4444',
129
+ color: '#ef4444',
130
+ fontSize: 12,
131
+ }}
132
+ >
133
+ {error}{' '}
134
+ <button type="button" onClick={() => load()} style={{ marginLeft: 8, cursor: 'pointer' }}>
135
+ Retry
136
+ </button>
137
+ </div>
138
+ )}
139
+
140
+ {!loading && (
141
+ <div style={{ display: 'flex', gap: 20, flexWrap: 'wrap', alignItems: 'flex-start' }}>
142
+ <div
143
+ style={{
144
+ background: '#fff',
145
+ padding: '16px',
146
+ borderRadius: '8px',
147
+ display: 'inline-block',
148
+ }}
149
+ data-testid="pairing-qr"
150
+ >
151
+ {selectedUrl ? (
152
+ <QRCodeSVG value={selectedUrl} size={150} level="M" includeMargin />
153
+ ) : (
154
+ <div style={{ width: 150, height: 150 }} />
155
+ )}
29
156
  </div>
30
- )}
31
- </div>
32
- <p style={{ marginTop: '16px', fontSize: '12px', color: 'var(--text-tertiary)' }}>
33
- URL: {localIpUrl}
157
+
158
+ <div style={{ flex: 1, minWidth: 200 }}>
159
+ {info && info.endpoints.length > 0 && (
160
+ <div className="field-col" style={{ marginBottom: 12 }}>
161
+ <label className="field-label" htmlFor="pairing-endpoint">
162
+ Pairing address
163
+ </label>
164
+ <select
165
+ id="pairing-endpoint"
166
+ className="settings-select"
167
+ data-testid="pairing-endpoint-select"
168
+ value={selectedUrl}
169
+ onChange={(e) => setSelectedUrl(e.target.value)}
170
+ style={{ width: '100%', marginTop: 4 }}
171
+ >
172
+ {info.endpoints.map((ep) => (
173
+ <option key={ep.url} value={ep.url}>
174
+ {ep.label}
175
+ {ep.recommended ? ' ★' : ''} — {ep.url}
176
+ </option>
177
+ ))}
178
+ </select>
179
+ </div>
180
+ )}
181
+
182
+ <p
183
+ data-testid="pairing-url"
184
+ style={{
185
+ margin: '0 0 8px',
186
+ fontSize: 12,
187
+ fontFamily: 'var(--font-mono)',
188
+ wordBreak: 'break-all',
189
+ color: 'var(--text-primary)',
190
+ }}
191
+ >
192
+ {selectedUrl || '—'}
193
+ </p>
194
+
195
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
196
+ <button type="button" className="btn-secondary" onClick={copyUrl} data-testid="pairing-copy">
197
+ {copied ? 'Copied' : 'Copy URL'}
198
+ </button>
199
+ <button type="button" className="btn-ghost btn-sm" onClick={() => load()}>
200
+ Refresh
201
+ </button>
202
+ </div>
203
+
204
+ {selected && (
205
+ <p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: '0 0 8px' }}>
206
+ {selected.reachable_hint}
207
+ </p>
208
+ )}
209
+
210
+ {isLoopback && (
211
+ <p
212
+ data-testid="pairing-loopback-warning"
213
+ style={{ fontSize: 12, color: '#f59e0b', margin: 0 }}
214
+ >
215
+ This address only works on this computer. Choose LAN or Tailscale for your phone.
216
+ </p>
217
+ )}
218
+ </div>
219
+ </div>
220
+ )}
221
+
222
+ {info?.warnings && info.warnings.length > 0 && (
223
+ <ul
224
+ data-testid="pairing-warnings"
225
+ style={{
226
+ margin: '16px 0 0',
227
+ paddingLeft: 18,
228
+ fontSize: 12,
229
+ color: '#f59e0b',
230
+ }}
231
+ >
232
+ {info.warnings.map((w) => (
233
+ <li key={w}>{w}</li>
234
+ ))}
235
+ </ul>
236
+ )}
237
+
238
+ <p style={{ marginTop: 16, fontSize: 11, color: 'var(--text-tertiary)' }}>
239
+ After scanning, log in on the phone (same password/PAT as this dashboard). Listening:{' '}
240
+ {(info?.listen_hosts || []).join(', ') || 'unknown'}
34
241
  </p>
35
242
  </div>
36
243
  );
@@ -85,23 +85,19 @@ export default function HealthPage() {
85
85
  setUpdateError('')
86
86
 
87
87
  try {
88
- await runUpdate()
89
-
90
- // Keep polling health to check when it goes down and recovers
91
- let serverDown = false
92
- const pollInterval = setInterval(async () => {
93
- try {
94
- const res = await fetchHealth()
95
- if (serverDown && res.status === 'healthy') {
96
- clearInterval(pollInterval)
97
- window.location.reload()
98
- }
99
- } catch {
100
- // Connection failed = server is offline / restarting
101
- serverDown = true
102
- }
103
- }, 2000)
88
+ const result = await runUpdate()
89
+ if (!result.success) {
90
+ setUpdateError(result.message || 'Package auto-update reported failures')
91
+ setIsUpdating(false)
92
+ return
93
+ }
104
94
 
95
+ // Multi-repo npm update does not reboot this host — show success, optional reload
96
+ setIsUpdating(false)
97
+ setCheckMessage(
98
+ result.message ||
99
+ 'Registered projects updated. Restart local daemon/server if you need new code in this process.',
100
+ )
105
101
  } catch (e) {
106
102
  setUpdateError((e as Error).message)
107
103
  setIsUpdating(false)
@@ -101,17 +101,20 @@ export function NotificationsPage() {
101
101
  }
102
102
 
103
103
  return (
104
- <div className="page-container notifications-page">
104
+ <div className="page-container notifications-page" data-testid="notifications-page">
105
105
  <div className="page-header">
106
106
  <h1>Notifications</h1>
107
- <button onClick={() => setShowModal(true)} className="btn btn-primary">
107
+ <button onClick={() => setShowModal(true)} className="btn btn-primary" data-testid="add-rule-btn">
108
108
  Add Rule
109
109
  </button>
110
110
  </div>
111
111
 
112
112
  {error && (
113
- <div className="alert alert-error" style={{ marginBottom: '24px' }}>
113
+ <div className="alert alert-error" role="alert" data-testid="notifications-error" style={{ marginBottom: '24px' }}>
114
114
  {error}
115
+ <button type="button" className="btn btn-ghost btn-sm" style={{ marginLeft: 12 }} onClick={() => loadData()}>
116
+ Retry
117
+ </button>
115
118
  </div>
116
119
  )}
117
120
  {success && (