@hienlh/ppm 0.10.1 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/web/assets/chat-tab-DpfwSKRB.js +10 -0
- package/dist/web/assets/{code-editor-J1ScsQ5K.js → code-editor-BB_JdEmM.js} +2 -2
- package/dist/web/assets/{conflict-editor-DHZHw4Za.js → conflict-editor-DWJbUNQd.js} +1 -1
- package/dist/web/assets/{database-viewer-DLcjXG7f.js → database-viewer-Ch30LM68.js} +1 -1
- package/dist/web/assets/{diff-viewer--rfhbBnw.js → diff-viewer-C16rHzAI.js} +1 -1
- package/dist/web/assets/{extension-webview-CYtw55sx.js → extension-webview-CVB5CXIc.js} +1 -1
- package/dist/web/assets/{index-CLnTXXxE.js → index-BIO_fcCU.js} +2 -2
- package/dist/web/assets/{markdown-renderer-Co8Alvhg.js → markdown-renderer-Cl38Rm7-.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-CxnmKm6E.js → port-forwarding-tab-CME4r9GB.js} +1 -1
- package/dist/web/assets/{postgres-viewer-a-QqP5jk.js → postgres-viewer-BNx3BNzV.js} +1 -1
- package/dist/web/assets/{settings-tab-DU14rPtu.js → settings-tab-qzle6GNb.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-xuoaJj1S.js → sqlite-viewer-DKLXjS3g.js} +1 -1
- package/dist/web/assets/{terminal-tab-BmRH_uhb.js → terminal-tab-DJwMCQll.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/web/components/chat/message-input.tsx +99 -76
- package/dist/web/assets/chat-tab-SBNMhk9B.js +0 -10
|
@@ -74,7 +74,11 @@ export const MessageInput = memo(function MessageInput({
|
|
|
74
74
|
providerId,
|
|
75
75
|
onProviderChange,
|
|
76
76
|
}: MessageInputProps) {
|
|
77
|
-
|
|
77
|
+
// Uncontrolled textarea: value lives in DOM + ref, not React state.
|
|
78
|
+
// Only `hasText` state triggers re-renders (empty↔non-empty for send button).
|
|
79
|
+
// This eliminates React re-render on every keystroke — critical for Chromium on iPad.
|
|
80
|
+
const valueRef = useRef(initialValue ?? "");
|
|
81
|
+
const [hasText, setHasText] = useState(() => (initialValue ?? "").trim().length > 0);
|
|
78
82
|
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
|
79
83
|
const [modeSelectorOpen, setModeSelectorOpen] = useState(false);
|
|
80
84
|
const [pendingSend, setPendingSend] = useState(false);
|
|
@@ -88,6 +92,24 @@ export const MessageInput = memo(function MessageInput({
|
|
|
88
92
|
const slashSearchIdRef = useRef(0);
|
|
89
93
|
const fileItemsRef = useRef<FileNode[]>([]);
|
|
90
94
|
const resizeRafRef = useRef(0);
|
|
95
|
+
// Track picker open state to avoid unnecessary parent callbacks per keystroke
|
|
96
|
+
const slashPickerOpenRef = useRef(false);
|
|
97
|
+
const filePickerOpenRef = useRef(false);
|
|
98
|
+
|
|
99
|
+
/** Write value to both textareas + ref + update hasText state */
|
|
100
|
+
const writeTextareas = useCallback((newValue: string) => {
|
|
101
|
+
valueRef.current = newValue;
|
|
102
|
+
if (textareaRef.current) textareaRef.current.value = newValue;
|
|
103
|
+
if (mobileTextareaRef.current) mobileTextareaRef.current.value = newValue;
|
|
104
|
+
setHasText(newValue.trim().length > 0);
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
/** Get the currently visible textarea */
|
|
108
|
+
const getVisibleTextarea = useCallback(() => {
|
|
109
|
+
return window.matchMedia("(min-width: 768px)").matches
|
|
110
|
+
? textareaRef.current
|
|
111
|
+
: mobileTextareaRef.current;
|
|
112
|
+
}, []);
|
|
91
113
|
|
|
92
114
|
// Voice input (Web Speech API)
|
|
93
115
|
const voice = useVoiceInput();
|
|
@@ -96,26 +118,24 @@ export const MessageInput = memo(function MessageInput({
|
|
|
96
118
|
const voiceResultCb = useCallback((text: string) => {
|
|
97
119
|
const prefix = preVoiceTextRef.current;
|
|
98
120
|
const newValue = prefix ? prefix + " " + text : text;
|
|
99
|
-
|
|
121
|
+
writeTextareas(newValue);
|
|
100
122
|
// Auto-resize textarea
|
|
101
123
|
requestAnimationFrame(() => {
|
|
102
|
-
const ta =
|
|
103
|
-
? textareaRef.current
|
|
104
|
-
: mobileTextareaRef.current;
|
|
124
|
+
const ta = getVisibleTextarea();
|
|
105
125
|
if (ta) {
|
|
106
126
|
ta.style.height = "auto";
|
|
107
127
|
ta.style.height = Math.min(ta.scrollHeight, 160) + "px";
|
|
108
128
|
}
|
|
109
129
|
});
|
|
110
|
-
}, []);
|
|
130
|
+
}, [writeTextareas, getVisibleTextarea]);
|
|
111
131
|
const handleVoiceToggle = useCallback(() => {
|
|
112
132
|
if (voice.isListening) {
|
|
113
133
|
voice.stop();
|
|
114
134
|
} else {
|
|
115
|
-
preVoiceTextRef.current =
|
|
135
|
+
preVoiceTextRef.current = valueRef.current.trim();
|
|
116
136
|
voice.start(voiceResultCb);
|
|
117
137
|
}
|
|
118
|
-
}, [voice.isListening, voice.start, voice.stop,
|
|
138
|
+
}, [voice.isListening, voice.start, voice.stop, voiceResultCb]);
|
|
119
139
|
|
|
120
140
|
// Listen for global keyboard shortcut (Cmd+Shift+V) to toggle voice
|
|
121
141
|
useEffect(() => {
|
|
@@ -127,24 +147,19 @@ export const MessageInput = memo(function MessageInput({
|
|
|
127
147
|
// Apply initialValue when it changes (e.g. "Ask AI" from command palette)
|
|
128
148
|
useEffect(() => {
|
|
129
149
|
if (initialValue) {
|
|
130
|
-
|
|
150
|
+
writeTextareas(initialValue);
|
|
131
151
|
// Focus and move cursor to end
|
|
132
152
|
setTimeout(() => {
|
|
133
153
|
const ta = textareaRef.current;
|
|
134
154
|
if (ta) { ta.focus(); ta.selectionStart = ta.selectionEnd = ta.value.length; }
|
|
135
155
|
}, 50);
|
|
136
156
|
}
|
|
137
|
-
}, [initialValue]);
|
|
157
|
+
}, [initialValue]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
138
158
|
|
|
139
159
|
// Auto-focus on mount when requested
|
|
140
160
|
useEffect(() => {
|
|
141
161
|
if (!autoFocus) return;
|
|
142
|
-
setTimeout(() => {
|
|
143
|
-
const ta = window.matchMedia("(min-width: 768px)").matches
|
|
144
|
-
? textareaRef.current
|
|
145
|
-
: mobileTextareaRef.current;
|
|
146
|
-
ta?.focus();
|
|
147
|
-
}, 100);
|
|
162
|
+
setTimeout(() => { getVisibleTextarea()?.focus(); }, 100);
|
|
148
163
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
149
164
|
|
|
150
165
|
// Fetch slash items when projectName changes
|
|
@@ -197,43 +212,44 @@ export const MessageInput = memo(function MessageInput({
|
|
|
197
212
|
// Handle parent selecting a slash item
|
|
198
213
|
useEffect(() => {
|
|
199
214
|
if (!slashSelected) return;
|
|
200
|
-
const el =
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
const
|
|
215
|
+
const el = getVisibleTextarea();
|
|
216
|
+
if (!el) return;
|
|
217
|
+
const text = el.value;
|
|
218
|
+
const cursorPos = el.selectionStart;
|
|
219
|
+
const textBefore = text.slice(0, cursorPos);
|
|
220
|
+
const textAfter = text.slice(cursorPos);
|
|
204
221
|
// Find the /query pattern before cursor and replace it
|
|
205
222
|
const replaced = textBefore.replace(/(?:^|\s)\/\S*$/, (match) => {
|
|
206
223
|
const prefix = match.startsWith("/") ? "" : match[0]; // preserve whitespace
|
|
207
224
|
return `${prefix}/${slashSelected.name} `;
|
|
208
225
|
});
|
|
209
|
-
|
|
210
|
-
setValue(newValue);
|
|
226
|
+
writeTextareas(replaced + textAfter);
|
|
211
227
|
onSlashStateChange?.(false, "");
|
|
228
|
+
slashPickerOpenRef.current = false;
|
|
212
229
|
onFileStateChange?.(false, "");
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
230
|
+
filePickerOpenRef.current = false;
|
|
231
|
+
el.focus();
|
|
232
|
+
setTimeout(() => {
|
|
233
|
+
el.selectionStart = el.selectionEnd = replaced.length;
|
|
234
|
+
}, 0);
|
|
219
235
|
}, [slashSelected]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
220
236
|
|
|
221
237
|
// Handle parent selecting a file
|
|
222
238
|
useEffect(() => {
|
|
223
239
|
if (!fileSelected) return;
|
|
224
|
-
const el =
|
|
240
|
+
const el = getVisibleTextarea();
|
|
225
241
|
if (!el) return;
|
|
226
242
|
|
|
227
|
-
|
|
243
|
+
const text = el.value;
|
|
228
244
|
const cursorPos = el.selectionStart;
|
|
229
|
-
const textBefore =
|
|
230
|
-
const textAfter =
|
|
245
|
+
const textBefore = text.slice(0, cursorPos);
|
|
246
|
+
const textAfter = text.slice(cursorPos);
|
|
231
247
|
// Find the @ trigger before cursor
|
|
232
248
|
const atMatch = textBefore.match(/@(\S*)$/);
|
|
233
249
|
if (atMatch) {
|
|
234
250
|
const start = textBefore.length - atMatch[0].length;
|
|
235
251
|
const newText = textBefore.slice(0, start) + `@${fileSelected.path} ` + textAfter;
|
|
236
|
-
|
|
252
|
+
writeTextareas(newText);
|
|
237
253
|
const newCursorPos = start + fileSelected.path.length + 2; // +2 for @ and space
|
|
238
254
|
setTimeout(() => {
|
|
239
255
|
el.selectionStart = el.selectionEnd = newCursorPos;
|
|
@@ -241,14 +257,15 @@ export const MessageInput = memo(function MessageInput({
|
|
|
241
257
|
}, 0);
|
|
242
258
|
} else {
|
|
243
259
|
// Fallback: append at end
|
|
244
|
-
const newText =
|
|
245
|
-
|
|
260
|
+
const newText = text + `@${fileSelected.path} `;
|
|
261
|
+
writeTextareas(newText);
|
|
246
262
|
setTimeout(() => {
|
|
247
263
|
el.selectionStart = el.selectionEnd = newText.length;
|
|
248
264
|
el.focus();
|
|
249
265
|
}, 0);
|
|
250
266
|
}
|
|
251
267
|
onFileStateChange?.(false, "");
|
|
268
|
+
filePickerOpenRef.current = false;
|
|
252
269
|
}, [fileSelected]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
253
270
|
|
|
254
271
|
// Handle external files dropped on parent (ChatTab)
|
|
@@ -290,7 +307,9 @@ export const MessageInput = memo(function MessageInput({
|
|
|
290
307
|
for (const file of files) {
|
|
291
308
|
if (!isSupportedFile(file)) {
|
|
292
309
|
// Unsupported → insert file name as text
|
|
293
|
-
|
|
310
|
+
const cur = valueRef.current;
|
|
311
|
+
const sep = cur.length > 0 && !cur.endsWith(" ") ? " " : "";
|
|
312
|
+
writeTextareas(cur + sep + file.name);
|
|
294
313
|
continue;
|
|
295
314
|
}
|
|
296
315
|
|
|
@@ -322,7 +341,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
322
341
|
}
|
|
323
342
|
(mobileTextareaRef.current ?? textareaRef.current)?.focus();
|
|
324
343
|
},
|
|
325
|
-
[uploadFile],
|
|
344
|
+
[uploadFile, writeTextareas],
|
|
326
345
|
);
|
|
327
346
|
|
|
328
347
|
const removeAttachment = useCallback((id: string) => {
|
|
@@ -335,7 +354,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
335
354
|
|
|
336
355
|
/** Execute the actual send (called directly or after uploads complete) */
|
|
337
356
|
const executeSend = useCallback(() => {
|
|
338
|
-
const trimmed =
|
|
357
|
+
const trimmed = valueRef.current.trim();
|
|
339
358
|
const readyAttachments = attachments.filter((a) => a.status === "ready");
|
|
340
359
|
if (!trimmed && readyAttachments.length === 0) {
|
|
341
360
|
setPendingSend(false);
|
|
@@ -343,10 +362,12 @@ export const MessageInput = memo(function MessageInput({
|
|
|
343
362
|
}
|
|
344
363
|
|
|
345
364
|
onSlashStateChange?.(false, "");
|
|
365
|
+
slashPickerOpenRef.current = false;
|
|
346
366
|
onFileStateChange?.(false, "");
|
|
367
|
+
filePickerOpenRef.current = false;
|
|
347
368
|
if (voice.isListening) voice.stop();
|
|
348
369
|
onSend(trimmed, readyAttachments, isStreaming ? priority : undefined);
|
|
349
|
-
|
|
370
|
+
writeTextareas("");
|
|
350
371
|
// Revoke preview URLs
|
|
351
372
|
for (const att of attachments) {
|
|
352
373
|
if (att.previewUrl) URL.revokeObjectURL(att.previewUrl);
|
|
@@ -356,14 +377,14 @@ export const MessageInput = memo(function MessageInput({
|
|
|
356
377
|
setPriority('next');
|
|
357
378
|
if (textareaRef.current) textareaRef.current.style.height = "auto";
|
|
358
379
|
if (mobileTextareaRef.current) mobileTextareaRef.current.style.height = "auto";
|
|
359
|
-
}, [
|
|
380
|
+
}, [attachments, onSend, onSlashStateChange, onFileStateChange, isStreaming, priority, writeTextareas]);
|
|
360
381
|
|
|
361
382
|
const handleSend = useCallback(() => {
|
|
362
383
|
if (disabled) return;
|
|
363
384
|
|
|
364
385
|
// If files are still uploading, queue the send for when they finish
|
|
365
386
|
if (attachments.some((a) => a.status === "uploading")) {
|
|
366
|
-
const trimmed =
|
|
387
|
+
const trimmed = valueRef.current.trim();
|
|
367
388
|
if (trimmed || attachments.some((a) => a.status !== "error")) {
|
|
368
389
|
setPendingSend(true);
|
|
369
390
|
}
|
|
@@ -371,7 +392,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
371
392
|
}
|
|
372
393
|
|
|
373
394
|
executeSend();
|
|
374
|
-
}, [
|
|
395
|
+
}, [attachments, disabled, executeSend]);
|
|
375
396
|
|
|
376
397
|
// Auto-send when queued and all uploads complete
|
|
377
398
|
useEffect(() => {
|
|
@@ -434,9 +455,9 @@ export const MessageInput = memo(function MessageInput({
|
|
|
434
455
|
// Cancel pending slash search if any
|
|
435
456
|
if (slashDebounceRef.current) { clearTimeout(slashDebounceRef.current); slashDebounceRef.current = undefined; }
|
|
436
457
|
if (slashRankedRef.current) slashRankedRef.current = false;
|
|
437
|
-
// Close pickers only if they were open (avoid unnecessary parent setState)
|
|
438
|
-
onSlashStateChange?.(false, "");
|
|
439
|
-
onFileStateChange?.(false, "");
|
|
458
|
+
// Close pickers only if they were actually open (avoid unnecessary parent setState)
|
|
459
|
+
if (slashPickerOpenRef.current) { onSlashStateChange?.(false, ""); slashPickerOpenRef.current = false; }
|
|
460
|
+
if (filePickerOpenRef.current) { onFileStateChange?.(false, ""); filePickerOpenRef.current = false; }
|
|
440
461
|
return;
|
|
441
462
|
}
|
|
442
463
|
|
|
@@ -446,7 +467,8 @@ export const MessageInput = memo(function MessageInput({
|
|
|
446
467
|
if (slashMatch && slashItemsRef.current.length > 0) {
|
|
447
468
|
const filter = slashMatch[1] ?? "";
|
|
448
469
|
onSlashStateChange?.(true, filter);
|
|
449
|
-
|
|
470
|
+
slashPickerOpenRef.current = true;
|
|
471
|
+
if (filePickerOpenRef.current) { onFileStateChange?.(false, ""); filePickerOpenRef.current = false; }
|
|
450
472
|
if (filter) fetchSlashSearch(filter);
|
|
451
473
|
return;
|
|
452
474
|
}
|
|
@@ -461,38 +483,43 @@ export const MessageInput = memo(function MessageInput({
|
|
|
461
483
|
const atMatch = textBefore.match(/@(\S*)$/);
|
|
462
484
|
if (atMatch && fileItemsRef.current.length > 0) {
|
|
463
485
|
onFileStateChange?.(true, atMatch[1] ?? "");
|
|
464
|
-
|
|
486
|
+
filePickerOpenRef.current = true;
|
|
487
|
+
if (slashPickerOpenRef.current) { onSlashStateChange?.(false, ""); slashPickerOpenRef.current = false; }
|
|
465
488
|
return;
|
|
466
489
|
}
|
|
467
490
|
}
|
|
468
491
|
|
|
469
|
-
// Nothing matched — close both pickers
|
|
470
|
-
onSlashStateChange?.(false, "");
|
|
471
|
-
onFileStateChange?.(false, "");
|
|
492
|
+
// Nothing matched — close both pickers (only if open)
|
|
493
|
+
if (slashPickerOpenRef.current) { onSlashStateChange?.(false, ""); slashPickerOpenRef.current = false; }
|
|
494
|
+
if (filePickerOpenRef.current) { onFileStateChange?.(false, ""); filePickerOpenRef.current = false; }
|
|
472
495
|
},
|
|
473
496
|
[onSlashStateChange, onFileStateChange, fetchSlashSearch],
|
|
474
497
|
);
|
|
475
498
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
499
|
+
/** Unified onChange for both textareas — updates ref, syncs other textarea, triggers picker */
|
|
500
|
+
const handleTextareaChange = useCallback(
|
|
501
|
+
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
502
|
+
const el = e.target;
|
|
503
|
+
const text = el.value;
|
|
504
|
+
valueRef.current = text;
|
|
505
|
+
// Sync the other textarea (handles viewport rotation edge case)
|
|
506
|
+
const other = el === textareaRef.current ? mobileTextareaRef.current : textareaRef.current;
|
|
507
|
+
if (other) other.value = text;
|
|
508
|
+
// Only trigger re-render on empty↔non-empty transition (for send button state)
|
|
509
|
+
setHasText(text.trim().length > 0);
|
|
510
|
+
// Update picker state (slash/file autocomplete)
|
|
511
|
+
updatePickerState(text, el.selectionStart);
|
|
512
|
+
// rAF-debounced resize
|
|
513
|
+
if (resizeRafRef.current) cancelAnimationFrame(resizeRafRef.current);
|
|
514
|
+
resizeRafRef.current = requestAnimationFrame(() => {
|
|
515
|
+
resizeRafRef.current = 0;
|
|
516
|
+
el.style.height = "auto";
|
|
517
|
+
el.style.height = Math.min(el.scrollHeight, el === mobileTextareaRef.current ? 80 : 160) + "px";
|
|
518
|
+
});
|
|
480
519
|
},
|
|
481
520
|
[updatePickerState],
|
|
482
521
|
);
|
|
483
522
|
|
|
484
|
-
const handleInput = useCallback((e?: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
485
|
-
const el = e?.target ?? textareaRef.current;
|
|
486
|
-
if (!el) return;
|
|
487
|
-
// Batch height recalculation to avoid sync layout reflow per keystroke
|
|
488
|
-
if (resizeRafRef.current) cancelAnimationFrame(resizeRafRef.current);
|
|
489
|
-
resizeRafRef.current = requestAnimationFrame(() => {
|
|
490
|
-
resizeRafRef.current = 0;
|
|
491
|
-
el.style.height = "auto";
|
|
492
|
-
el.style.height = Math.min(el.scrollHeight, 160) + "px";
|
|
493
|
-
});
|
|
494
|
-
}, []);
|
|
495
|
-
|
|
496
523
|
/** Handle paste — intercept images from clipboard */
|
|
497
524
|
const handlePaste = useCallback(
|
|
498
525
|
(e: ClipboardEvent<HTMLTextAreaElement>) => {
|
|
@@ -543,7 +570,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
543
570
|
[processFiles],
|
|
544
571
|
);
|
|
545
572
|
|
|
546
|
-
const hasContent =
|
|
573
|
+
const hasContent = hasText || attachments.some((a) => a.status !== "error");
|
|
547
574
|
const showCancel = isStreaming && !hasContent;
|
|
548
575
|
|
|
549
576
|
return (
|
|
@@ -555,11 +582,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
555
582
|
if (disabled) return;
|
|
556
583
|
// Only focus when clicking outside the textarea (e.g. padding area)
|
|
557
584
|
if (e.target instanceof HTMLTextAreaElement) return;
|
|
558
|
-
|
|
559
|
-
const ta = window.matchMedia("(min-width: 768px)").matches
|
|
560
|
-
? textareaRef.current
|
|
561
|
-
: mobileTextareaRef.current;
|
|
562
|
-
ta?.focus();
|
|
585
|
+
getVisibleTextarea()?.focus();
|
|
563
586
|
}}
|
|
564
587
|
>
|
|
565
588
|
{/* Attachment chips (inside container, aligned with input) */}
|
|
@@ -598,8 +621,8 @@ export const MessageInput = memo(function MessageInput({
|
|
|
598
621
|
</button>
|
|
599
622
|
<textarea
|
|
600
623
|
ref={mobileTextareaRef}
|
|
601
|
-
|
|
602
|
-
onChange={
|
|
624
|
+
defaultValue={initialValue ?? ""}
|
|
625
|
+
onChange={handleTextareaChange}
|
|
603
626
|
onKeyDown={handleKeyDown}
|
|
604
627
|
onPaste={handlePaste}
|
|
605
628
|
onDrop={handleDrop}
|
|
@@ -648,8 +671,8 @@ export const MessageInput = memo(function MessageInput({
|
|
|
648
671
|
<div className="hidden md:block">
|
|
649
672
|
<textarea
|
|
650
673
|
ref={textareaRef}
|
|
651
|
-
|
|
652
|
-
onChange={
|
|
674
|
+
defaultValue={initialValue ?? ""}
|
|
675
|
+
onChange={handleTextareaChange}
|
|
653
676
|
onKeyDown={handleKeyDown}
|
|
654
677
|
onPaste={handlePaste}
|
|
655
678
|
onDrop={handleDrop}
|