@hienlh/ppm 0.13.2 → 0.13.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 (33) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/{audio-preview--hRMnXRZ.js → audio-preview-R7cq1uhJ.js} +1 -1
  5. package/dist/web/assets/{chat-tab-4kL3DNxf.js → chat-tab-umei1UkV.js} +4 -4
  6. package/dist/web/assets/{code-editor-Caq5_BaF.js → code-editor-BTosKXkr.js} +2 -2
  7. package/dist/web/assets/{conflict-editor-Dlo25nmt.js → conflict-editor-dzofjxab.js} +1 -1
  8. package/dist/web/assets/{database-viewer-DcBl6OkV.js → database-viewer-5Uf8Rrls.js} +1 -1
  9. package/dist/web/assets/{diff-viewer-CCzPq1o-.js → diff-viewer-DKLeIBkK.js} +1 -1
  10. package/dist/web/assets/{extension-webview-D7bGVSEd.js → extension-webview-HILvTnnn.js} +1 -1
  11. package/dist/web/assets/{image-preview-CfkqnhXJ.js → image-preview-0cJMnFZK.js} +1 -1
  12. package/dist/web/assets/index-DDBvHVVr.js +27 -0
  13. package/dist/web/assets/{markdown-renderer-DyAm7zuA.js → markdown-renderer-D0MrsVJB.js} +1 -1
  14. package/dist/web/assets/{pdf-preview-CZPcuy5c.js → pdf-preview-BBVDS-z5.js} +1 -1
  15. package/dist/web/assets/{port-forwarding-tab-3RNozlZ5.js → port-forwarding-tab-ByKzBs-R.js} +1 -1
  16. package/dist/web/assets/{postgres-viewer-CXJv4TXc.js → postgres-viewer-BnCbdR7g.js} +1 -1
  17. package/dist/web/assets/{settings-tab-Cnav4g2u.js → settings-tab-BPdzUw3v.js} +1 -1
  18. package/dist/web/assets/{sqlite-viewer-C8WUEFhA.js → sqlite-viewer-D6mSIIx2.js} +1 -1
  19. package/dist/web/assets/{terminal-tab-CaEsMxp8.js → terminal-tab-BLIA53mt.js} +1 -1
  20. package/dist/web/assets/{video-preview-Dfz71RGb.js → video-preview-CKaht6nI.js} +1 -1
  21. package/dist/web/index.html +1 -1
  22. package/dist/web/sw.js +1 -1
  23. package/package.json +1 -1
  24. package/src/providers/claude-agent-sdk.ts +5 -2
  25. package/src/server/routes/chat.ts +10 -1
  26. package/src/server/ws/chat.ts +29 -2
  27. package/src/types/chat.ts +1 -1
  28. package/src/web/components/chat/message-list.tsx +6 -5
  29. package/src/web/components/layout/command-palette.tsx +35 -12
  30. package/src/web/components/layout/draggable-tab.tsx +5 -5
  31. package/src/web/hooks/use-chat.ts +6 -0
  32. package/src/web/lib/score-file-search.ts +41 -21
  33. package/dist/web/assets/index-BGFG66Gh.js +0 -27
@@ -50,6 +50,8 @@ interface SessionEntry {
50
50
  teamNames: Set<string>;
51
51
  /** toolUseId of a pending TeamCreate call */
52
52
  pendingTeamCreate?: string;
53
+ /** Compact indicator state — sticky until turn ends or boundary received, synced on reconnect */
54
+ compactStatus?: "compacting" | null;
53
55
  }
54
56
 
55
57
  /** Tracks active sessions — persists even when FE disconnects */
