@hienlh/ppm 0.4.5 → 0.5.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 (60) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bun.lock +3 -0
  3. package/dist/web/assets/api-client-ANLU-Irq.js +1 -0
  4. package/dist/web/assets/chat-tab-C6iTYbRI.js +7 -0
  5. package/dist/web/assets/code-editor-hnDDc8JZ.js +1 -0
  6. package/dist/web/assets/{diff-viewer-B9oX4DDx.js → diff-viewer-BWeMVAvK.js} +1 -1
  7. package/dist/web/assets/git-graph-D6oftHHC.js +1 -0
  8. package/dist/web/assets/index-CWwJBtaO.js +21 -0
  9. package/dist/web/assets/index-jmj5f_bQ.css +2 -0
  10. package/dist/web/assets/{input-AESbQWjx.js → input-D-F4ITU0.js} +1 -1
  11. package/dist/web/assets/jsx-runtime-B4BJKQ1u.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-DdDDhQDx.js → markdown-renderer-PHBaNQ3l.js} +2 -2
  13. package/dist/web/assets/react-WvgCEYPV.js +1 -0
  14. package/dist/web/assets/rotate-ccw-BesidNnx.js +1 -0
  15. package/dist/web/assets/settings-store-CGtTcr8r.js +1 -0
  16. package/dist/web/assets/settings-tab-BpETyigv.js +1 -0
  17. package/dist/web/assets/tab-store-Dq1kMOkJ.js +1 -0
  18. package/dist/web/assets/{terminal-tab-BeFf07MH.js → terminal-tab-BTumEYyO.js} +2 -2
  19. package/dist/web/assets/{use-monaco-theme-Bb9W0CI2.js → use-monaco-theme-CsNwoeyj.js} +1 -1
  20. package/dist/web/index.html +8 -7
  21. package/dist/web/sw.js +1 -1
  22. package/package.json +2 -1
  23. package/src/providers/claude-agent-sdk.ts +36 -13
  24. package/src/server/index.ts +3 -0
  25. package/src/server/routes/chat.ts +31 -3
  26. package/src/server/ws/chat.ts +40 -0
  27. package/src/services/claude-usage.service.ts +51 -23
  28. package/src/types/api.ts +1 -0
  29. package/src/types/chat.ts +1 -0
  30. package/src/web/app.tsx +5 -0
  31. package/src/web/components/chat/chat-history-bar.tsx +15 -3
  32. package/src/web/components/chat/chat-tab.tsx +45 -50
  33. package/src/web/components/chat/message-input.tsx +116 -55
  34. package/src/web/components/chat/message-list.tsx +156 -69
  35. package/src/web/components/chat/usage-badge.tsx +4 -4
  36. package/src/web/components/layout/command-palette.tsx +37 -8
  37. package/src/web/components/layout/draggable-tab.tsx +4 -4
  38. package/src/web/components/layout/mobile-drawer.tsx +2 -2
  39. package/src/web/components/layout/mobile-nav.tsx +3 -2
  40. package/src/web/components/layout/project-bar.tsx +5 -3
  41. package/src/web/components/layout/project-bottom-sheet.tsx +3 -1
  42. package/src/web/components/layout/tab-bar.tsx +4 -4
  43. package/src/web/components/shared/bug-report-popup.tsx +58 -0
  44. package/src/web/hooks/use-chat.ts +63 -7
  45. package/src/web/hooks/use-usage.ts +15 -17
  46. package/src/web/lib/report-bug.ts +12 -3
  47. package/src/web/stores/project-store.ts +7 -1
  48. package/vite.config.ts +2 -0
  49. package/dist/web/assets/api-client-BsHoRDAn.js +0 -1
  50. package/dist/web/assets/chat-tab-Bj1hZQ4x.js +0 -6
  51. package/dist/web/assets/code-editor-Bj9jdnLm.js +0 -1
  52. package/dist/web/assets/copy-BNk4Z75P.js +0 -1
  53. package/dist/web/assets/external-link-CrtbmtJ6.js +0 -1
  54. package/dist/web/assets/git-graph-DoLRBTMk.js +0 -1
  55. package/dist/web/assets/index-C_yeSRZ0.css +0 -2
  56. package/dist/web/assets/index-D27GI6gs.js +0 -21
  57. package/dist/web/assets/jsx-runtime-BFALxl05.js +0 -1
  58. package/dist/web/assets/settings-store-DWYkr_a3.js +0 -1
  59. package/dist/web/assets/settings-tab-BLoiK6Nc.js +0 -1
  60. package/dist/web/assets/tab-store-B1wzyDLQ.js +0 -1
