@peers-app/peers-ui 0.12.1 → 0.12.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.
@@ -83,6 +83,7 @@ const ChatOverlay = ({ position = 'bottom-right', }) => {
83
83
  const [voiceSettings, setVoiceSettings] = (0, react_1.useState)(DEFAULT_VOICE_SETTINGS);
84
84
  const [overlayPosition, setOverlayPosition] = (0, react_1.useState)(DEFAULT_POSITION);
85
85
  const [isDragging, setIsDragging] = (0, react_1.useState)(false);
86
+ const [pendingInitialMessage, setPendingInitialMessage] = (0, react_1.useState)(undefined);
86
87
  const messagesEndRef = (0, react_1.useRef)(null);
87
88
  const messagesContainerRef = (0, react_1.useRef)(null);
88
89
  const currentAudioRef = (0, react_1.useRef)(null);
@@ -185,6 +186,14 @@ const ChatOverlay = ({ position = 'bottom-right', }) => {
185
186
  // Subscribe to voice events
186
187
  (0, react_1.useEffect)(() => {
187
188
  const subscriptions = [
189
+ (0, peers_sdk_1.subscribe)('chat:openWithMessage', (event) => {
190
+ const { message } = event.data || {};
191
+ if (message) {
192
+ setPendingInitialMessage(message);
193
+ }
194
+ setIsExpanded(true);
195
+ setShowSettings(false);
196
+ }),
188
197
  (0, peers_sdk_1.subscribe)('voice:stateChanged', (event) => {
189
198
  setVoiceState(event.data.state);
190
199
  if (event.data.state === 'idle') {
@@ -354,6 +363,8 @@ const ChatOverlay = ({ position = 'bottom-right', }) => {
354
363
  const handleMessageSubmit = (0, react_1.useCallback)(async (message) => {
355
364
  // Notify voice service of text activity (resets inactivity timer)
356
365
  peers_sdk_1.rpcServerCalls.voiceNotifyTextActivity?.().catch(() => { });
366
+ // Clear any pending initial message that was pre-filled
367
+ setPendingInitialMessage(undefined);
357
368
  // If no thread yet, this message becomes the thread root
358
369
  if (!threadId) {
359
370
  message.messageParentId = undefined;
@@ -480,7 +491,7 @@ const ChatOverlay = ({ position = 'bottom-right', }) => {
480
491
  voiceState === 'recording' && (react_1.default.createElement("div", { className: "progress flex-grow-1", style: { height: '4px' } },
481
492
  react_1.default.createElement("div", { className: "progress-bar bg-danger", style: { width: `${Math.min(100, volumeLevel * 500)}%` } })))))),
482
493
  !showSettings && (react_1.default.createElement("div", { className: "card-footer p-2" },
483
- react_1.default.createElement(message_compose_1.MessageCompose, { channelId: globals_1.me?.userId || 'default', threadId: threadId || (0, peers_sdk_1.newid)(), onMessageSubmit: handleMessageSubmit }))))),
494
+ react_1.default.createElement(message_compose_1.MessageCompose, { channelId: globals_1.me?.userId || 'default', threadId: threadId || (0, peers_sdk_1.newid)(), onMessageSubmit: handleMessageSubmit, initialMessage: pendingInitialMessage, key: pendingInitialMessage ? `pre-${pendingInitialMessage.slice(0, 20)}` : 'default' }))))),
484
495
  react_1.default.createElement("div", { className: "d-flex gap-2 justify-content-end", onMouseDown: handleDragStart, style: { userSelect: isDragging ? 'none' : undefined } },
485
496
  voiceSettings.enabled && isExpanded && (react_1.default.createElement("button", { className: getButtonClass(), style: {
486
497
  width: '56px',
@@ -5,6 +5,7 @@ interface IProps {
5
5
  channelId: string;
6
6
  threadId?: string;
7
7
  onMessageSubmit: (message: IMessage, files?: IFile[]) => any;
8
+ initialMessage?: string;
8
9
  }
9
10
  export declare const MessageCompose: (props: IProps) => React.JSX.Element;
10
11
  export declare function isMobile(): boolean;
@@ -61,7 +61,7 @@ function getContentPersistenceValue(channelId, threadId) {
61
61
  return pVarCache[contentPersistenceName];
62
62
  }
63
63
  const MessageCompose = (props) => {
64
- const { channelId, threadId, onMessageSubmit } = props;
64
+ const { channelId, threadId, onMessageSubmit, initialMessage } = props;
65
65
  const [contentObs] = (0, react_1.useState)(() => (0, peers_sdk_1.observable)(''));
66
66
  const [mdEffects] = (0, react_1.useState)(() => ({}));
67
67
  const composeBottomRef = react_1.default.useRef(null);
@@ -75,7 +75,8 @@ const MessageCompose = (props) => {
75
75
  let sub;
76
76
  getContentPersistenceValue(channelId, threadId).loadingPromise.then(pVar => {
77
77
  if (!disposed) {
78
- contentObs(pVar() ?? '');
78
+ const persistedValue = pVar() ?? '';
79
+ contentObs(initialMessage ?? persistedValue);
79
80
  sub = contentObs.subscribe(() => pVar(contentObs()));
80
81
  setTimeout(() => scrollToBottom(), 100);
81
82
  }
@@ -0,0 +1,14 @@
1
+ import React from 'react';
2
+ export interface IDiagnosticCheck {
3
+ id: string;
4
+ label: string;
5
+ layer: string;
6
+ status: 'pass' | 'fail' | 'warn' | 'skip' | 'running';
7
+ detail: string;
8
+ suggestion?: string;
9
+ }
10
+ interface Props {
11
+ onBack: () => void;
12
+ }
13
+ export declare function ConnectionTroubleshooter({ onBack }: Props): React.JSX.Element;
14
+ export {};
@@ -0,0 +1,298 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ConnectionTroubleshooter = ConnectionTroubleshooter;
37
+ const react_1 = __importStar(require("react"));
38
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
39
+ const STATUS_ICON = {
40
+ pass: react_1.default.createElement("i", { className: "bi bi-check-circle-fill text-success" }),
41
+ fail: react_1.default.createElement("i", { className: "bi bi-x-circle-fill text-danger" }),
42
+ warn: react_1.default.createElement("i", { className: "bi bi-exclamation-triangle-fill text-warning" }),
43
+ skip: react_1.default.createElement("i", { className: "bi bi-dash-circle text-muted" }),
44
+ running: (react_1.default.createElement("span", { className: "spinner-border spinner-border-sm text-primary", role: "status", "aria-hidden": "true" })),
45
+ };
46
+ const STATUS_BADGE_CLASS = {
47
+ pass: 'bg-success',
48
+ fail: 'bg-danger',
49
+ warn: 'bg-warning text-dark',
50
+ skip: 'bg-secondary',
51
+ running: 'bg-primary',
52
+ };
53
+ const LAYER_ORDER = [
54
+ 'Local System',
55
+ 'Cloud Relay',
56
+ 'Local Network',
57
+ 'Protocol Support',
58
+ 'WebRTC / TURN',
59
+ 'Peer State',
60
+ 'Active Connections',
61
+ ];
62
+ function groupByLayer(checks) {
63
+ const groups = {};
64
+ for (const check of checks) {
65
+ if (!groups[check.layer])
66
+ groups[check.layer] = [];
67
+ groups[check.layer].push(check);
68
+ }
69
+ return groups;
70
+ }
71
+ function getSummary(checks) {
72
+ return {
73
+ issues: checks.filter(c => c.status === 'fail').length,
74
+ warnings: checks.filter(c => c.status === 'warn').length,
75
+ passes: checks.filter(c => c.status === 'pass').length,
76
+ skips: checks.filter(c => c.status === 'skip').length,
77
+ };
78
+ }
79
+ function formatReportForAI(checks) {
80
+ const lines = ['Connection Troubleshooter Report', '================================'];
81
+ const groups = groupByLayer(checks);
82
+ for (const layer of LAYER_ORDER) {
83
+ const layerChecks = groups[layer];
84
+ if (!layerChecks)
85
+ continue;
86
+ lines.push(`\n[${layer}]`);
87
+ for (const check of layerChecks) {
88
+ const statusStr = check.status.toUpperCase().padEnd(7);
89
+ lines.push(` ${statusStr} ${check.label}: ${check.detail}`);
90
+ if (check.suggestion) {
91
+ lines.push(` Suggestion: ${check.suggestion}`);
92
+ }
93
+ }
94
+ }
95
+ const { issues, warnings, skips } = getSummary(checks);
96
+ lines.push(`\nSummary: ${issues} issue(s), ${warnings} warning(s), ${skips} skipped.`);
97
+ return lines.join('\n');
98
+ }
99
+ function ConnectionTroubleshooter({ onBack }) {
100
+ const [checks, setChecks] = (0, react_1.useState)([]);
101
+ const [running, setRunning] = (0, react_1.useState)(false);
102
+ const [done, setDone] = (0, react_1.useState)(false);
103
+ const [expanded, setExpanded] = (0, react_1.useState)(new Set());
104
+ const hasRun = (0, react_1.useRef)(false);
105
+ const runDiagnostics = async () => {
106
+ setRunning(true);
107
+ setDone(false);
108
+ setChecks([]);
109
+ setExpanded(new Set());
110
+ const api = window.electronAPI?.networkViewer;
111
+ if (!api) {
112
+ setChecks([{
113
+ id: 'no-api',
114
+ label: 'Electron API unavailable',
115
+ layer: 'Local System',
116
+ status: 'fail',
117
+ detail: 'The Electron network API is not available. This tool only works in the desktop app.',
118
+ }]);
119
+ setRunning(false);
120
+ setDone(true);
121
+ return;
122
+ }
123
+ try {
124
+ // Stream results by running all checks on the backend and updating as they come in.
125
+ // Show a placeholder "running" row immediately while the full batch executes.
126
+ const placeholders = LAYER_ORDER.map(layer => ({
127
+ id: `placeholder-${layer}`,
128
+ label: `Checking ${layer}…`,
129
+ layer,
130
+ status: 'running',
131
+ detail: '',
132
+ }));
133
+ setChecks(placeholders);
134
+ const results = await api.runDiagnostics();
135
+ setChecks(results);
136
+ // Auto-expand all failed/warned checks so they're visible immediately
137
+ const toExpand = new Set();
138
+ for (const r of results) {
139
+ if ((r.status === 'fail' || r.status === 'warn') && r.suggestion) {
140
+ toExpand.add(r.id);
141
+ }
142
+ }
143
+ setExpanded(toExpand);
144
+ }
145
+ catch (err) {
146
+ setChecks([{
147
+ id: 'run-error',
148
+ label: 'Diagnostics failed to run',
149
+ layer: 'Local System',
150
+ status: 'fail',
151
+ detail: `Error: ${err?.message || 'Unknown error'}`,
152
+ suggestion: 'Restart the application and try again.',
153
+ }]);
154
+ }
155
+ finally {
156
+ setRunning(false);
157
+ setDone(true);
158
+ }
159
+ };
160
+ (0, react_1.useEffect)(() => {
161
+ if (!hasRun.current) {
162
+ hasRun.current = true;
163
+ runDiagnostics();
164
+ }
165
+ // eslint-disable-next-line react-hooks/exhaustive-deps
166
+ }, []);
167
+ const toggleExpand = (id) => {
168
+ setExpanded(prev => {
169
+ const next = new Set(prev);
170
+ if (next.has(id))
171
+ next.delete(id);
172
+ else
173
+ next.add(id);
174
+ return next;
175
+ });
176
+ };
177
+ const handleAskAssistant = () => {
178
+ const report = formatReportForAI(checks.filter(c => c.status !== 'running'));
179
+ const message = `I ran the Peers connection troubleshooter and got the following results. Please help me understand what's wrong and how to fix it:\n\n${report}`;
180
+ (0, peers_sdk_1.emit)({ name: 'chat:openWithMessage', data: { message } }).catch(() => {
181
+ // Fallback: copy to clipboard
182
+ navigator.clipboard.writeText(message).then(() => {
183
+ alert('Diagnostic report copied to clipboard. Paste it into the AI assistant chat.');
184
+ }).catch(() => {
185
+ alert('Could not open AI assistant. Please open the chat manually and paste the diagnostic report.');
186
+ });
187
+ });
188
+ };
189
+ const groups = groupByLayer(checks.filter(c => !c.id.startsWith('placeholder-')));
190
+ const placeholders = checks.filter(c => c.id.startsWith('placeholder-'));
191
+ const summary = getSummary(checks);
192
+ const completedCount = checks.filter(c => c.status !== 'running').length;
193
+ const totalCount = checks.length;
194
+ const progressPct = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
195
+ const overallStatus = !done
196
+ ? 'running'
197
+ : summary.issues > 0
198
+ ? 'fail'
199
+ : summary.warnings > 0
200
+ ? 'warn'
201
+ : 'pass';
202
+ return (react_1.default.createElement("div", { className: "container-fluid p-4" },
203
+ react_1.default.createElement("div", { className: "d-flex justify-content-between align-items-center mb-3" },
204
+ react_1.default.createElement("div", { className: "d-flex align-items-center gap-3" },
205
+ react_1.default.createElement("button", { className: "btn btn-sm btn-outline-secondary", onClick: onBack },
206
+ react_1.default.createElement("i", { className: "bi bi-arrow-left me-1" }),
207
+ "Back"),
208
+ react_1.default.createElement("h4", { className: "mb-0" },
209
+ react_1.default.createElement("i", { className: "bi bi-stethoscope me-2 text-primary" }),
210
+ "Connection Troubleshooter")),
211
+ react_1.default.createElement("button", { className: "btn btn-sm btn-primary", onClick: runDiagnostics, disabled: running }, running ? (react_1.default.createElement(react_1.default.Fragment, null,
212
+ react_1.default.createElement("span", { className: "spinner-border spinner-border-sm me-1", role: "status", "aria-hidden": "true" }),
213
+ "Running\u2026")) : (react_1.default.createElement(react_1.default.Fragment, null,
214
+ react_1.default.createElement("i", { className: "bi bi-arrow-clockwise me-1" }),
215
+ "Re-run")))),
216
+ running && (react_1.default.createElement("div", { className: "mb-4" },
217
+ react_1.default.createElement("div", { className: "d-flex justify-content-between mb-1" },
218
+ react_1.default.createElement("small", { className: "text-muted" }, "Running diagnostics\u2026"),
219
+ react_1.default.createElement("small", { className: "text-muted" },
220
+ completedCount,
221
+ " / ",
222
+ totalCount)),
223
+ react_1.default.createElement("div", { className: "progress", style: { height: '6px' } },
224
+ react_1.default.createElement("div", { className: "progress-bar progress-bar-striped progress-bar-animated", style: { width: `${progressPct}%` } })))),
225
+ done && checks.length > 0 && (react_1.default.createElement("div", { className: `alert ${overallStatus === 'pass' ? 'alert-success' : overallStatus === 'warn' ? 'alert-warning' : 'alert-danger'} d-flex justify-content-between align-items-center mb-4` },
226
+ react_1.default.createElement("div", { className: "d-flex align-items-center gap-2" },
227
+ overallStatus === 'pass' && react_1.default.createElement("i", { className: "bi bi-check-circle-fill fs-5" }),
228
+ overallStatus === 'warn' && react_1.default.createElement("i", { className: "bi bi-exclamation-triangle-fill fs-5" }),
229
+ overallStatus === 'fail' && react_1.default.createElement("i", { className: "bi bi-x-circle-fill fs-5" }),
230
+ react_1.default.createElement("div", null,
231
+ overallStatus === 'pass' && react_1.default.createElement("strong", null, "All checks passed."),
232
+ overallStatus === 'warn' && react_1.default.createElement("strong", null,
233
+ summary.warnings,
234
+ " warning",
235
+ summary.warnings > 1 ? 's' : '',
236
+ " found."),
237
+ overallStatus === 'fail' && react_1.default.createElement("strong", null,
238
+ summary.issues,
239
+ " issue",
240
+ summary.issues > 1 ? 's' : '',
241
+ " found",
242
+ summary.warnings > 0 ? `, ${summary.warnings} warning${summary.warnings > 1 ? 's' : ''}` : '',
243
+ "."),
244
+ ' ',
245
+ react_1.default.createElement("span", { className: "opacity-75" },
246
+ summary.passes,
247
+ " passed \u00B7 ",
248
+ summary.issues,
249
+ " failed \u00B7 ",
250
+ summary.warnings,
251
+ " warnings \u00B7 ",
252
+ summary.skips,
253
+ " skipped"))),
254
+ react_1.default.createElement("button", { className: "btn btn-sm btn-dark d-flex align-items-center gap-1", onClick: handleAskAssistant },
255
+ react_1.default.createElement("i", { className: "bi bi-chat-dots-fill" }),
256
+ "Ask AI Assistant"))),
257
+ running && placeholders.length > 0 && (react_1.default.createElement("div", { className: "card mb-3" },
258
+ react_1.default.createElement("div", { className: "card-body p-2" }, placeholders.map(p => (react_1.default.createElement("div", { key: p.id, className: "d-flex align-items-center gap-2 p-2 border-bottom" },
259
+ STATUS_ICON[p.status],
260
+ react_1.default.createElement("span", { className: "text-muted small" }, p.label))))))),
261
+ LAYER_ORDER.map(layer => {
262
+ const layerChecks = groups[layer];
263
+ if (!layerChecks || layerChecks.length === 0)
264
+ return null;
265
+ const layerStatus = layerChecks.some(c => c.status === 'fail')
266
+ ? 'fail'
267
+ : layerChecks.some(c => c.status === 'warn')
268
+ ? 'warn'
269
+ : layerChecks.some(c => c.status === 'running')
270
+ ? 'running'
271
+ : layerChecks.every(c => c.status === 'skip')
272
+ ? 'skip'
273
+ : 'pass';
274
+ return (react_1.default.createElement("div", { className: "card mb-3", key: layer },
275
+ react_1.default.createElement("div", { className: "card-header d-flex align-items-center justify-content-between py-2" },
276
+ react_1.default.createElement("div", { className: "d-flex align-items-center gap-2" },
277
+ STATUS_ICON[layerStatus],
278
+ react_1.default.createElement("strong", { className: "small" }, layer)),
279
+ react_1.default.createElement("span", { className: `badge ${STATUS_BADGE_CLASS[layerStatus]}` }, layerStatus === 'running' ? 'running' : layerStatus)),
280
+ react_1.default.createElement("div", { className: "list-group list-group-flush" }, layerChecks.map(check => (react_1.default.createElement("div", { key: check.id, className: "list-group-item p-0" },
281
+ react_1.default.createElement("div", { className: `d-flex align-items-start gap-2 px-3 py-2 ${check.suggestion ? 'cursor-pointer' : ''}`, style: { cursor: check.suggestion ? 'pointer' : 'default' }, onClick: () => check.suggestion && toggleExpand(check.id), role: check.suggestion ? 'button' : undefined },
282
+ react_1.default.createElement("div", { className: "pt-1 flex-shrink-0" }, STATUS_ICON[check.status]),
283
+ react_1.default.createElement("div", { className: "flex-grow-1 min-width-0" },
284
+ react_1.default.createElement("div", { className: "d-flex justify-content-between align-items-start" },
285
+ react_1.default.createElement("span", { className: "small fw-medium" }, check.label),
286
+ check.suggestion && (react_1.default.createElement("i", { className: `bi bi-chevron-${expanded.has(check.id) ? 'up' : 'down'} text-muted ms-2 flex-shrink-0 small` }))),
287
+ react_1.default.createElement("div", { className: "small text-muted mt-1" }, check.detail))),
288
+ check.suggestion && expanded.has(check.id) && (react_1.default.createElement("div", { className: "px-3 pb-2 pt-0 ms-4" },
289
+ react_1.default.createElement("div", { className: "alert alert-info py-2 px-3 mb-0 small d-flex gap-2" },
290
+ react_1.default.createElement("i", { className: "bi bi-lightbulb-fill text-info flex-shrink-0 mt-1" }),
291
+ react_1.default.createElement("span", null, check.suggestion))))))))));
292
+ }),
293
+ done && checks.length > 0 && (react_1.default.createElement("div", { className: "text-center py-3" },
294
+ react_1.default.createElement("button", { className: "btn btn-outline-primary", onClick: handleAskAssistant },
295
+ react_1.default.createElement("i", { className: "bi bi-chat-dots-fill me-2" }),
296
+ "Ask AI Assistant for Help"),
297
+ react_1.default.createElement("p", { className: "text-muted small mt-2 mb-0" }, "Sends the full diagnostic report to the AI assistant for guided troubleshooting.")))));
298
+ }
@@ -3,3 +3,4 @@ export * from "./network-viewer";
3
3
  export * from "./device-details-modal";
4
4
  export * from "./cpu-usage-graph";
5
5
  export * from "./usage-graph";
6
+ export * from "./connection-troubleshooter";
@@ -20,3 +20,4 @@ __exportStar(require("./network-viewer"), exports);
20
20
  __exportStar(require("./device-details-modal"), exports);
21
21
  __exportStar(require("./cpu-usage-graph"), exports);
22
22
  __exportStar(require("./usage-graph"), exports);
23
+ __exportStar(require("./connection-troubleshooter"), exports);
@@ -43,6 +43,7 @@ const globals_1 = require("../../globals");
43
43
  const device_details_modal_1 = require("./device-details-modal");
44
44
  const group_details_modal_1 = require("./group-details-modal");
45
45
  const usage_graph_1 = require("./usage-graph");
46
+ const connection_troubleshooter_1 = require("./connection-troubleshooter");
46
47
  /** Format bytes to human-readable string (KB, MB, GB) */
47
48
  function formatBytes(bytes) {
48
49
  if (bytes < 1024)
@@ -74,6 +75,7 @@ function NetworkViewerList() {
74
75
  const [selectedDeviceId, setSelectedDeviceId] = (0, react_1.useState)(null);
75
76
  const [selectedGroupId, setSelectedGroupId] = (0, react_1.useState)(null);
76
77
  const [connecting, setConnecting] = (0, react_1.useState)(new Set());
78
+ const [showTroubleshooter, setShowTroubleshooter] = (0, react_1.useState)(false);
77
79
  const loadData = async () => {
78
80
  try {
79
81
  if (!globals_1.isDesktop)
@@ -198,12 +200,19 @@ function NetworkViewerList() {
198
200
  return (react_1.default.createElement("div", { className: "container-fluid p-4" },
199
201
  react_1.default.createElement("div", { className: "alert alert-warning" }, "Unable to load network information.")));
200
202
  }
203
+ if (showTroubleshooter) {
204
+ return react_1.default.createElement(connection_troubleshooter_1.ConnectionTroubleshooter, { onBack: () => setShowTroubleshooter(false) });
205
+ }
201
206
  return (react_1.default.createElement("div", { className: "container-fluid p-4" },
202
207
  react_1.default.createElement("div", { className: "d-flex justify-content-between align-items-center mb-4" },
203
208
  react_1.default.createElement("h2", null, "Network Viewer"),
204
- react_1.default.createElement("button", { className: "btn btn-sm btn-primary", onClick: handleRefresh, disabled: refreshing },
205
- react_1.default.createElement("i", { className: `bi bi-arrow-clockwise ${refreshing ? 'spin' : ''}` }),
206
- refreshing ? ' Refreshing...' : ' Refresh')),
209
+ react_1.default.createElement("div", { className: "d-flex gap-2" },
210
+ react_1.default.createElement("button", { className: "btn btn-sm btn-outline-warning", onClick: () => setShowTroubleshooter(true), title: "Run connection diagnostics" },
211
+ react_1.default.createElement("i", { className: "bi bi-stethoscope me-1" }),
212
+ "Troubleshoot"),
213
+ react_1.default.createElement("button", { className: "btn btn-sm btn-primary", onClick: handleRefresh, disabled: refreshing },
214
+ react_1.default.createElement("i", { className: `bi bi-arrow-clockwise ${refreshing ? 'spin' : ''}` }),
215
+ refreshing ? ' Refreshing...' : ' Refresh'))),
207
216
  react_1.default.createElement("div", { className: "card mb-2" },
208
217
  react_1.default.createElement("div", { className: "card-body" },
209
218
  react_1.default.createElement("h5", { className: "card-title" }, "Local Device"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-ui.git"
@@ -26,7 +26,7 @@
26
26
  "test:coverage": "jest --coverage"
27
27
  },
28
28
  "peerDependencies": {
29
- "@peers-app/peers-sdk": "^0.12.1",
29
+ "@peers-app/peers-sdk": "^0.12.2",
30
30
  "bootstrap": "^5.3.3",
31
31
  "react": "^18.0.0",
32
32
  "react-dom": "^18.0.0"
@@ -36,7 +36,7 @@
36
36
  "@babel/preset-env": "^7.24.5",
37
37
  "@babel/preset-react": "^7.24.1",
38
38
  "@babel/preset-typescript": "^7.27.1",
39
- "@peers-app/peers-sdk": "0.12.1",
39
+ "@peers-app/peers-sdk": "0.12.2",
40
40
  "@testing-library/dom": "^10.4.0",
41
41
  "@testing-library/jest-dom": "^6.6.3",
42
42
  "@testing-library/react": "^16.3.0",
@@ -82,6 +82,7 @@ export const ChatOverlay: React.FC<ChatOverlayProps> = ({
82
82
  const [voiceSettings, setVoiceSettings] = useState<VoiceSettingsData>(DEFAULT_VOICE_SETTINGS);
83
83
  const [overlayPosition, setOverlayPosition] = useState<OverlayPosition>(DEFAULT_POSITION);
84
84
  const [isDragging, setIsDragging] = useState(false);
85
+ const [pendingInitialMessage, setPendingInitialMessage] = useState<string | undefined>(undefined);
85
86
 
86
87
  const messagesEndRef = useRef<HTMLDivElement>(null);
87
88
  const messagesContainerRef = useRef<HTMLDivElement>(null);
@@ -201,6 +202,14 @@ export const ChatOverlay: React.FC<ChatOverlayProps> = ({
201
202
  // Subscribe to voice events
202
203
  useEffect(() => {
203
204
  const subscriptions = [
205
+ subscribe('chat:openWithMessage', (event: any) => {
206
+ const { message } = event.data || {};
207
+ if (message) {
208
+ setPendingInitialMessage(message);
209
+ }
210
+ setIsExpanded(true);
211
+ setShowSettings(false);
212
+ }),
204
213
  subscribe('voice:stateChanged', (event: any) => {
205
214
  setVoiceState(event.data.state);
206
215
  if (event.data.state === 'idle') {
@@ -385,6 +394,9 @@ export const ChatOverlay: React.FC<ChatOverlayProps> = ({
385
394
  const handleMessageSubmit = useCallback(async (message: IMessage) => {
386
395
  // Notify voice service of text activity (resets inactivity timer)
387
396
  rpcServerCalls.voiceNotifyTextActivity?.().catch(() => {});
397
+
398
+ // Clear any pending initial message that was pre-filled
399
+ setPendingInitialMessage(undefined);
388
400
 
389
401
  // If no thread yet, this message becomes the thread root
390
402
  if (!threadId) {
@@ -646,6 +658,8 @@ export const ChatOverlay: React.FC<ChatOverlayProps> = ({
646
658
  channelId={me?.userId || 'default'}
647
659
  threadId={threadId || newid()}
648
660
  onMessageSubmit={handleMessageSubmit}
661
+ initialMessage={pendingInitialMessage}
662
+ key={pendingInitialMessage ? `pre-${pendingInitialMessage.slice(0, 20)}` : 'default'}
649
663
  />
650
664
  </div>
651
665
  )}
@@ -32,10 +32,11 @@ interface IProps {
32
32
  channelId: string
33
33
  threadId?: string
34
34
  onMessageSubmit: (message: IMessage, files?: IFile[]) => any
35
+ initialMessage?: string
35
36
  }
36
37
 
37
38
  export const MessageCompose = (props: IProps) => {
38
- const { channelId, threadId, onMessageSubmit } = props;
39
+ const { channelId, threadId, onMessageSubmit, initialMessage } = props;
39
40
 
40
41
  const [contentObs] = useState(() => observable(''));
41
42
  const [mdEffects] = useState<IEditorEffects>(() => ({}));
@@ -52,7 +53,8 @@ export const MessageCompose = (props: IProps) => {
52
53
  let sub: Subscription | undefined;
53
54
  getContentPersistenceValue(channelId, threadId).loadingPromise.then(pVar => {
54
55
  if (!disposed) {
55
- contentObs(pVar() ?? '');
56
+ const persistedValue = pVar() ?? '';
57
+ contentObs(initialMessage ?? persistedValue);
56
58
  sub = contentObs.subscribe(() => pVar(contentObs()));
57
59
  setTimeout(() => scrollToBottom(), 100);
58
60
  }
@@ -0,0 +1,358 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { emit } from '@peers-app/peers-sdk';
3
+
4
+ export interface IDiagnosticCheck {
5
+ id: string;
6
+ label: string;
7
+ layer: string;
8
+ status: 'pass' | 'fail' | 'warn' | 'skip' | 'running';
9
+ detail: string;
10
+ suggestion?: string;
11
+ }
12
+
13
+ interface Props {
14
+ onBack: () => void;
15
+ }
16
+
17
+ const STATUS_ICON: Record<IDiagnosticCheck['status'], React.ReactNode> = {
18
+ pass: <i className="bi bi-check-circle-fill text-success" />,
19
+ fail: <i className="bi bi-x-circle-fill text-danger" />,
20
+ warn: <i className="bi bi-exclamation-triangle-fill text-warning" />,
21
+ skip: <i className="bi bi-dash-circle text-muted" />,
22
+ running: (
23
+ <span className="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true" />
24
+ ),
25
+ };
26
+
27
+ const STATUS_BADGE_CLASS: Record<IDiagnosticCheck['status'], string> = {
28
+ pass: 'bg-success',
29
+ fail: 'bg-danger',
30
+ warn: 'bg-warning text-dark',
31
+ skip: 'bg-secondary',
32
+ running: 'bg-primary',
33
+ };
34
+
35
+ const LAYER_ORDER = [
36
+ 'Local System',
37
+ 'Cloud Relay',
38
+ 'Local Network',
39
+ 'Protocol Support',
40
+ 'WebRTC / TURN',
41
+ 'Peer State',
42
+ 'Active Connections',
43
+ ];
44
+
45
+ function groupByLayer(checks: IDiagnosticCheck[]): Record<string, IDiagnosticCheck[]> {
46
+ const groups: Record<string, IDiagnosticCheck[]> = {};
47
+ for (const check of checks) {
48
+ if (!groups[check.layer]) groups[check.layer] = [];
49
+ groups[check.layer].push(check);
50
+ }
51
+ return groups;
52
+ }
53
+
54
+ function getSummary(checks: IDiagnosticCheck[]): { issues: number; warnings: number; passes: number; skips: number } {
55
+ return {
56
+ issues: checks.filter(c => c.status === 'fail').length,
57
+ warnings: checks.filter(c => c.status === 'warn').length,
58
+ passes: checks.filter(c => c.status === 'pass').length,
59
+ skips: checks.filter(c => c.status === 'skip').length,
60
+ };
61
+ }
62
+
63
+ function formatReportForAI(checks: IDiagnosticCheck[]): string {
64
+ const lines: string[] = ['Connection Troubleshooter Report', '================================'];
65
+ const groups = groupByLayer(checks);
66
+
67
+ for (const layer of LAYER_ORDER) {
68
+ const layerChecks = groups[layer];
69
+ if (!layerChecks) continue;
70
+ lines.push(`\n[${layer}]`);
71
+ for (const check of layerChecks) {
72
+ const statusStr = check.status.toUpperCase().padEnd(7);
73
+ lines.push(` ${statusStr} ${check.label}: ${check.detail}`);
74
+ if (check.suggestion) {
75
+ lines.push(` Suggestion: ${check.suggestion}`);
76
+ }
77
+ }
78
+ }
79
+
80
+ const { issues, warnings, skips } = getSummary(checks);
81
+ lines.push(`\nSummary: ${issues} issue(s), ${warnings} warning(s), ${skips} skipped.`);
82
+
83
+ return lines.join('\n');
84
+ }
85
+
86
+ export function ConnectionTroubleshooter({ onBack }: Props) {
87
+ const [checks, setChecks] = useState<IDiagnosticCheck[]>([]);
88
+ const [running, setRunning] = useState(false);
89
+ const [done, setDone] = useState(false);
90
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
91
+ const hasRun = useRef(false);
92
+
93
+ const runDiagnostics = async () => {
94
+ setRunning(true);
95
+ setDone(false);
96
+ setChecks([]);
97
+ setExpanded(new Set());
98
+
99
+ const api = (window as any).electronAPI?.networkViewer;
100
+ if (!api) {
101
+ setChecks([{
102
+ id: 'no-api',
103
+ label: 'Electron API unavailable',
104
+ layer: 'Local System',
105
+ status: 'fail',
106
+ detail: 'The Electron network API is not available. This tool only works in the desktop app.',
107
+ }]);
108
+ setRunning(false);
109
+ setDone(true);
110
+ return;
111
+ }
112
+
113
+ try {
114
+ // Stream results by running all checks on the backend and updating as they come in.
115
+ // Show a placeholder "running" row immediately while the full batch executes.
116
+ const placeholders: IDiagnosticCheck[] = LAYER_ORDER.map(layer => ({
117
+ id: `placeholder-${layer}`,
118
+ label: `Checking ${layer}…`,
119
+ layer,
120
+ status: 'running',
121
+ detail: '',
122
+ }));
123
+ setChecks(placeholders);
124
+
125
+ const results: IDiagnosticCheck[] = await api.runDiagnostics();
126
+ setChecks(results);
127
+
128
+ // Auto-expand all failed/warned checks so they're visible immediately
129
+ const toExpand = new Set<string>();
130
+ for (const r of results) {
131
+ if ((r.status === 'fail' || r.status === 'warn') && r.suggestion) {
132
+ toExpand.add(r.id);
133
+ }
134
+ }
135
+ setExpanded(toExpand);
136
+ } catch (err: any) {
137
+ setChecks([{
138
+ id: 'run-error',
139
+ label: 'Diagnostics failed to run',
140
+ layer: 'Local System',
141
+ status: 'fail',
142
+ detail: `Error: ${err?.message || 'Unknown error'}`,
143
+ suggestion: 'Restart the application and try again.',
144
+ }]);
145
+ } finally {
146
+ setRunning(false);
147
+ setDone(true);
148
+ }
149
+ };
150
+
151
+ useEffect(() => {
152
+ if (!hasRun.current) {
153
+ hasRun.current = true;
154
+ runDiagnostics();
155
+ }
156
+ // eslint-disable-next-line react-hooks/exhaustive-deps
157
+ }, []);
158
+
159
+ const toggleExpand = (id: string) => {
160
+ setExpanded(prev => {
161
+ const next = new Set(prev);
162
+ if (next.has(id)) next.delete(id);
163
+ else next.add(id);
164
+ return next;
165
+ });
166
+ };
167
+
168
+ const handleAskAssistant = () => {
169
+ const report = formatReportForAI(checks.filter(c => c.status !== 'running'));
170
+ const message = `I ran the Peers connection troubleshooter and got the following results. Please help me understand what's wrong and how to fix it:\n\n${report}`;
171
+ emit({ name: 'chat:openWithMessage', data: { message } }).catch(() => {
172
+ // Fallback: copy to clipboard
173
+ navigator.clipboard.writeText(message).then(() => {
174
+ alert('Diagnostic report copied to clipboard. Paste it into the AI assistant chat.');
175
+ }).catch(() => {
176
+ alert('Could not open AI assistant. Please open the chat manually and paste the diagnostic report.');
177
+ });
178
+ });
179
+ };
180
+
181
+ const groups = groupByLayer(checks.filter(c => !c.id.startsWith('placeholder-')));
182
+ const placeholders = checks.filter(c => c.id.startsWith('placeholder-'));
183
+ const summary = getSummary(checks);
184
+ const completedCount = checks.filter(c => c.status !== 'running').length;
185
+ const totalCount = checks.length;
186
+ const progressPct = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
187
+
188
+ const overallStatus: IDiagnosticCheck['status'] = !done
189
+ ? 'running'
190
+ : summary.issues > 0
191
+ ? 'fail'
192
+ : summary.warnings > 0
193
+ ? 'warn'
194
+ : 'pass';
195
+
196
+ return (
197
+ <div className="container-fluid p-4">
198
+ {/* Header */}
199
+ <div className="d-flex justify-content-between align-items-center mb-3">
200
+ <div className="d-flex align-items-center gap-3">
201
+ <button className="btn btn-sm btn-outline-secondary" onClick={onBack}>
202
+ <i className="bi bi-arrow-left me-1" />
203
+ Back
204
+ </button>
205
+ <h4 className="mb-0">
206
+ <i className="bi bi-stethoscope me-2 text-primary" />
207
+ Connection Troubleshooter
208
+ </h4>
209
+ </div>
210
+ <button
211
+ className="btn btn-sm btn-primary"
212
+ onClick={runDiagnostics}
213
+ disabled={running}
214
+ >
215
+ {running ? (
216
+ <>
217
+ <span className="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true" />
218
+ Running…
219
+ </>
220
+ ) : (
221
+ <>
222
+ <i className="bi bi-arrow-clockwise me-1" />
223
+ Re-run
224
+ </>
225
+ )}
226
+ </button>
227
+ </div>
228
+
229
+ {/* Progress bar */}
230
+ {running && (
231
+ <div className="mb-4">
232
+ <div className="d-flex justify-content-between mb-1">
233
+ <small className="text-muted">Running diagnostics…</small>
234
+ <small className="text-muted">{completedCount} / {totalCount}</small>
235
+ </div>
236
+ <div className="progress" style={{ height: '6px' }}>
237
+ <div
238
+ className="progress-bar progress-bar-striped progress-bar-animated"
239
+ style={{ width: `${progressPct}%` }}
240
+ />
241
+ </div>
242
+ </div>
243
+ )}
244
+
245
+ {/* Summary card — shown when done */}
246
+ {done && checks.length > 0 && (
247
+ <div className={`alert ${overallStatus === 'pass' ? 'alert-success' : overallStatus === 'warn' ? 'alert-warning' : 'alert-danger'} d-flex justify-content-between align-items-center mb-4`}>
248
+ <div className="d-flex align-items-center gap-2">
249
+ {overallStatus === 'pass' && <i className="bi bi-check-circle-fill fs-5" />}
250
+ {overallStatus === 'warn' && <i className="bi bi-exclamation-triangle-fill fs-5" />}
251
+ {overallStatus === 'fail' && <i className="bi bi-x-circle-fill fs-5" />}
252
+ <div>
253
+ {overallStatus === 'pass' && <strong>All checks passed.</strong>}
254
+ {overallStatus === 'warn' && <strong>{summary.warnings} warning{summary.warnings > 1 ? 's' : ''} found.</strong>}
255
+ {overallStatus === 'fail' && <strong>{summary.issues} issue{summary.issues > 1 ? 's' : ''} found{summary.warnings > 0 ? `, ${summary.warnings} warning${summary.warnings > 1 ? 's' : ''}` : ''}.</strong>}
256
+ {' '}
257
+ <span className="opacity-75">
258
+ {summary.passes} passed · {summary.issues} failed · {summary.warnings} warnings · {summary.skips} skipped
259
+ </span>
260
+ </div>
261
+ </div>
262
+ <button className="btn btn-sm btn-dark d-flex align-items-center gap-1" onClick={handleAskAssistant}>
263
+ <i className="bi bi-chat-dots-fill" />
264
+ Ask AI Assistant
265
+ </button>
266
+ </div>
267
+ )}
268
+
269
+ {/* Placeholder rows while waiting for full results */}
270
+ {running && placeholders.length > 0 && (
271
+ <div className="card mb-3">
272
+ <div className="card-body p-2">
273
+ {placeholders.map(p => (
274
+ <div key={p.id} className="d-flex align-items-center gap-2 p-2 border-bottom">
275
+ {STATUS_ICON[p.status]}
276
+ <span className="text-muted small">{p.label}</span>
277
+ </div>
278
+ ))}
279
+ </div>
280
+ </div>
281
+ )}
282
+
283
+ {/* Results grouped by layer */}
284
+ {LAYER_ORDER.map(layer => {
285
+ const layerChecks = groups[layer];
286
+ if (!layerChecks || layerChecks.length === 0) return null;
287
+
288
+ const layerStatus: IDiagnosticCheck['status'] = layerChecks.some(c => c.status === 'fail')
289
+ ? 'fail'
290
+ : layerChecks.some(c => c.status === 'warn')
291
+ ? 'warn'
292
+ : layerChecks.some(c => c.status === 'running')
293
+ ? 'running'
294
+ : layerChecks.every(c => c.status === 'skip')
295
+ ? 'skip'
296
+ : 'pass';
297
+
298
+ return (
299
+ <div className="card mb-3" key={layer}>
300
+ <div className="card-header d-flex align-items-center justify-content-between py-2">
301
+ <div className="d-flex align-items-center gap-2">
302
+ {STATUS_ICON[layerStatus]}
303
+ <strong className="small">{layer}</strong>
304
+ </div>
305
+ <span className={`badge ${STATUS_BADGE_CLASS[layerStatus]}`}>
306
+ {layerStatus === 'running' ? 'running' : layerStatus}
307
+ </span>
308
+ </div>
309
+ <div className="list-group list-group-flush">
310
+ {layerChecks.map(check => (
311
+ <div key={check.id} className="list-group-item p-0">
312
+ <div
313
+ className={`d-flex align-items-start gap-2 px-3 py-2 ${check.suggestion ? 'cursor-pointer' : ''}`}
314
+ style={{ cursor: check.suggestion ? 'pointer' : 'default' }}
315
+ onClick={() => check.suggestion && toggleExpand(check.id)}
316
+ role={check.suggestion ? 'button' : undefined}
317
+ >
318
+ <div className="pt-1 flex-shrink-0">{STATUS_ICON[check.status]}</div>
319
+ <div className="flex-grow-1 min-width-0">
320
+ <div className="d-flex justify-content-between align-items-start">
321
+ <span className="small fw-medium">{check.label}</span>
322
+ {check.suggestion && (
323
+ <i className={`bi bi-chevron-${expanded.has(check.id) ? 'up' : 'down'} text-muted ms-2 flex-shrink-0 small`} />
324
+ )}
325
+ </div>
326
+ <div className="small text-muted mt-1">{check.detail}</div>
327
+ </div>
328
+ </div>
329
+ {check.suggestion && expanded.has(check.id) && (
330
+ <div className="px-3 pb-2 pt-0 ms-4">
331
+ <div className="alert alert-info py-2 px-3 mb-0 small d-flex gap-2">
332
+ <i className="bi bi-lightbulb-fill text-info flex-shrink-0 mt-1" />
333
+ <span>{check.suggestion}</span>
334
+ </div>
335
+ </div>
336
+ )}
337
+ </div>
338
+ ))}
339
+ </div>
340
+ </div>
341
+ );
342
+ })}
343
+
344
+ {/* Ask AI Assistant button at bottom (when done, as a secondary option) */}
345
+ {done && checks.length > 0 && (
346
+ <div className="text-center py-3">
347
+ <button className="btn btn-outline-primary" onClick={handleAskAssistant}>
348
+ <i className="bi bi-chat-dots-fill me-2" />
349
+ Ask AI Assistant for Help
350
+ </button>
351
+ <p className="text-muted small mt-2 mb-0">
352
+ Sends the full diagnostic report to the AI assistant for guided troubleshooting.
353
+ </p>
354
+ </div>
355
+ )}
356
+ </div>
357
+ );
358
+ }
@@ -6,3 +6,4 @@ export * from "./network-viewer";
6
6
  export * from "./device-details-modal";
7
7
  export * from "./cpu-usage-graph";
8
8
  export * from "./usage-graph";
9
+ export * from "./connection-troubleshooter";
@@ -7,6 +7,7 @@ import { isDesktop } from '../../globals';
7
7
  import { DeviceDetailsModal } from './device-details-modal';
8
8
  import { GroupDetailsModal } from './group-details-modal';
9
9
  import { UsageGraph } from './usage-graph';
10
+ import { ConnectionTroubleshooter } from './connection-troubleshooter';
10
11
 
11
12
  /** Format bytes to human-readable string (KB, MB, GB) */
12
13
  function formatBytes(bytes: number): string {
@@ -107,6 +108,7 @@ export function NetworkViewerList() {
107
108
  const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
108
109
  const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
109
110
  const [connecting, setConnecting] = useState<Set<string>>(new Set());
111
+ const [showTroubleshooter, setShowTroubleshooter] = useState(false);
110
112
 
111
113
  const loadData = async () => {
112
114
  try {
@@ -244,18 +246,32 @@ export function NetworkViewerList() {
244
246
  );
245
247
  }
246
248
 
249
+ if (showTroubleshooter) {
250
+ return <ConnectionTroubleshooter onBack={() => setShowTroubleshooter(false)} />;
251
+ }
252
+
247
253
  return (
248
254
  <div className="container-fluid p-4">
249
255
  <div className="d-flex justify-content-between align-items-center mb-4">
250
256
  <h2>Network Viewer</h2>
251
- <button
252
- className="btn btn-sm btn-primary"
253
- onClick={handleRefresh}
254
- disabled={refreshing}
255
- >
256
- <i className={`bi bi-arrow-clockwise ${refreshing ? 'spin' : ''}`}></i>
257
- {refreshing ? ' Refreshing...' : ' Refresh'}
258
- </button>
257
+ <div className="d-flex gap-2">
258
+ <button
259
+ className="btn btn-sm btn-outline-warning"
260
+ onClick={() => setShowTroubleshooter(true)}
261
+ title="Run connection diagnostics"
262
+ >
263
+ <i className="bi bi-stethoscope me-1"></i>
264
+ Troubleshoot
265
+ </button>
266
+ <button
267
+ className="btn btn-sm btn-primary"
268
+ onClick={handleRefresh}
269
+ disabled={refreshing}
270
+ >
271
+ <i className={`bi bi-arrow-clockwise ${refreshing ? 'spin' : ''}`}></i>
272
+ {refreshing ? ' Refreshing...' : ' Refresh'}
273
+ </button>
274
+ </div>
259
275
  </div>
260
276
 
261
277
  {/* Device Identifiers */}