@@ -262,8 +264,12 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
262
264
  if (evType === "system") {
263
265
  const sub = (ev as any).subtype;
264
266
  if (sub === "compacting") {
267
+ entry.compactStatus = "compacting";
268
+ console.log(`[chat] session=${sessionId} compact_status=compacting (persisted on entry)`);
265
269
  broadcast(sessionId, { type: "compact_status", status: "compacting" });
266
270
  } else if (sub === "compact_done") {
271
+ entry.compactStatus = null;
272
+ console.log(`[chat] session=${sessionId} compact_status=done (via compact_boundary)`);
267
273
  broadcast(sessionId, { type: "compact_status", status: "done" });
268
274
  }
269
275
  if (!firstEventReceived) {
@@ -415,6 +421,14 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
415
421
  if (evType === "done") {
416
422
  entry.turnEvents = [];
417
423
  entry.pendingApprovalEvent = undefined;
424
+ // Clear stale compact status if turn ended without compact_boundary.
425
+ // SDK may emit `status: compacting` without a matching boundary (deferred,
426
+ // resolved, or errored); without this clear, UI shows stuck "Compacting…".
427
+ if (entry.compactStatus === "compacting") {
428
+ entry.compactStatus = null;
429
+ console.log(`[chat] session=${sessionId} compact_status=done (cleared on turn done without boundary)`);
430
+ broadcast(sessionId, { type: "compact_status", status: "done" });
431
+ }
418
432
  setPhase(sessionId, "idle");
419
433
  // Reset heartbeat tracking for next turn
420
434
  firstEventReceived = false;
@@ -432,6 +446,12 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
432
446
  if (heartbeat) clearInterval(heartbeat);
433
447
  entry.isStreamingActive = false;
434
448
  entry.turnEvents = [];
449
+ // Force-clear compact status on stream teardown (error, close, etc.)
450
+ if (entry.compactStatus === "compacting") {
451
+ entry.compactStatus = null;
452
+ console.log(`[chat] session=${sessionId} compact_status=done (cleared on stream teardown)`);
453
+ broadcast(sessionId, { type: "compact_status", status: "done" });
454
+ }
435
455
  setPhase(sessionId, "idle");
436
456
  entry.pendingApprovalEvent = undefined;
437
457
  // Cleanup bash output spies
@@ -488,6 +508,7 @@ export const chatWebSocket = {
488
508
  phase: existing.phase,
489
509
  pendingApproval: existing.pendingApprovalEvent ?? null,
490
510
  sessionTitle: session?.title || null,
511
+ compactStatus: existing.compactStatus ?? null,
491
512
  }));
492
513
 
493
514
  // If actively streaming, send buffered turn events for reconnect sync
@@ -528,6 +549,7 @@ export const chatWebSocket = {
528
549
  isStreamingActive: false,
529
550
  teamWatchers: new Map(),
530
551
  teamNames: new Set(),
552
+ compactStatus: null,
531
553
  };
532
554
  activeSessions.set(sessionId, newEntry);
533
555
  setupClientPing(newEntry, ws);
@@ -539,6 +561,7 @@ export const chatWebSocket = {
539
561
  phase: "idle",
540
562
  pendingApproval: null,
541
563
  sessionTitle: session?.title || null,
564
+ compactStatus: null,
542
565
  }));
543
566
 
544
567
  // Async: resolve title from SDK if in-memory title is generic (DB title takes priority)
@@ -580,7 +603,7 @@ export const chatWebSocket = {
580
603
  const newEntry: SessionEntry = {
581
604
  providerId: pid, clients: new Set([ws]), projectPath: pp, projectName: pn,
582
605
  pingIntervals: new Map(), phase: "idle", turnEvents: [], isStreamingActive: false,
583
- teamWatchers: new Map(), teamNames: new Set(),
606
+ teamWatchers: new Map(), teamNames: new Set(), compactStatus: null,
584
607
  };
585
608
  activeSessions.set(sessionId, newEntry);
586
609
  setupClientPing(newEntry, ws);
@@ -605,6 +628,7 @@ export const chatWebSocket = {
605
628
  phase: entry.phase,
606
629
  pendingApproval: entry.pendingApprovalEvent ?? null,
607
630
  sessionTitle: chatService.getSession(sessionId)?.title || null,
631
+ compactStatus: entry.compactStatus ?? null,
608
632
  }));