@@ -1,11 +1,11 @@
1
1
  import { useState, useCallback, useRef, useEffect, type DragEvent } from "react";
2
- import { Upload, Bug, Copy, ExternalLink, X } from "lucide-react";
2
+ import { Upload, X } from "lucide-react";
3
3
  import { api, projectUrl } from "@/lib/api-client";
4
4
  import { useChat } from "@/hooks/use-chat";
5
5
  import { useUsage } from "@/hooks/use-usage";
6
6
  import { useTabStore } from "@/stores/tab-store";
7
7
  import { useSettingsStore } from "@/stores/settings-store";
8
- import { buildBugReport, openGithubIssue, copyToClipboard } from "@/lib/report-bug";
8
+ import { openBugReportPopup } from "@/lib/report-bug";
9
9
  import { MessageList } from "./message-list";
10
10
  import { MessageInput, type ChatAttachment } from "./message-input";
11
11
  import { SlashCommandPicker, type SlashItem } from "./slash-command-picker";
@@ -39,10 +39,6 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
39
39
  const [fileFilter, setFileFilter] = useState("");
40
40
  const [fileSelected, setFileSelected] = useState<FileNode | null>(null);
41
41
 
42
- // Bug report popup
43
- const [bugReportText, setBugReportText] = useState<string | null>(null);
44
- const [copied, setCopied] = useState(false);
45
-
46
42
  // Drag-and-drop state
47
43
  const [isDragging, setIsDragging] = useState(false);
48
44
  const [externalFiles, setExternalFiles] = useState<File[] | null>(null);
@@ -54,7 +50,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
54
50
  const version = useSettingsStore((s) => s.version);
55
51
 
56
52
  // Usage runs independently — auto-refreshes on interval
57
- const { usageInfo, usageLoading, lastUpdatedAt, refreshUsage, mergeUsage } =
53
+ const { usageInfo, usageLoading, lastFetchedAt, refreshUsage, mergeUsage } =
58
54
  useUsage(projectName, providerId);
59
55
 
60
56
  // Persist sessionId and providerId to tab metadata so reload restores the session
@@ -69,6 +65,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
69
65
  messages,
70
66
  messagesLoading,
71
67
  isStreaming,
68
+ streamingStatus,
69
+ connectingElapsed,
70
+ thinkingWarningThreshold,
72
71
  pendingApproval,
73
72
  sendMessage,
74
73
  respondToApproval,
@@ -78,6 +77,17 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
78
77
  isConnected,
79
78
  } = useChat(sessionId, providerId, projectName, { onUsageEvent: mergeUsage });
80
79
 
