@hienlh/ppm 0.13.0 → 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 (51) hide show
  1. package/CHANGELOG.md +30 -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-J5neETTY.js → audio-preview-R7cq1uhJ.js} +1 -1
  5. package/dist/web/assets/chat-tab-umei1UkV.js +12 -0
  6. package/dist/web/assets/{code-editor-tMfcFaQ5.js → code-editor-BTosKXkr.js} +2 -2
  7. package/dist/web/assets/{conflict-editor-FydCxWTC.js → conflict-editor-dzofjxab.js} +1 -1
  8. package/dist/web/assets/{database-viewer-Celi1puH.js → database-viewer-5Uf8Rrls.js} +1 -1
  9. package/dist/web/assets/{diff-viewer-NgDJLTk9.js → diff-viewer-DKLeIBkK.js} +1 -1
  10. package/dist/web/assets/{extension-webview-xWAdCj3q.js → extension-webview-HILvTnnn.js} +1 -1
  11. package/dist/web/assets/{image-preview-C6bFkdZD.js → image-preview-0cJMnFZK.js} +1 -1
  12. package/dist/web/assets/index-Bce0weeW.css +2 -0
  13. package/dist/web/assets/index-DDBvHVVr.js +27 -0
  14. package/dist/web/assets/{markdown-renderer-BAnnk1pI.js → markdown-renderer-D0MrsVJB.js} +1 -1
  15. package/dist/web/assets/{pdf-preview-BNuFTSOL.js → pdf-preview-BBVDS-z5.js} +1 -1
  16. package/dist/web/assets/{port-forwarding-tab-BbDlGxAs.js → port-forwarding-tab-ByKzBs-R.js} +1 -1
  17. package/dist/web/assets/{postgres-viewer-Cman1YRO.js → postgres-viewer-BnCbdR7g.js} +1 -1
  18. package/dist/web/assets/{settings-tab-n5X_Dbu4.js → settings-tab-BPdzUw3v.js} +1 -1
  19. package/dist/web/assets/{sqlite-viewer-D6JT11uu.js → sqlite-viewer-D6mSIIx2.js} +1 -1
  20. package/dist/web/assets/{terminal-tab-B4kMthYo.js → terminal-tab-BLIA53mt.js} +1 -1
  21. package/dist/web/assets/{video-preview-BftQOOzF.js → video-preview-CKaht6nI.js} +1 -1
  22. package/dist/web/index.html +2 -2
  23. package/dist/web/sw.js +1 -1
  24. package/package.json +1 -1
  25. package/src/index.ts +0 -0
  26. package/src/providers/claude-agent-sdk.ts +5 -2
  27. package/src/server/index.ts +12 -4
  28. package/src/server/routes/chat.ts +10 -1
  29. package/src/server/ws/chat.ts +29 -2
  30. package/src/services/autostart-generator.ts +3 -1
  31. package/src/services/autostart-register.ts +17 -0
  32. package/src/services/sd-notify.ts +27 -0
  33. package/src/services/supervisor.ts +31 -5
  34. package/src/types/chat.ts +1 -1
  35. package/src/web/components/chat/chat-history-bar.tsx +2 -9
  36. package/src/web/components/chat/chat-history-panel.tsx +2 -9
  37. package/src/web/components/chat/chat-welcome.tsx +1 -18
  38. package/src/web/components/chat/message-list.tsx +6 -5
  39. package/src/web/components/layout/command-palette.tsx +35 -12
  40. package/src/web/components/layout/draggable-tab.tsx +12 -5
  41. package/src/web/hooks/use-chat.ts +6 -0
  42. package/src/web/hooks/use-notification-badge.ts +7 -7
  43. package/src/web/lib/favicon.ts +37 -15
  44. package/src/web/lib/format-date.ts +21 -0
  45. package/src/web/lib/score-file-search.ts +41 -21
  46. package/src/web/styles/globals.css +12 -0
  47. package/bun.lock +0 -2062
  48. package/bunfig.toml +0 -2
  49. package/dist/web/assets/chat-tab-sVHRa1Fz.js +0 -12
  50. package/dist/web/assets/index-BMhiElt6.css +0 -2
  51. package/dist/web/assets/index-DtbAoxyy.js +0 -23
