@hienlh/ppm 0.10.1 → 0.10.3

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 (22) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/web/assets/chat-tab-lo46P4ZN.js +10 -0
  3. package/dist/web/assets/{code-editor-J1ScsQ5K.js → code-editor-DN0UiBvk.js} +2 -2
  4. package/dist/web/assets/{conflict-editor-DHZHw4Za.js → conflict-editor-C28xnWqp.js} +1 -1
  5. package/dist/web/assets/{database-viewer-DLcjXG7f.js → database-viewer-DDGq5efK.js} +1 -1
  6. package/dist/web/assets/{diff-viewer--rfhbBnw.js → diff-viewer-DBELqMy0.js} +1 -1
  7. package/dist/web/assets/{extension-webview-CYtw55sx.js → extension-webview--HUG0c_R.js} +1 -1
  8. package/dist/web/assets/{index-CLnTXXxE.js → index-BUxaCPPv.js} +2 -2
  9. package/dist/web/assets/index-Dq7PPmAk.css +2 -0
  10. package/dist/web/assets/{markdown-renderer-Co8Alvhg.js → markdown-renderer-CtsslbMO.js} +1 -1
  11. package/dist/web/assets/{port-forwarding-tab-CxnmKm6E.js → port-forwarding-tab-BszAda9U.js} +1 -1
  12. package/dist/web/assets/{postgres-viewer-a-QqP5jk.js → postgres-viewer-X2li3HfX.js} +1 -1
  13. package/dist/web/assets/{settings-tab-DU14rPtu.js → settings-tab-DR1HhS4C.js} +1 -1
  14. package/dist/web/assets/{sqlite-viewer-xuoaJj1S.js → sqlite-viewer-BV0p6qnR.js} +1 -1
  15. package/dist/web/assets/{terminal-tab-BmRH_uhb.js → terminal-tab-BiRx6kvn.js} +1 -1
  16. package/dist/web/index.html +2 -2
  17. package/dist/web/sw.js +1 -1
  18. package/package.json +1 -1
  19. package/src/web/components/chat/message-input.tsx +121 -87
  20. package/src/web/components/chat/message-list.tsx +1 -1
  21. package/dist/web/assets/chat-tab-SBNMhk9B.js +0 -10
  22. package/dist/web/assets/index-CQJBmJiN.css +0 -2