80
+ // Auto-send pending message for forked sessions (set by handleFork)
81
+ const pendingForkMsgRef = useRef(metadata?.pendingMessage as string | undefined);
82
+ useEffect(() => {
83
+ if (pendingForkMsgRef.current && isConnected && sessionId) {
84
+ const msg = pendingForkMsgRef.current;
85
+ pendingForkMsgRef.current = undefined;
86
+ if (tabId) updateTab(tabId, { metadata: { ...metadata, pendingMessage: undefined } });
87
+ setTimeout(() => sendMessage(msg), 100);
88
+ }
89
+ }, [isConnected, sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
90
+
81
91
  const handleNewSession = useCallback(() => {
82
92
  useTabStore.getState().openTab({
83
93
  type: "chat",
@@ -93,6 +103,27 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
93
103
  setProviderId(session.providerId);
94
104
  }, []);
95
105
 
106
+ /** Fork current session and open new tab with the forked session, resending userMessage */
107
+ const handleFork = useCallback(async (userMessage: string) => {
108
+ if (!sessionId || !projectName) return;
109
+ try {
110
+ const { api, projectUrl } = await import("@/lib/api-client");
111
+ const forked = await api.post<{ id: string; forkedFrom: string }>(
112
+ `${projectUrl(projectName)}/chat/sessions/${sessionId}/fork?providerId=${providerId}`,
113
+ );
114
+ // Open new chat tab with forked session — it will send userMessage on connect
115
+ useTabStore.getState().openTab({
116
+ type: "chat",
117
+ title: `Fork: ${userMessage.slice(0, 30)}`,
118
+ metadata: { projectName, sessionId: forked.id, providerId, pendingMessage: userMessage },
119
+ projectId: projectName || null,
120
+ closable: true,
121
+ });
122
+ } catch (e) {
123
+ console.error("Fork failed:", e);
124
+ }
125
+ }, [sessionId, projectName, providerId]);
126
+
96
127
  /** Build message content with file references prepended */
97
128
  const buildMessageWithAttachments = useCallback(
98
129
  (content: string, attachments: ChatAttachment[]): string => {
@@ -238,7 +269,11 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
238
269
  pendingApproval={pendingApproval}
239
270
  onApprovalResponse={respondToApproval}
240
271
  isStreaming={isStreaming}
272
+ streamingStatus={streamingStatus}
273
+ connectingElapsed={connectingElapsed}
274
+ thinkingWarningThreshold={thinkingWarningThreshold}
241
275
  projectName={projectName}
276
+ onFork={!isStreaming ? handleFork : undefined}
242
277
  />
243
278
 
244
279
  {/* Bottom toolbar */}
@@ -249,14 +284,10 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
249
284
  usageInfo={usageInfo}
250
285
  usageLoading={usageLoading}
251
286
  refreshUsage={refreshUsage}
252
- lastUpdatedAt={lastUpdatedAt}
287
+ lastFetchedAt={lastFetchedAt}
253
288
  sessionId={sessionId}
254
289
  onSelectSession={handleSelectSession}
255
- onBugReport={sessionId ? async () => {
256
- const text = await buildBugReport(version, { sessionId, projectName });
257
- setBugReportText(text);
258
- setCopied(false);
259
- } : undefined}
290
+ onBugReport={sessionId ? () => openBugReportPopup(version, { sessionId, projectName }) : undefined}
260
291
  isConnected={isConnected}
261
292
  onReconnect={() => {
262
293
  if (!isConnected) reconnect();
@@ -296,43 +327,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
296
327
  />
297
328
  </div>
298
329
 
299
- {/* Bug report popup */}
300
- {bugReportText && (
301
- <>
302
- <div className="fixed inset-0 z-50 bg-black/50" onClick={() => setBugReportText(null)} />
303
- <div className="fixed inset-x-4 top-[10%] bottom-[10%] z-50 mx-auto max-w-lg flex flex-col rounded-lg border border-border bg-background shadow-xl">
304
- <div className="flex items-center justify-between px-4 py-2 border-b border-border">
305
- <span className="text-sm font-medium">Bug Report</span>
306
- <button onClick={() => setBugReportText(null)} className="p-1 rounded hover:bg-surface-elevated">
307
- <X className="size-4" />
308
- </button>
309
- </div>
310
- <pre className="flex-1 overflow-auto px-4 py-2 text-xs font-mono whitespace-pre-wrap break-all">{bugReportText}</pre>
311
- <div className="flex gap-2 px-4 py-3 border-t border-border">
312
- <button
313
- onClick={async () => {
314
- const ok = await copyToClipboard(bugReportText);
315
- if (ok) setCopied(true);
316
- }}
317
- className="flex-1 flex items-center justify-center gap-2 px-3 py-2 text-sm rounded-lg bg-surface hover:bg-surface-elevated border border-border transition-colors"
318
- >
319
- <Copy className="size-4" />
320
- {copied ? "Copied!" : "Copy"}
321
- </button>
322
- <button
323
- onClick={() => {
324
- openGithubIssue(bugReportText);
325
- setBugReportText(null);
326
- }}
327
- className="flex-1 flex items-center justify-center gap-2 px-3 py-2 text-sm rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
328
- >
329
- <ExternalLink className="size-4" />
330
- GitHub Issue
331
- </button>
332
- </div>
333
- </div>
334
- </>
335
- )}
330
+ {/* Bug report popup is now global — see BugReportPopup in app.tsx */}
336
331
  </div>
337
332
  );
338
333
  }
@@ -1,5 +1,5 @@
1
1
  import { useState, useRef, useCallback, useEffect, type KeyboardEvent, type DragEvent, type ClipboardEvent } from "react";
2
- import { Send, Square, Paperclip } from "lucide-react";
2
+ import { ArrowUp, Square, Paperclip } from "lucide-react";
3
3
  import { api, projectUrl, getAuthToken } from "@/lib/api-client";
4
4
  import { randomId } from "@/lib/utils";
5
5
  import { isSupportedFile, isImageFile } from "@/lib/file-support";
@@ -35,6 +35,8 @@ interface MessageInputProps {
35
35
  fileSelected?: FileNode | null;
36
36
  /** External files added via drag-drop on parent */
37
37
  externalFiles?: File[] | null;
38
+ /** Pre-fill input value (e.g. from command palette "Ask AI") */
39
+ initialValue?: string;
38
40
  }
39
41
 
40
42
  export function MessageInput({
@@ -50,14 +52,27 @@ export function MessageInput({
50
52
  onFileItemsLoaded,
51
53
  fileSelected,
52
54
  externalFiles,
55
+ initialValue,
53
56
  }: MessageInputProps) {
54
- const [value, setValue] = useState("");
57
+ const [value, setValue] = useState(initialValue ?? "");
55
58
  const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
56
59
  const textareaRef = useRef<HTMLTextAreaElement>(null);
57
60
  const fileInputRef = useRef<HTMLInputElement>(null);
58
61
  const slashItemsRef = useRef<SlashItem[]>([]);
59
62
  const fileItemsRef = useRef<FileNode[]>([]);
60
63
 
64
+ // Apply initialValue when it changes (e.g. "Ask AI" from command palette)
65
+ useEffect(() => {
66
+ if (initialValue) {
67
+ setValue(initialValue);
68
+ // Focus and move cursor to end
69
+ setTimeout(() => {
70
+ const ta = textareaRef.current;
71
+ if (ta) { ta.focus(); ta.selectionStart = ta.selectionEnd = ta.value.length; }
72
+ }, 50);
73
+ }
74
+ }, [initialValue]);
75
+
61
76
  // Fetch slash items when projectName changes
62
77
  useEffect(() => {
63
78
  if (!projectName) {
@@ -366,65 +381,111 @@ export function MessageInput({
366
381
  const showCancel = isStreaming && !hasContent;
367
382
 
368
383
  return (
369
- <div className="border-t border-border bg-background">
370
- {/* Attachment chips */}
384
+ <div className="p-2 md:p-3 bg-background">
385
+ {/* Attachment chips (above input) */}
371
386
  <AttachmentChips attachments={attachments} onRemove={removeAttachment} />
372
387
 
373
- {/* Input row */}
374
- <div className="flex items-end gap-2 p-3">
375
- {/* Attach button */}
376
- <button
377
- type="button"
378
- onClick={handleAttachClick}
379
- disabled={disabled}
380
- className="flex items-center justify-center rounded-lg p-2 text-text-subtle hover:text-text-primary hover:bg-surface transition-colors shrink-0 disabled:opacity-50"
381
- aria-label="Attach file"
382
- >
383
- <Paperclip className="size-4" />
384
- </button>
385
- <input
386
- ref={fileInputRef}
387
- type="file"
388
- multiple
389
- className="hidden"
390
- onChange={handleFileInputChange}
391
- />
392
-
393
- <textarea
394
- ref={textareaRef}
395
- value={value}
396
- onChange={(e) => {
397
- handleChange(e.target.value);
398
- handleInput();
399
- }}
400
- onKeyDown={handleKeyDown}
401
- onPaste={handlePaste}
402
- onDrop={handleDrop}
403
- onDragOver={handleDragOver}
404
- placeholder={isStreaming ? "Follow-up or Stop..." : "Message... (↵ to send)"}
405
- disabled={disabled}
406
- rows={1}
407
- className="flex-1 resize-none rounded-lg border border-border bg-surface px-3 py-2 text-base md:text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-ring disabled:opacity-50 max-h-40"
408
- />
409
- {showCancel ? (
388
+ {/* Rounded input container */}
389
+ <div
390
+ className="border border-border rounded-xl md:rounded-2xl bg-surface shadow-sm cursor-text"
391
+ onClick={() => !disabled && textareaRef.current?.focus()}
392
+ >
393
+ {/* Mobile: single row — attach + textarea + send */}
394
+ <div className="flex items-end gap-1 md:hidden px-2 py-2">
410
395
  <button
411
- onClick={onCancel}
412
- className="flex items-center justify-center rounded-lg bg-red-600 p-2 text-white hover:bg-red-500 transition-colors shrink-0"
413
- aria-label="Stop response"
396
+ type="button"
397
+ onClick={(e) => { e.stopPropagation(); handleAttachClick(); }}
398
+ disabled={disabled}
399
+ className="flex items-center justify-center size-7 shrink-0 rounded-full text-text-subtle hover:text-text-primary transition-colors disabled:opacity-50"
400
+ aria-label="Attach file"
414
401
  >
415
- <Square className="size-4" />
402
+ <Paperclip className="size-4" />
416
403
  </button>
417
- ) : (
418
- <button
419
- onClick={handleSend}
420
- disabled={disabled || !hasContent}
421
- className="flex items-center justify-center rounded-lg bg-primary p-2 text-white hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shrink-0"
422
- aria-label="Send message"
423
- >
424
- <Send className="size-4" />
425
- </button>
426
- )}
404
+ <textarea
405
+ ref={textareaRef}
406
+ value={value}
407
+ onChange={(e) => { handleChange(e.target.value); handleInput(); }}
408
+ onKeyDown={handleKeyDown}
409
+ onPaste={handlePaste}
410
+ onDrop={handleDrop}
411
+ onDragOver={handleDragOver}
412
+ placeholder={isStreaming ? "Follow-up..." : "Ask anything..."}
413
+ disabled={disabled}
414
+ rows={1}
415
+ 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-32"
416
+ />
417
+ {showCancel ? (
418
+ <button
419
+ onClick={(e) => { e.stopPropagation(); onCancel?.(); }}
420
+ className="flex items-center justify-center size-7 shrink-0 rounded-full bg-red-600 text-white hover:bg-red-500 transition-colors"
421
+ aria-label="Stop"
422
+ >
423
+ <Square className="size-3" />
424
+ </button>
425
+ ) : (
426
+ <button
427
+ onClick={(e) => { e.stopPropagation(); handleSend(); }}
428
+ disabled={disabled || !hasContent}
429
+ className="flex items-center justify-center size-7 shrink-0 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-30 transition-colors"
430
+ aria-label="Send"
431
+ >
432
+ <ArrowUp className="size-3.5" />
433
+ </button>
434
+ )}
435
+ </div>
436
+
437
+ {/* Desktop: textarea + action bar below */}
438
+ <div className="hidden md:block">
439
+ <textarea
440
+ ref={textareaRef}
441
+ value={value}
442
+ onChange={(e) => { handleChange(e.target.value); handleInput(); }}
443
+ onKeyDown={handleKeyDown}
444
+ onPaste={handlePaste}
445
+ onDrop={handleDrop}
446
+ onDragOver={handleDragOver}
447
+ placeholder={isStreaming ? "Follow-up or Stop..." : "Ask anything..."}
448
+ disabled={disabled}
449
+ rows={1}
450
+ 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"
451
+ />
452
+ <div className="flex items-center justify-between px-3 pb-2">
453
+ <div className="flex items-center gap-1">
454
+ <button
455
+ type="button"
456
+ onClick={(e) => { e.stopPropagation(); handleAttachClick(); }}
457
+ disabled={disabled}
458
+ className="flex items-center justify-center size-8 rounded-full text-text-subtle hover:text-text-primary hover:bg-surface-elevated transition-colors disabled:opacity-50"
459
+ aria-label="Attach file"
460
+ >
461
+ <Paperclip className="size-4" />
462
+ </button>
463
+ </div>
464
+ <div className="flex items-center gap-1">
465
+ {showCancel ? (
466
+ <button
467
+ onClick={(e) => { e.stopPropagation(); onCancel?.(); }}
468
+ className="flex items-center justify-center size-8 rounded-full bg-red-600 text-white hover:bg-red-500 transition-colors"
469
+ aria-label="Stop response"
470
+ >
471
+ <Square className="size-3.5" />
472
+ </button>
473
+ ) : (
474
+ <button
475
+ onClick={(e) => { e.stopPropagation(); handleSend(); }}
476
+ disabled={disabled || !hasContent}
477
+ className="flex items-center justify-center size-8 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
478
+ aria-label="Send message"
479
+ >
480
+ <ArrowUp className="size-4" />
481
+ </button>
482
+ )}
483
+ </div>
484
+ </div>
485
+ </div>
427
486
  </div>
487
+
488
+ <input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileInputChange} />
428
489
  </div>
429
490
  );
430
491
  }