@@ -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") {
@@ -125,10 +125,12 @@ After=network-online.target
125
125
  Wants=network-online.target
126
126
 
127
127
  [Service]
128
- Type=simple
128
+ Type=notify
129
+ NotifyAccess=all
129
130
  ExecStart=${execStart}
130
131
  Restart=on-failure
131
132
  RestartSec=5
133
+ TimeoutStartSec=60
132
134
  TimeoutStopSec=10
133
135
  KillMode=mixed
134
136
  ${envPath}
@@ -350,4 +350,21 @@ export function getAutoStartStatus(): AutoStartStatus {
350
350
  };
351
351
  }
352
352
 
353
+ /**
354
+ * Detect whether an existing systemd unit file is outdated and needs
355
+ * regeneration. Currently flags units missing Type=notify (introduced to fix
356
+ * the WSL/systemd upgrade-kill bug). Linux-only; returns false elsewhere.
357
+ */
358
+ export function isAutoStartUnitStale(): boolean {
359
+ if (process.platform !== "linux") return false;
360
+ try {
361
+ const path = getServicePath();
362
+ if (!existsSync(path)) return false;
363
+ const content = readFileSync(path, "utf-8");
364
+ return !content.includes("Type=notify");
365
+ } catch {
366
+ return false;
367
+ }
368
+ }
369
+
353
370
  export { loadMetadata, METADATA_FILE };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * sd_notify helper — forwards messages to systemd via the `systemd-notify` binary.
3
+ * No-op on non-systemd platforms (NOTIFY_SOCKET unset).
4
+ *
5
+ * Usage:
6
+ * await sdNotify("READY=1"); // mark unit active (Type=notify)
7
+ * await sdNotify(`MAINPID=${newPid}`); // handoff main process (NotifyAccess=all)
8
+ *
9
+ * Shelling out to `systemd-notify` avoids implementing AF_UNIX SOCK_DGRAM
10
+ * transport in Node/Bun (not supported by node:dgram). The binary ships with
11
+ * systemd itself, so availability matches systemd availability.
12
+ */
13
+ export async function sdNotify(state: string): Promise<void> {
14
+ if (!process.env.NOTIFY_SOCKET) return; // not running under systemd
15
+ try {
16
+ const proc = Bun.spawn({
17
+ cmd: ["systemd-notify", state],
18
+ stdio: ["ignore", "ignore", "ignore"],
19
+ env: process.env,
20
+ });
21
+ await proc.exited;
22
+ } catch {
23
+ // best-effort: if systemd-notify is missing, startup still proceeds
24
+ // (Type=notify units without READY=1 will time out, but that's already
25
+ // the failure mode — this helper doesn't make it worse).
26
+ }
27
+ }
@@ -19,6 +19,7 @@ import {
19
19
  STATUS_FILE, PID_FILE,
20
20
  } from "./supervisor-state.ts";
21
21
  import { startStoppedPage, stopStoppedPage } from "./supervisor-stopped-page.ts";
22
+ import { sdNotify } from "./sd-notify.ts";
22
23
 
23
24
  // ─── Constants ─────────────────────────────────────────────────────────
24
25
  const MAX_RESTARTS = 10;
@@ -228,10 +229,22 @@ export async function spawnTunnel(port: number): Promise<void> {
228
229
  return;
229
230
  }
230
231
 