@@ -74,7 +74,11 @@ export const MessageInput = memo(function MessageInput({
74
74
  providerId,
75
75
  onProviderChange,
76
76
  }: MessageInputProps) {
77
- const [value, setValue] = useState(initialValue ?? "");
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,29 @@ 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
+ // CSS field-sizing: content handles auto-resize natively (Safari 18.2+, Chrome 123+).
99
+ // Only fall back to JS scrollHeight resize when unsupported.
100
+ const needsJsResize = useRef(
101
+ typeof CSS === "undefined" || !CSS.supports("field-sizing", "content"),
102
+ );
103
+
104
+ /** Write value to both textareas + ref + update hasText state */
105
+ const writeTextareas = useCallback((newValue: string) => {
106
+ valueRef.current = newValue;
107
+ if (textareaRef.current) textareaRef.current.value = newValue;
108
+ if (mobileTextareaRef.current) mobileTextareaRef.current.value = newValue;
109
+ setHasText(newValue.trim().length > 0);
110
+ }, []);
111
+
112
+ /** Get the currently visible textarea */
113
+ const getVisibleTextarea = useCallback(() => {
114
+ return window.matchMedia("(min-width: 768px)").matches
115
+ ? textareaRef.current
116
+ : mobileTextareaRef.current;
117
+ }, []);
91
118
 
92
119
  // Voice input (Web Speech API)
93
120
  const voice = useVoiceInput();
@@ -96,26 +123,26 @@ export const MessageInput = memo(function MessageInput({
96
123
  const voiceResultCb = useCallback((text: string) => {
97
124
  const prefix = preVoiceTextRef.current;
98
125
  const newValue = prefix ? prefix + " " + text : text;
99
- setValue(newValue);
100
- // Auto-resize textarea
101
- requestAnimationFrame(() => {
102
- const ta = window.matchMedia("(min-width: 768px)").matches
103
- ? textareaRef.current
104
- : mobileTextareaRef.current;
105
- if (ta) {
106
- ta.style.height = "auto";
107
- ta.style.height = Math.min(ta.scrollHeight, 160) + "px";
108
- }
109
- });
110
- }, []);
126
+ writeTextareas(newValue);
127
+ // Auto-resize textarea (only when CSS field-sizing is unsupported)
128
+ if (needsJsResize.current) {
129
+ requestAnimationFrame(() => {
130
+ const ta = getVisibleTextarea();
131
+ if (ta) {
132
+ ta.style.height = "auto";
133
+ ta.style.height = Math.min(ta.scrollHeight, 160) + "px";
134
+ }
135
+ });
136
+ }
137
+ }, [writeTextareas, getVisibleTextarea]);
111
138
  const handleVoiceToggle = useCallback(() => {
112
139
  if (voice.isListening) {
113
140
  voice.stop();
114
141
  } else {
115
- preVoiceTextRef.current = value.trim();
142
+ preVoiceTextRef.current = valueRef.current.trim();
116
143
  voice.start(voiceResultCb);
117
144
  }
118
- }, [voice.isListening, voice.start, voice.stop, value, voiceResultCb]);
145
+ }, [voice.isListening, voice.start, voice.stop, voiceResultCb]);
119
146
 
120
147
  // Listen for global keyboard shortcut (Cmd+Shift+V) to toggle voice
121
148
  useEffect(() => {
@@ -127,24 +154,19 @@ export const MessageInput = memo(function MessageInput({
127
154
  // Apply initialValue when it changes (e.g. "Ask AI" from command palette)
128
155
  useEffect(() => {
129
156
  if (initialValue) {
130
- setValue(initialValue);
157
+ writeTextareas(initialValue);
131
158
  // Focus and move cursor to end
132
159
  setTimeout(() => {
133
160
  const ta = textareaRef.current;
134
161
  if (ta) { ta.focus(); ta.selectionStart = ta.selectionEnd = ta.value.length; }
135
162
  }, 50);
136
163
  }
137
- }, [initialValue]);
164
+ }, [initialValue]); // eslint-disable-line react-hooks/exhaustive-deps
138
165
 
139
166
  // Auto-focus on mount when requested
140
167
  useEffect(() => {
141
168
  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);
169
+ setTimeout(() => { getVisibleTextarea()?.focus(); }, 100);
148
170
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
149
171
 
150
172
  // Fetch slash items when projectName changes
@@ -197,43 +219,44 @@ export const MessageInput = memo(function MessageInput({
197
219
  // Handle parent selecting a slash item
198
220
  useEffect(() => {
199
221
  if (!slashSelected) return;
200
- const el = textareaRef.current;
201
- const cursorPos = el?.selectionStart ?? value.length;
202
- const textBefore = value.slice(0, cursorPos);
203
- const textAfter = value.slice(cursorPos);
222
+ const el = getVisibleTextarea();
223
+ if (!el) return;
224
+ const text = el.value;
225
+ const cursorPos = el.selectionStart;
226
+ const textBefore = text.slice(0, cursorPos);
227
+ const textAfter = text.slice(cursorPos);
204
228
  // Find the /query pattern before cursor and replace it
205
229
  const replaced = textBefore.replace(/(?:^|\s)\/\S*$/, (match) => {
206
230
  const prefix = match.startsWith("/") ? "" : match[0]; // preserve whitespace
207
231
  return `${prefix}/${slashSelected.name} `;
208
232
  });
209
- const newValue = replaced + textAfter;
210
- setValue(newValue);
233
+ writeTextareas(replaced + textAfter);
211
234
  onSlashStateChange?.(false, "");
235
+ slashPickerOpenRef.current = false;
212
236
  onFileStateChange?.(false, "");
213
- if (el) {
214
- el.focus();
215
- setTimeout(() => {
216
- el.selectionStart = el.selectionEnd = replaced.length;
217
- }, 0);
218
- }
237
+ filePickerOpenRef.current = false;
238
+ el.focus();
239
+ setTimeout(() => {
240
+ el.selectionStart = el.selectionEnd = replaced.length;
241
+ }, 0);
219
242
  }, [slashSelected]); // eslint-disable-line react-hooks/exhaustive-deps
220
243
 
221
244
  // Handle parent selecting a file
222
245
  useEffect(() => {
223
246
  if (!fileSelected) return;
224
- const el = textareaRef.current;
247
+ const el = getVisibleTextarea();
225
248
  if (!el) return;
226
249
 
227
- // Replace the @query with @path
250
+ const text = el.value;
228
251
  const cursorPos = el.selectionStart;
229
- const textBefore = value.slice(0, cursorPos);
230
- const textAfter = value.slice(cursorPos);
252
+ const textBefore = text.slice(0, cursorPos);
253
+ const textAfter = text.slice(cursorPos);
231
254
  // Find the @ trigger before cursor
232
255
  const atMatch = textBefore.match(/@(\S*)$/);
233
256
  if (atMatch) {
234
257
  const start = textBefore.length - atMatch[0].length;
235
258
  const newText = textBefore.slice(0, start) + `@${fileSelected.path} ` + textAfter;
236
- setValue(newText);
259
+ writeTextareas(newText);
237
260
  const newCursorPos = start + fileSelected.path.length + 2; // +2 for @ and space
238
261
  setTimeout(() => {
239
262
  el.selectionStart = el.selectionEnd = newCursorPos;
@@ -241,14 +264,15 @@ export const MessageInput = memo(function MessageInput({
241
264
  }, 0);
242
265
  } else {
243
266
  // Fallback: append at end
244
- const newText = value + `@${fileSelected.path} `;
245
- setValue(newText);
267
+ const newText = text + `@${fileSelected.path} `;
268
+ writeTextareas(newText);
246
269
  setTimeout(() => {
247
270
  el.selectionStart = el.selectionEnd = newText.length;
248
271
  el.focus();
249
272
  }, 0);
250
273
  }
251
274
  onFileStateChange?.(false, "");
275
+ filePickerOpenRef.current = false;
252
276
  }, [fileSelected]); // eslint-disable-line react-hooks/exhaustive-deps
253
277
 
254
278
  // Handle external files dropped on parent (ChatTab)
@@ -290,7 +314,9 @@ export const MessageInput = memo(function MessageInput({
290
314
  for (const file of files) {
291
315
  if (!isSupportedFile(file)) {
292
316
  // Unsupported → insert file name as text
293
- setValue((prev) => prev + (prev.length > 0 && !prev.endsWith(" ") ? " " : "") + file.name);
317
+ const cur = valueRef.current;
318
+ const sep = cur.length > 0 && !cur.endsWith(" ") ? " " : "";
319
+ writeTextareas(cur + sep + file.name);
294
320
  continue;
295
321
  }
296
322
 
@@ -322,7 +348,7 @@ export const MessageInput = memo(function MessageInput({
322
348
  }
323
349
  (mobileTextareaRef.current ?? textareaRef.current)?.focus();
324
350
  },
325
- [uploadFile],
351
+ [uploadFile, writeTextareas],
326
352
  );
327
353
 
328
354
  const removeAttachment = useCallback((id: string) => {
@@ -335,7 +361,7 @@ export const MessageInput = memo(function MessageInput({
335
361
 
336
362
  /** Execute the actual send (called directly or after uploads complete) */
337
363
  const executeSend = useCallback(() => {
338
- const trimmed = value.trim();
364
+ const trimmed = valueRef.current.trim();
339
365
  const readyAttachments = attachments.filter((a) => a.status === "ready");
340
366
  if (!trimmed && readyAttachments.length === 0) {
341
367
  setPendingSend(false);
@@ -343,10 +369,12 @@ export const MessageInput = memo(function MessageInput({
343
369
  }
344
370
 
345
371
  onSlashStateChange?.(false, "");
372
+ slashPickerOpenRef.current = false;
346
373
  onFileStateChange?.(false, "");
374
+ filePickerOpenRef.current = false;
347
375
  if (voice.isListening) voice.stop();
348
376
  onSend(trimmed, readyAttachments, isStreaming ? priority : undefined);
349
- setValue("");
377
+ writeTextareas("");
350
378
  // Revoke preview URLs
351
379
  for (const att of attachments) {
352
380
  if (att.previewUrl) URL.revokeObjectURL(att.previewUrl);
@@ -354,16 +382,18 @@ export const MessageInput = memo(function MessageInput({
354
382
  setAttachments([]);
355
383
  setPendingSend(false);
356
384
  setPriority('next');
357
- if (textareaRef.current) textareaRef.current.style.height = "auto";
358
- if (mobileTextareaRef.current) mobileTextareaRef.current.style.height = "auto";
359
- }, [value, attachments, onSend, onSlashStateChange, onFileStateChange, isStreaming, priority]);
385
+ if (needsJsResize.current) {
386
+ if (textareaRef.current) textareaRef.current.style.height = "auto";
387
+ if (mobileTextareaRef.current) mobileTextareaRef.current.style.height = "auto";
388
+ }
389
+ }, [attachments, onSend, onSlashStateChange, onFileStateChange, isStreaming, priority, writeTextareas]);
360
390
 
361
391
  const handleSend = useCallback(() => {
362
392
  if (disabled) return;
363
393
 
364
394
  // If files are still uploading, queue the send for when they finish
365
395
  if (attachments.some((a) => a.status === "uploading")) {
366
- const trimmed = value.trim();
396
+ const trimmed = valueRef.current.trim();
367
397
  if (trimmed || attachments.some((a) => a.status !== "error")) {
368
398
  setPendingSend(true);
369
399
  }
@@ -371,7 +401,7 @@ export const MessageInput = memo(function MessageInput({
371
401
  }
372
402
 
373
403
  executeSend();
374
- }, [value, attachments, disabled, executeSend]);
404
+ }, [attachments, disabled, executeSend]);
375
405
 
376
406
  // Auto-send when queued and all uploads complete
377
407
  useEffect(() => {
@@ -434,9 +464,9 @@ export const MessageInput = memo(function MessageInput({
434
464
  // Cancel pending slash search if any
435
465
  if (slashDebounceRef.current) { clearTimeout(slashDebounceRef.current); slashDebounceRef.current = undefined; }
436
466
  if (slashRankedRef.current) slashRankedRef.current = false;
437
- // Close pickers only if they were open (avoid unnecessary parent setState)
438
- onSlashStateChange?.(false, "");
439
- onFileStateChange?.(false, "");
467
+ // Close pickers only if they were actually open (avoid unnecessary parent setState)
468
+ if (slashPickerOpenRef.current) { onSlashStateChange?.(false, ""); slashPickerOpenRef.current = false; }
469
+ if (filePickerOpenRef.current) { onFileStateChange?.(false, ""); filePickerOpenRef.current = false; }
440
470
  return;
441
471
  }
442
472
 
@@ -446,7 +476,8 @@ export const MessageInput = memo(function MessageInput({
446
476
  if (slashMatch && slashItemsRef.current.length > 0) {
447
477
  const filter = slashMatch[1] ?? "";
448
478
  onSlashStateChange?.(true, filter);
449
- onFileStateChange?.(false, "");
479
+ slashPickerOpenRef.current = true;
480
+ if (filePickerOpenRef.current) { onFileStateChange?.(false, ""); filePickerOpenRef.current = false; }
450
481
  if (filter) fetchSlashSearch(filter);
451
482
  return;
452
483
  }
@@ -461,38 +492,45 @@ export const MessageInput = memo(function MessageInput({
461
492
  const atMatch = textBefore.match(/@(\S*)$/);
462
493
  if (atMatch && fileItemsRef.current.length > 0) {
463
494
  onFileStateChange?.(true, atMatch[1] ?? "");
464
- onSlashStateChange?.(false, "");
495
+ filePickerOpenRef.current = true;
496
+ if (slashPickerOpenRef.current) { onSlashStateChange?.(false, ""); slashPickerOpenRef.current = false; }
465
497
  return;
466
498
  }
467
499
  }
468
500
 
469
- // Nothing matched — close both pickers
470
- onSlashStateChange?.(false, "");
471
- onFileStateChange?.(false, "");
501
+ // Nothing matched — close both pickers (only if open)
502
+ if (slashPickerOpenRef.current) { onSlashStateChange?.(false, ""); slashPickerOpenRef.current = false; }
503
+ if (filePickerOpenRef.current) { onFileStateChange?.(false, ""); filePickerOpenRef.current = false; }
472
504
  },
473
505
  [onSlashStateChange, onFileStateChange, fetchSlashSearch],
474
506
  );
475
507
 
476
- const handleChange = useCallback(
477
- (text: string, cursorPos: number) => {
478
- setValue(text);
479
- updatePickerState(text, cursorPos);
508
+ /** Unified onChange for both textareas — updates ref, syncs other textarea, triggers picker */
509
+ const handleTextareaChange = useCallback(
510
+ (e: React.ChangeEvent<HTMLTextAreaElement>) => {
511
+ const el = e.target;
512
+ const text = el.value;
513
+ valueRef.current = text;
514
+ // Sync the other textarea (handles viewport rotation edge case)
515
+ const other = el === textareaRef.current ? mobileTextareaRef.current : textareaRef.current;
516
+ if (other) other.value = text;
517
+ // Only trigger re-render on empty↔non-empty transition (for send button state)
518
+ setHasText(text.trim().length > 0);
519
+ // Update picker state (slash/file autocomplete)
520
+ updatePickerState(text, el.selectionStart);
521
+ // JS auto-resize fallback — only when CSS field-sizing: content is unsupported
522
+ if (needsJsResize.current) {
523
+ if (resizeRafRef.current) cancelAnimationFrame(resizeRafRef.current);
524
+ resizeRafRef.current = requestAnimationFrame(() => {
525
+ resizeRafRef.current = 0;
526
+ el.style.height = "auto";
527
+ el.style.height = Math.min(el.scrollHeight, el === mobileTextareaRef.current ? 80 : 160) + "px";
528
+ });
529
+ }
480
530
  },
481
531
  [updatePickerState],
482
532
  );
483
533
 
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
534
  /** Handle paste — intercept images from clipboard */
497
535
  const handlePaste = useCallback(
498
536
  (e: ClipboardEvent<HTMLTextAreaElement>) => {
@@ -543,7 +581,7 @@ export const MessageInput = memo(function MessageInput({
543
581
  [processFiles],
544
582
  );
545
583
 
546
- const hasContent = value.trim().length > 0 || attachments.some((a) => a.status !== "error");
584
+ const hasContent = hasText || attachments.some((a) => a.status !== "error");
547
585
  const showCancel = isStreaming && !hasContent;
548
586
 
549
587
  return (
@@ -555,11 +593,7 @@ export const MessageInput = memo(function MessageInput({
555
593
  if (disabled) return;
556
594
  // Only focus when clicking outside the textarea (e.g. padding area)
557
595
  if (e.target instanceof HTMLTextAreaElement) return;
558
- // Pick the visible textarea based on viewport width
559
- const ta = window.matchMedia("(min-width: 768px)").matches
560
- ? textareaRef.current
561
- : mobileTextareaRef.current;
562
- ta?.focus();
596
+ getVisibleTextarea()?.focus();
563
597
  }}
564
598
  >
565
599
  {/* Attachment chips (inside container, aligned with input) */}
@@ -598,8 +632,8 @@ export const MessageInput = memo(function MessageInput({
598
632
  </button>
599
633
  <textarea
600
634
  ref={mobileTextareaRef}
601
- value={value}
602
- onChange={(e) => { handleChange(e.target.value, e.target.selectionStart); handleInput(e); }}
635
+ defaultValue={initialValue ?? ""}
636
+ onChange={handleTextareaChange}
603
637
  onKeyDown={handleKeyDown}
604
638
  onPaste={handlePaste}
605
639
  onDrop={handleDrop}
@@ -607,7 +641,7 @@ export const MessageInput = memo(function MessageInput({
607
641
  placeholder={isStreaming ? "Follow-up..." : "Ask anything..."}
608
642
  disabled={disabled}
609
643
  rows={1}
610
- className="flex-1 resize-none bg-transparent py-1.5 text-sm text-foreground placeholder:text-text-subtle focus:outline-none disabled:opacity-50 max-h-20"
644
+ className="flex-1 resize-none bg-transparent py-1.5 text-sm text-foreground placeholder:text-text-subtle focus:outline-none disabled:opacity-50 max-h-20 [field-sizing:content]"
611
645
  />
612
646
  {voice.supported && (
613
647
  <button
@@ -648,8 +682,8 @@ export const MessageInput = memo(function MessageInput({
648
682
  <div className="hidden md:block">
649
683
  <textarea
650
684
  ref={textareaRef}
651
- value={value}
652
- onChange={(e) => { handleChange(e.target.value, e.target.selectionStart); handleInput(e); }}
685
+ defaultValue={initialValue ?? ""}
686
+ onChange={handleTextareaChange}
653
687
  onKeyDown={handleKeyDown}
654
688
  onPaste={handlePaste}
655
689
  onDrop={handleDrop}
@@ -657,7 +691,7 @@ export const MessageInput = memo(function MessageInput({
657
691
  placeholder={isStreaming ? "Follow-up or Stop..." : "Ask anything..."}
658
692
  disabled={disabled}
659
693
  rows={1}
660
- className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-sm text-foreground placeholder:text-text-subtle focus:outline-none disabled:opacity-50 max-h-40"
694
+ className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-sm text-foreground placeholder:text-text-subtle focus:outline-none disabled:opacity-50 max-h-40 [field-sizing:content]"
661
695
  />
662
696
  <div className="flex items-center justify-between px-3 pb-2">
663
697
  <div className="flex items-center gap-1">
@@ -117,7 +117,7 @@ export function MessageList({
117
117
 
118
118
  return (
119
119
  <div className="relative flex-1 overflow-hidden flex flex-col min-h-0">
120
- <StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden" resize="smooth" initial="instant">
120
+ <StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden [contain:strict]" resize="smooth" initial="instant">
121
121
  <StickToBottom.Content className="p-4 space-y-4 select-none">
122
122
  {hasMore && (
123
123
  <button onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}