609
633
  if (entry.phase !== "idle") {
610
634
  sendTurnEvents(sessionId, ws);
@@ -705,7 +729,10 @@ export const chatWebSocket = {
705
729
  } else if (parsed.type === "cancel") {
706
730
  // Fully teardown streaming session — user must resume to continue
707
731
  const provider = providerRegistry.get(providerId);
708
- provider?.abortQuery?.(sessionId);
732
+ const phase = entry?.phase ?? "unknown";
733
+ console.log(`[chat] session=${sessionId} WS cancel received from FE (phase=${phase})`);
734
+ logSessionEvent(sessionId, "CANCEL", `WS cancel from FE (phase=${phase})`);
735
+ provider?.abortQuery?.(sessionId, "ws_cancel");
709
736
  } else if (parsed.type === "approval_response") {
710
737
  const provider = providerRegistry.get(providerId);
711
738
  if (provider && typeof provider.resolveApproval === "function") {
package/src/types/chat.ts CHANGED
@@ -24,7 +24,7 @@ export interface AIProvider {
24
24
  // Optional capabilities — providers implement what they support
25
25
  resolveApproval?(requestId: string, approved: boolean, data?: unknown): void;
26
26
  onToolApproval?: (callback: ToolApprovalHandler) => void;
27
- abortQuery?(sessionId: string): void;
27
+ abortQuery?(sessionId: string, source?: string): void;
28
28
  getMessages?(sessionId: string): Promise<ChatMessage[]>;
29
29
  listSessionsByDir?(dir: string, opts?: { limit?: number; offset?: number }): Promise<SessionInfo[]>;
30
30
  ensureProjectPath?(sessionId: string, path: string): void;
@@ -348,7 +348,11 @@ function UserBubble({ content, messageId, projectName, onFork, onExpandCompact,
348
348
  const parsed = parseUserAttachments(content);
349
349
  const { cleanText: noSysTags, tags } = extractSystemTags(parsed.text);
350
350
  const { command, cleanText } = parseCommandTags(noSysTags);
351
- return { files: parsed.files, text: cleanText, tags, command, jsonlPath: extractJsonlPath(cleanText) };
351
+ // Merge command args into body text so line-clamp + Show more applies uniformly
352
+ const bodyText = command?.args
353
+ ? (cleanText ? `${command.args}\n\n${cleanText}` : command.args)
354
+ : cleanText;
355
+ return { files: parsed.files, text: bodyText, tags, command, jsonlPath: extractJsonlPath(cleanText) };
352
356
  }, [content]);
353
357
 
354
358
  // Pre-compact expansion state — local per button instance
@@ -394,16 +398,13 @@ function UserBubble({ content, messageId, projectName, onFork, onExpandCompact,
394
398
  {/* System tags as badges */}
395
399
  {tags.length > 0 && <SystemTagBadges tags={tags} />}
396
400
 
397
- {/* Slash command chip */}
401
+ {/* Slash command chip — args rendered in body for expand/collapse support */}
398
402
  {command && (
399
403
  <div className="flex items-center gap-1.5 mb-0.5">
400
404
  <span className="inline-flex items-center gap-1 rounded-md bg-primary/15 border border-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
401
405
  <Slash className="size-3 shrink-0" />
402
406
  {command.name}
403
407
  </span>
404
- {command.args && (
405
- <span className="text-xs text-text-secondary truncate max-w-80">{command.args}</span>
406
- )}
407
408
  </div>
408
409
  )}
409
410
 
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useRef, useMemo, useCallback } from "react";
1
+ import { useState, useEffect, useRef, useMemo, useCallback, useDeferredValue } from "react";
2
2
  import {
3
3
  Terminal,
4
4
  MessageSquare,
@@ -25,7 +25,10 @@ import { useFileStore, type FileNode } from "@/stores/file-store";
25
25
  import { useExtensionStore } from "@/stores/extension-store";
26
26
  import { api } from "@/lib/api-client";
27
27
  import { basename } from "@/lib/utils";
28
- import { scoreFileSearch, compareScores, type FileSearchScore } from "@/lib/score-file-search";
28
+ import { scoreFileSearchFast, compareScores, getFilename, type FileSearchScore } from "@/lib/score-file-search";
29
+
30
+ /** Max results to display — prevents rendering thousands of matches */
31
+ const MAX_RESULTS = 100;
29
32
 
30
33
  interface CommandItem {
31
34
  id: string;
@@ -108,6 +111,7 @@ const fsCache = new Map<string, string[]>();
108
111
 
109
112
  export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boolean; onClose: () => void; initialQuery?: string }) {
110
113
  const [query, setQuery] = useState("");
114
+ const deferredQuery = useDeferredValue(query);
111
115
  const [selectedIdx, setSelectedIdx] = useState(0);
112
116
  const [fsFiles, setFsFiles] = useState<string[]>([]);
113
117
  const [fsLoading, setFsLoading] = useState(false);
@@ -305,11 +309,29 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
305
309
  [actionCommands, fileCommands],
306
310
  );
307
311
 
312
+ /**
313
+ * Precomputed lowercase search index — avoids re-allocating thousands of
314
+ * lowercased strings per keystroke. Recomputed only when allCommands changes.
315
+ */
316
+ const searchIndex = useMemo(() => {
317
+ return allCommands.map((cmd) => {
318
+ const path = cmd.keywords ?? cmd.label;
319
+ const pathLower = path.toLowerCase();
320
+ return {
321
+ cmd,
322
+ filenameLower: getFilename(pathLower),
323
+ pathLower,
324
+ labelLen: cmd.label.length,
325
+ depth: path.split("/").length,
326
+ };
327
+ });
328
+ }, [allCommands]);
329
+
308
330
  const filtered = useMemo(() => {
309
331
  // Path mode — search filesystem results using filename portion only
310
- if (isPathQuery(query)) {
311
- const lastSlash = query.lastIndexOf("/");
312
- const fileFilter = lastSlash >= 0 ? query.slice(lastSlash + 1).toLowerCase() : "";
332
+ if (isPathQuery(deferredQuery)) {
333
+ const lastSlash = deferredQuery.lastIndexOf("/");
334
+ const fileFilter = lastSlash >= 0 ? deferredQuery.slice(lastSlash + 1).toLowerCase() : "";
313
335
  if (!fileFilter) return fsCommands.slice(0, 50);
314
336
  return fsCommands.filter((c) => {
315
337
  const name = c.label.toLowerCase();
@@ -319,17 +341,18 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
319
341
  }
320
342
 
321
343
  // Normal mode
322
- if (!query.trim()) return actionCommands;
344
+ if (!deferredQuery.trim()) return actionCommands;
345
+ const qLower = deferredQuery.toLowerCase();
323
346
  const scored: Array<{ cmd: CommandItem; score: FileSearchScore }> = [];
324
- for (const c of allCommands) {
325
- const s = scoreFileSearch(query, c.label, c.keywords ?? c.label);
326
- if (s) scored.push({ cmd: c, score: s });
347
+ for (const entry of searchIndex) {
348
+ const s = scoreFileSearchFast(qLower, entry.filenameLower, entry.pathLower, entry.labelLen, entry.depth);
349
+ if (s) scored.push({ cmd: entry.cmd, score: s });
327
350
  }
328
351
  scored.sort((a, b) => compareScores(a.score, b.score));
329
- const matched = scored.map((s) => s.cmd);
352
+ const matched = scored.slice(0, MAX_RESULTS).map((s) => s.cmd);
330
353
  // Prepend DB results (already filtered server-side) when query is 2+ chars
331
- return query.trim().length >= 2 ? [...dbCommands, ...matched] : matched;
332
- }, [allCommands, actionCommands, fsCommands, dbCommands, query]);
354
+ return deferredQuery.trim().length >= 2 ? [...dbCommands, ...matched] : matched;
355
+ }, [searchIndex, actionCommands, fsCommands, dbCommands, deferredQuery]);
333
356
 
334
357
  // Reset state when opening
335
358
  useEffect(() => {
@@ -101,14 +101,14 @@ export function DraggableTab({
101
101
  )}
102
102
  >
103
103
  <span
104
- // No-tag: force neutral gray (overrides parent text-primary for active tabs) so untagged
105
- // tabs don't look like they have a blue tag. Active state is still signaled via border + title color.
106
- className={cn("relative", !tagColor && "text-text-secondary")}
107
- style={tagColor ? { color: tagColor } : undefined}
104
+ // Streaming: force amber (matches favicon streaming bg) so typing state is unmistakable
105
+ // regardless of tab active state. Otherwise inherits parent button's color (primary/text-secondary).
106
+ // Tag identity is now shown as a separate left-edge bar (see wrapper div below), not icon color.
107
+ className={cn("relative", isStreaming && "text-amber-500")}
108
108
  >
109
109
  <Icon className="size-4" />
110
110
  {isStreaming ? (
111
- // Messenger-style typing dots inside chat bubble — inherits current icon color
111
+ // Messenger-style typing dots inside chat bubble — inherits current icon color (amber while streaming)
112
112
  <span aria-hidden className="absolute inset-0 flex items-center justify-center gap-[1.5px]">
113
113
  <span className="tab-typing-dot size-[2px] rounded-full bg-current" />
114
114
  <span className="tab-typing-dot size-[2px] rounded-full bg-current" style={{ animationDelay: "0.15s" }} />
@@ -505,6 +505,9 @@ export function useChat(sessionId: string | null, providerId = "claude", project
505
505
  setPhase(p);
506
506
  phaseRef.current = p;
507
507
  setConnectingElapsed(p === "connecting" ? ((data as any).elapsed ?? 0) : 0);
508
+ // Safety: idle phase means no turn running — ensure compact indicator does not linger.
509
+ // BE should broadcast compact_status=done too, but this is a belt-and-braces clear.
510
+ if (p === "idle") setCompactStatus(null);
508
511
  return;
509
512
  }
510
513
 
@@ -523,6 +526,9 @@ export function useChat(sessionId: string | null, providerId = "claude", project
523
526
  input: state.pendingApproval.input,
524
527
  });
525
528
  }
529
+ // Sync compact indicator from authoritative server state (covers reconnect).
530
+ // state.compactStatus is "compacting" | null — treat undefined as null for back-compat.
531
+ setCompactStatus(state.compactStatus === "compacting" ? "compacting" : null);
526
532
  // If idle, refetch history (completed turns) and hide overlay
527
533
  if (p === "idle") {
528
534
  refetchRef.current?.();
@@ -6,6 +6,10 @@
6
6
  * > path contains(3) > fuzzy filename(4) > fuzzy path(5)
7
7
  *
8
8
  * Tie-breakers: shorter filename, fewer path segments.
9
+ *
10
+ * Hot-path note: callers pass PRE-LOWERCASED strings to avoid repeated
11
+ * allocations per keystroke. Use `scoreFileSearch` (convenience wrapper)
12
+ * for ad-hoc calls; use `scoreFileSearchFast` for the inner loop.
9
13
  */
10
14
 
11
15
  export interface FileSearchScore {
@@ -20,7 +24,7 @@ export interface FileSearchScore {
20
24
  }
21
25
 
22
26
  /** Extract filename from a path */
23
- function getFilename(path: string): string {
27
+ export function getFilename(path: string): string {
24
28
  const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
25
29
  return i >= 0 ? path.slice(i + 1) : path;
26
30
  }
@@ -43,42 +47,58 @@ function fuzzyGap(query: string, text: string): number {
43
47
  return gap;
44
48
  }
45
49
 
46
- export function scoreFileSearch(
47
- query: string,
48
- label: string,
49
- path: string,
50
+ /**
51
+ * Fast scoring — requires pre-lowercased inputs. Use for tight loops.
52
+ * All string params MUST already be lowercase.
53
+ */
54
+ export function scoreFileSearchFast(
55
+ qLower: string,
56
+ filenameLower: string,
57
+ pathLower: string,
58
+ labelLen: number,
59
+ depth: number,
50
60
  ): FileSearchScore | null {
51
- const q = query.toLowerCase();
52
- const nameLower = label.toLowerCase();
53
- const pathLower = path.toLowerCase();
54
- const filename = getFilename(pathLower);
55
- const depth = path.split("/").length;
56
-
57
61
  // Tier 0: exact filename match
58
- if (filename === q) return { tier: 0, offset: 0, nameLen: label.length, depth };
62
+ if (filenameLower === qLower) return { tier: 0, offset: 0, nameLen: labelLen, depth };
59
63
 
60
64
  // Tier 1: filename starts with query
61
- if (filename.startsWith(q)) return { tier: 1, offset: 0, nameLen: label.length, depth };
65
+ if (filenameLower.startsWith(qLower)) return { tier: 1, offset: 0, nameLen: labelLen, depth };
62
66
 
63
67
  // Tier 2: filename contains query as substring
64
- const fnIdx = filename.indexOf(q);
65
- if (fnIdx >= 0) return { tier: 2, offset: fnIdx, nameLen: label.length, depth };
68
+ const fnIdx = filenameLower.indexOf(qLower);
69
+ if (fnIdx >= 0) return { tier: 2, offset: fnIdx, nameLen: labelLen, depth };
66
70
 
67
71
  // Tier 3: full path contains query as substring
68
- const pathIdx = pathLower.indexOf(q);
69
- if (pathIdx >= 0) return { tier: 3, offset: pathIdx, nameLen: label.length, depth };
72
+ const pathIdx = pathLower.indexOf(qLower);
73
+ if (pathIdx >= 0) return { tier: 3, offset: pathIdx, nameLen: labelLen, depth };
70
74
 
71
75
  // Tier 4: fuzzy match on filename
72
- const fnGap = fuzzyGap(q, filename);
73
- if (fnGap >= 0) return { tier: 4, offset: fnGap, nameLen: label.length, depth };
76
+ const fnGap = fuzzyGap(qLower, filenameLower);
77
+ if (fnGap >= 0) return { tier: 4, offset: fnGap, nameLen: labelLen, depth };
74
78
 
75
79
  // Tier 5: fuzzy match on full path
76
- const pathGap = fuzzyGap(q, pathLower);
77
- if (pathGap >= 0) return { tier: 5, offset: pathGap, nameLen: label.length, depth };
80
+ const pathGap = fuzzyGap(qLower, pathLower);
81
+ if (pathGap >= 0) return { tier: 5, offset: pathGap, nameLen: labelLen, depth };
78
82
 
79
83
  return null;
80
84
  }
81
85
 
86
+ /** Convenience wrapper — lowers inputs on the fly. Use for ad-hoc calls. */
87
+ export function scoreFileSearch(
88
+ query: string,
89
+ label: string,
90
+ path: string,
91
+ ): FileSearchScore | null {
92
+ const pathLower = path.toLowerCase();
93
+ return scoreFileSearchFast(
94
+ query.toLowerCase(),
95
+ getFilename(pathLower),
96
+ pathLower,
97
+ label.length,
98
+ path.split("/").length,
99
+ );
100
+ }
101
+
82
102
  /** Compare two scores — for Array.sort (ascending = best first) */
83
103
  export function compareScores(a: FileSearchScore, b: FileSearchScore): number {
84
104
  return (