231
- tunnelChild = Bun.spawn(
232
- [bin, "tunnel", "--url", `http://127.0.0.1:${port}`],
233
- { stderr: "pipe", stdout: "ignore", stdin: "ignore" },
234
- );
232
+ // Under systemd, wrap tunnel in a transient user scope so it lives in its
233
+ // own cgroup instead of ppm.service. This prevents systemd from SIGKILLing
234
+ // the tunnel when ppm.service cgroup is torn down during upgrade/restart,
235
+ // preserving the cloudflared trycloudflare URL across the new supervisor.
236
+ // INVOCATION_ID is set by systemd; absence means we're not under systemd.
237
+ const underSystemd = !!process.env.INVOCATION_ID && process.platform === "linux";
238
+ const tunnelCmd = underSystemd
239
+ ? [
240
+ "systemd-run", "--user", "--scope", "--quiet", "--collect",
241
+ "--",
242
+ bin, "tunnel", "--url", `http://127.0.0.1:${port}`,
243
+ ]
244
+ : [bin, "tunnel", "--url", `http://127.0.0.1:${port}`];
245
+
246
+ tunnelChild = Bun.spawn(tunnelCmd, { stderr: "pipe", stdout: "ignore", stdin: "ignore" });
247
+ if (underSystemd) log("INFO", "Tunnel spawned inside transient systemd-run scope (escapes ppm.service cgroup)");
235
248
 
236
249
  try {
237
250
  tunnelUrl = await extractUrlFromStderr(tunnelChild.stderr as ReadableStream<Uint8Array>);
@@ -481,7 +494,15 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
481
494
  try {
482
495
  const data = JSON.parse(readFileSync(STATUS_FILE(), "utf-8"));
483
496
  if (data.supervisorPid && data.supervisorPid !== currentSupervisorPid) {
484
- log("INFO", `New supervisor detected (PID: ${data.supervisorPid}), old exiting`);
497
+ log("INFO", `New supervisor detected (PID: ${data.supervisorPid}), handing off MainPID to systemd`);
498
+ // Tell systemd the new supervisor is now MainPID — required so that
499
+ // systemd does NOT tear down the ppm.service cgroup when this old
500
+ // supervisor exits 0. Needs NotifyAccess=all in unit file.
501
+ // No-op on non-systemd platforms (NOTIFY_SOCKET unset).
502
+ await sdNotify(`MAINPID=${data.supervisorPid}`);
503
+ // Small delay so systemd processes the datagram before our exit.
504
+ await Bun.sleep(300);
505
+ log("INFO", `Old supervisor exiting`);
485
506
  // Children already killed, just clear remaining timers and exit
486
507
  if (heartbeatTimer) clearInterval(heartbeatTimer);
487
508
  if (upgradeCheckTimer) clearInterval(upgradeCheckTimer);
@@ -919,6 +940,11 @@ export async function runSupervisor(opts: {
919
940
  connectCloud(opts, serverArgs, logFd);
920
941
  startCloudMonitor(opts, serverArgs, logFd);
921
942
 
943
+ // Signal readiness to systemd (Type=notify). No-op on non-systemd platforms.
944
+ // Must happen AFTER signal handlers + status.json are set up so systemd
945
+ // can race-freely promote us to MainPID and forward SIGUSR1/TERM.
946
+ await sdNotify("READY=1");
947
+
922
948
  // Spawn server + tunnel in parallel
923
949
  const promises: Promise<void>[] = [spawnServer(serverArgs, logFd)];
924
950
 
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;
@@ -10,6 +10,7 @@ import { SessionContextMenu } from "./session-context-menu";
10
10
  import { UsageDetailPanel } from "./usage-badge";
11
11
  import { TeamActivityPanel } from "./team-activity-panel";
12
12
  import { ProviderBadge } from "./provider-selector";
13
+ import { formatRelativeDate } from "@/lib/format-date";
13
14
  import type { SessionInfo, SessionListResponse, ProjectTag } from "../../../types/chat";
14
15
  import type { UsageInfo } from "../../../types/chat";
15
16
  import type { TeamMessageItem } from "@/hooks/use-chat";
@@ -40,14 +41,6 @@ interface ChatHistoryBarProps {
40
41
  onTeamOpen?: () => void;
41
42
  }
42
43
 
43
- function formatDate(iso: string): string {
44
- try {
45
- return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
46
- } catch {
47
- return "";
48
- }
49
- }
50
-
51
44
  function relativeTime(iso: string): string {
52
45
  const secs = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
53
46
  if (secs < 5) return "now";
@@ -545,7 +538,7 @@ export function ChatHistoryBar({
545
538
  </>
546
539
  )}
547
540
  {editingId !== session.id && session.updatedAt && (
548
- <span className="text-[10px] text-text-subtle shrink-0 w-10 text-right">{formatDate(session.updatedAt)}</span>
541
+ <span className="text-[10px] text-text-subtle shrink-0 w-16 text-right">{formatRelativeDate(session.updatedAt)}</span>
549
542
  )}
550
543
  </div>
551
544
  </SessionContextMenu>
@@ -2,20 +2,13 @@ import { useEffect, useState, useCallback } from "react";
2
2
  import { MessageSquare, Loader2, RefreshCw } from "lucide-react";
3
3
  import { api, projectUrl } from "@/lib/api-client";
4
4
  import { useTabStore } from "@/stores/tab-store";
5
+ import { formatRelativeDate } from "@/lib/format-date";
5
6
  import type { SessionInfo } from "../../../types/chat";
6
7
 
7
8
  interface ChatHistoryPanelProps {
8
9
  projectName?: string;
9
10
  }
10
11
 
11
- function formatDate(iso: string): string {
12
- try {
13
- return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
14
- } catch {
15
- return "";
16
- }
17
- }
18
-
19
12
  export function ChatHistoryPanel({ projectName }: ChatHistoryPanelProps) {
20
13
  const [sessions, setSessions] = useState<SessionInfo[]>([]);
21
14
  const [loading, setLoading] = useState(false);
@@ -96,7 +89,7 @@ export function ChatHistoryPanel({ projectName }: ChatHistoryPanelProps) {
96
89
  <div className="flex-1 min-w-0">
97
90
  <p className="text-xs font-medium truncate text-text-primary">{session.title || "Untitled"}</p>
98
91
  {session.updatedAt && (
99
- <p className="text-[10px] text-text-subtle">{formatDate(session.updatedAt)}</p>
92
+ <p className="text-[10px] text-text-subtle">{formatRelativeDate(session.updatedAt)}</p>
100
93
  )}
101
94
  </div>
102
95
  </button>
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useCallback } from "react";
2
2
  import { Bot, ChevronDown, ChevronUp, MessageSquare, Pin, PinOff } from "lucide-react";
3
3
  import { api, projectUrl } from "@/lib/api-client";
4
+ import { formatRelativeDate } from "@/lib/format-date";
4
5
  import { useProjectTags, TagChipBar } from "./tag-filter-chips";
5
6
  import { SessionContextMenu } from "./session-context-menu";
6
7
  import type { SessionInfo } from "../../../types/chat";
@@ -8,24 +9,6 @@ import type { SessionInfo } from "../../../types/chat";
8
9
  const MAX_RECENT_SESSIONS = 5;
9
10
  const FETCH_SESSIONS_LIMIT = 20;
10
11
 
11
- function formatRelativeDate(iso: string): string {
12
- try {
13
- const date = new Date(iso);
14
- const now = new Date();
15
- const diffMs = now.getTime() - date.getTime();
16
- const diffMin = Math.floor(diffMs / 60_000);
17
- if (diffMin < 1) return "just now";
18
- if (diffMin < 60) return `${diffMin}m ago`;
19
- const diffHr = Math.floor(diffMin / 60);
20
- if (diffHr < 24) return `${diffHr}h ago`;
21
- const diffDay = Math.floor(diffHr / 24);
22
- if (diffDay < 7) return `${diffDay}d ago`;
23
- return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
24
- } catch {
25
- return "";
26
- }
27
- }
28
-
29
12
  interface ChatWelcomeProps {
30
13
  projectName: string;
31
14
  onSelectSession: (session: SessionInfo) => 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(() => {
@@ -100,13 +100,20 @@ export function DraggableTab({
100
100
  colorStyle && "border-transparent",
101
101
  )}
102
102
  >
103
- {tagColor && (
104
- <span className="size-2 rounded-full shrink-0" style={{ backgroundColor: tagColor }} />
105
- )}
106
- <span className="relative">
103
+ <span
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
+ >
107
109
  <Icon className="size-4" />
108
110
  {isStreaming ? (
109
- <span className="absolute -top-1 -right-1 size-2 rounded-full bg-emerald-500 animate-pulse motion-reduce:animate-none" />
111
+ // Messenger-style typing dots inside chat bubble inherits current icon color (amber while streaming)
112
+ <span aria-hidden className="absolute inset-0 flex items-center justify-center gap-[1.5px]">
113
+ <span className="tab-typing-dot size-[2px] rounded-full bg-current" />
114
+ <span className="tab-typing-dot size-[2px] rounded-full bg-current" style={{ animationDelay: "0.15s" }} />
115
+ <span className="tab-typing-dot size-[2px] rounded-full bg-current" style={{ animationDelay: "0.3s" }} />
116
+ </span>
110
117
  ) : notificationType && !isActive ? (
111
118
  <span className={cn("absolute -top-1 -right-1 size-2 rounded-full", notificationColor(notificationType))} />
112
119
  ) : null}
@@ -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?.();
@@ -3,7 +3,7 @@ import { useNotificationStore, selectTotalUnread } from "@/stores/notification-s
3
3
  import { useProjectStore } from "@/stores/project-store";
4
4
  import { useSettingsStore } from "@/stores/settings-store";
5
5
  import { useStreamingStore, selectAnyStreaming } from "@/stores/streaming-store";
6
- import { setFavicon } from "@/lib/favicon";
6
+ import { setFavicon, STREAM_FRAME_COUNT } from "@/lib/favicon";
7
7
 
8
8
  function buildTitle(unread: number, projectName?: string, deviceName?: string): string {
9
9
  const parts = [projectName, deviceName || null, "PPM"].filter(Boolean).join(" - ");
@@ -30,13 +30,13 @@ export function useNotificationBadge(): void {
30
30
  updateTitle();
31
31
 
32
32
  if (anyStreaming) {
33
- // Alternate favicon between primary (blue) and streaming (amber) every 800ms
34
- let alt = false;
35
- setFavicon(getHasBadge(), false);
33
+ // Cycle through typing-dots frames (3 dots + 1 rest frame, Messenger style) every 300ms
34
+ let frame = 0;
35
+ setFavicon(getHasBadge(), frame);
36
36
  intervalRef.current = setInterval(() => {
37
- alt = !alt;
38
- setFavicon(getHasBadge(), alt);
39
- }, 800);
37
+ frame = (frame + 1) % STREAM_FRAME_COUNT;
38
+ setFavicon(getHasBadge(), frame);
39
+ }, 300);
40
40
  } else {
41
41
  setFavicon(getHasBadge());
42
42
  }
@@ -1,30 +1,52 @@
1
1
  const COLOR_PRIMARY = "#3b82f6";
2
- const COLOR_STREAMING = "#f59e0b"; // amber-500
2
+ const COLOR_STREAMING = "#f59e0b"; // amber-500 — high-attention bg for typing state
3
+ const COLOR_BADGE = "#ef4444";
3
4
 
4
- function buildSvg(bgColor: string, badgeDot: boolean): string {
5
+ /** Idle favicon — "PPM" text on primary background, optional red badge dot. */
6
+ function buildIdleSvg(badgeDot: boolean): string {
5
7
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
6
- <rect width="32" height="32" rx="6" fill="${bgColor}"/>
8
+ <rect width="32" height="32" rx="6" fill="${COLOR_PRIMARY}"/>
7
9
  <text x="16" y="22" text-anchor="middle" font-family="system-ui,sans-serif" font-weight="700" font-size="11" fill="white">PPM</text>
8
- ${badgeDot ? '<circle cx="26" cy="6" r="5" fill="#ef4444"/>' : ""}
10
+ ${badgeDot ? `<circle cx="26" cy="6" r="5" fill="${COLOR_BADGE}"/>` : ""}
9
11
  </svg>`;
10
12
  }
11
13
 
12
- // Pre-encode all 4 variants
13
- export const FAVICON_NORMAL = `data:image/svg+xml,${encodeURIComponent(buildSvg(COLOR_PRIMARY, false))}`;
14
- export const FAVICON_BADGE = `data:image/svg+xml,${encodeURIComponent(buildSvg(COLOR_PRIMARY, true))}`;
15
- const FAVICON_STREAMING = `data:image/svg+xml,${encodeURIComponent(buildSvg(COLOR_STREAMING, false))}`;
16
- const FAVICON_STREAMING_BADGE = `data:image/svg+xml,${encodeURIComponent(buildSvg(COLOR_STREAMING, true))}`;
14
+ /** Streaming favicon — 3 dots (Messenger typing style). `activeDot` (0-2) is raised, -1 = rest. */
15
+ function buildStreamingSvg(activeDot: number, badgeDot: boolean): string {
16
+ const dots = [10, 16, 22].map((cx, i) => {
17
+ const cy = i === activeDot ? 13 : 17;
18
+ return `<circle cx="${cx}" cy="${cy}" r="3" fill="white"/>`;
19
+ }).join("");
20
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
21
+ <rect width="32" height="32" rx="6" fill="${COLOR_STREAMING}"/>
22
+ ${dots}
23
+ ${badgeDot ? `<circle cx="26" cy="6" r="5" fill="${COLOR_BADGE}"/>` : ""}
24
+ </svg>`;
25
+ }
26
+
27
+ const encode = (svg: string) => `data:image/svg+xml,${encodeURIComponent(svg)}`;
28
+
29
+ // Pre-encode all variants: [no-badge, with-badge]
30
+ const FAVICON_IDLE: [string, string] = [encode(buildIdleSvg(false)), encode(buildIdleSvg(true))];
31
+ // 4 frames: dot0 up, dot1 up, dot2 up, all rest (-1) — rest frame makes cycle boundary perceptible
32
+ const FAVICON_STREAM: [string, string][] = [0, 1, 2, -1].map((f) =>
33
+ [encode(buildStreamingSvg(f, false)), encode(buildStreamingSvg(f, true))] as [string, string],
34
+ );
35
+ export const STREAM_FRAME_COUNT = FAVICON_STREAM.length;
17
36
 
18
37
  /**
19
- * Swap favicon. When `isStreamingAlt` is true, uses amber color to
20
- * create an alternation effect (caller toggles this on an interval).
38
+ * Swap favicon.
39
+ * @param hasBadge true if unread notifications exist
40
+ * @param streamingFrame — 0..STREAM_FRAME_COUNT-1 for typing-dots animation, null for idle
21
41
  */
22
- export function setFavicon(hasBadge: boolean, isStreamingAlt = false): void {
42
+ export function setFavicon(hasBadge: boolean, streamingFrame: number | null = null): void {
23
43
  const el = document.getElementById("ppm-favicon") as HTMLLinkElement | null;
24
44
  if (!el) return;
25
- if (isStreamingAlt) {
26
- el.href = hasBadge ? FAVICON_STREAMING_BADGE : FAVICON_STREAMING;
45
+ const badgeIdx = hasBadge ? 1 : 0;
46
+ if (streamingFrame === null) {
47
+ el.href = FAVICON_IDLE[badgeIdx];
27
48
  } else {
28
- el.href = hasBadge ? FAVICON_BADGE : FAVICON_NORMAL;
49
+ const frame = ((streamingFrame % STREAM_FRAME_COUNT) + STREAM_FRAME_COUNT) % STREAM_FRAME_COUNT;
50
+ el.href = FAVICON_STREAM[frame]![badgeIdx];
29
51
  }
30
52
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Format an ISO timestamp as a short English relative time string.
3
+ * Examples: "just now", "5m ago", "3h ago", "2d ago", "Apr 21".
4
+ */
5
+ export function formatRelativeDate(iso: string): string {
6
+ try {
7
+ const date = new Date(iso);
8
+ const now = new Date();
9
+ const diffMs = now.getTime() - date.getTime();
10
+ const diffMin = Math.floor(diffMs / 60_000);
11
+ if (diffMin < 1) return "just now";
12
+ if (diffMin < 60) return `${diffMin}m ago`;
13
+ const diffHr = Math.floor(diffMin / 60);
14
+ if (diffHr < 24) return `${diffHr}h ago`;
15
+ const diffDay = Math.floor(diffHr / 24);
16
+ if (diffDay < 7) return `${diffDay}d ago`;
17
+ return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
18
+ } catch {
19
+ return "";
20
+ }
21
+ }