@hienlh/ppm 0.12.12 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/README.md +11 -0
  3. package/assets/skills/ppm/SKILL.md +74 -0
  4. package/assets/skills/ppm/references/cli-reference.md +728 -0
  5. package/assets/skills/ppm/references/common-tasks.md +139 -0
  6. package/assets/skills/ppm/references/http-api.md +204 -0
  7. package/dist/web/assets/ai-settings-section-QE6nBNgN.js +1 -0
  8. package/dist/web/assets/{api-settings-C3T95dWg.js → api-settings-DAk7D-NP.js} +1 -1
  9. package/dist/web/assets/architecture-PBZL5I3N-DvZbltvY.js +1 -0
  10. package/dist/web/assets/{audio-preview-BkbgGtDH.js → audio-preview--hRMnXRZ.js} +1 -1
  11. package/dist/web/assets/chat-tab-4kL3DNxf.js +12 -0
  12. package/dist/web/assets/{code-editor-BtspASkW.js → code-editor-Caq5_BaF.js} +4 -4
  13. package/dist/web/assets/{conflict-editor-Dgsu6fmj.js → conflict-editor-Dlo25nmt.js} +1 -1
  14. package/dist/web/assets/{csv-preview-DcWCjQkZ.js → csv-preview-HMSavgBb.js} +1 -1
  15. package/dist/web/assets/{database-viewer-C85RxdMV.js → database-viewer-DcBl6OkV.js} +2 -2
  16. package/dist/web/assets/{diff-viewer-2pPy97Tl.js → diff-viewer-CCzPq1o-.js} +1 -1
  17. package/dist/web/assets/{esm-_CLpyLJ_.js → esm-K1XIK4vc.js} +1 -1
  18. package/dist/web/assets/{extension-store-BZDZ9QRc.js → extension-store-3yZYn07W.js} +1 -1
  19. package/dist/web/assets/{extension-webview-U1lMYZ0p.js → extension-webview-D7bGVSEd.js} +1 -1
  20. package/dist/web/assets/{file-store-4BpOJthN.js → file-store-BrbCNyLm.js} +1 -1
  21. package/dist/web/assets/gitGraph-HDMCJU4V-BxhdxFgj.js +1 -0
  22. package/dist/web/assets/{image-preview-BcT1SbY2.js → image-preview-CfkqnhXJ.js} +1 -1
  23. package/dist/web/assets/index-BGFG66Gh.js +27 -0
  24. package/dist/web/assets/index-Bce0weeW.css +2 -0
  25. package/dist/web/assets/info-3K5VOQVL-BwAZ2zd8.js +1 -0
  26. package/dist/web/assets/{input-2eDVjcRZ.js → input-Dk49gO8E.js} +1 -1
  27. package/dist/web/assets/{keybindings-store-BOG1yviy.js → keybindings-store-B-zET-0o.js} +1 -1
  28. package/dist/web/assets/keybindings-store-DaBV6qhz.js +1 -0
  29. package/dist/web/assets/{markdown-renderer-Dbam_-04.js → markdown-renderer-DyAm7zuA.js} +3 -3
  30. package/dist/web/assets/packet-RMMSAZCW-tx2n5Qry.js +1 -0
  31. package/dist/web/assets/{pdf-preview-BmHVGx32.js → pdf-preview-CZPcuy5c.js} +1 -1
  32. package/dist/web/assets/pie-UPGHQEXC-D6S2MqVT.js +1 -0
  33. package/dist/web/assets/plus-51UQ45rf.js +1 -0
  34. package/dist/web/assets/{port-forwarding-tab-Dkq1upWC.js → port-forwarding-tab-3RNozlZ5.js} +1 -1
  35. package/dist/web/assets/{postgres-viewer-BgBJAJ9q.js → postgres-viewer-CXJv4TXc.js} +3 -3
  36. package/dist/web/assets/radar-KQ55EAFF-BviZcL-b.js +1 -0
  37. package/dist/web/assets/{scroll-area-CdxNNnN-.js → scroll-area-BEllam7_.js} +1 -1
  38. package/dist/web/assets/{settings-store-CMAssqyb.js → settings-store-BLLR7ed8.js} +2 -2
  39. package/dist/web/assets/settings-tab-Cnav4g2u.js +1 -0
  40. package/dist/web/assets/{sql-query-editor-b7zJ8XPp.js → sql-query-editor-CVAnRFbi.js} +1 -1
  41. package/dist/web/assets/{sqlite-viewer-4lLAz1es.js → sqlite-viewer-C8WUEFhA.js} +1 -1
  42. package/dist/web/assets/{tab-store-DNBsLdPn.js → tab-store-B3M9hjho.js} +1 -1
  43. package/dist/web/assets/{terminal-tab-BtnqkN1H.js → terminal-tab-CaEsMxp8.js} +1 -1
  44. package/dist/web/assets/treemap-KZPCXAKY-CM54VdaB.js +1 -0
  45. package/dist/web/assets/{use-blob-url-QX-XajU8.js → use-blob-url-e9uTXjv5.js} +1 -1
  46. package/dist/web/assets/{use-monaco-theme-D68oX3XU.js → use-monaco-theme-BkZDwoVd.js} +1 -1
  47. package/dist/web/assets/{vendor-mermaid-sQS4C_iL.js → vendor-mermaid-Dx86tuVP.js} +2 -2
  48. package/dist/web/assets/{video-preview-CkOKvVLt.js → video-preview-Dfz71RGb.js} +1 -1
  49. package/dist/web/index.html +18 -18
  50. package/dist/web/sw.js +1 -1
  51. package/docs/project-changelog.md +15 -1
  52. package/package.json +3 -3
  53. package/scripts/generate-ppm-skill.ts +23 -0
  54. package/scripts/lib/generate-cli-reference.ts +81 -0
  55. package/scripts/lib/generate-common-tasks.ts +14 -0
  56. package/scripts/lib/generate-http-api.ts +145 -0
  57. package/scripts/lib/generate-skill-md.ts +28 -0
  58. package/scripts/lib/write-output.ts +17 -0
  59. package/src/cli/commands/export-cmd.ts +85 -0
  60. package/src/index.ts +167 -153
  61. package/src/server/index.ts +12 -4
  62. package/src/services/autostart-generator.ts +3 -1
  63. package/src/services/autostart-register.ts +17 -0
  64. package/src/services/sd-notify.ts +27 -0
  65. package/src/services/skill-export/backup-existing.ts +33 -0
  66. package/src/services/skill-export/copy-bundled-skill.ts +36 -0
  67. package/src/services/skill-export/generate-db-schema.ts +66 -0
  68. package/src/services/skill-export/index.ts +6 -0
  69. package/src/services/skill-export/resolve-assets-dir.ts +31 -0
  70. package/src/services/skill-export/resolve-target-dir.ts +17 -0
  71. package/src/services/supervisor.ts +31 -5
  72. package/src/web/components/chat/chat-history-bar.tsx +2 -9
  73. package/src/web/components/chat/chat-history-panel.tsx +2 -9
  74. package/src/web/components/chat/chat-tab.tsx +6 -1
  75. package/src/web/components/chat/chat-welcome.tsx +1 -18
  76. package/src/web/components/chat/message-list.tsx +96 -43
  77. package/src/web/components/layout/draggable-tab.tsx +12 -5
  78. package/src/web/hooks/use-chat.ts +37 -1
  79. package/src/web/hooks/use-notification-badge.ts +7 -7
  80. package/src/web/lib/favicon.ts +37 -15
  81. package/src/web/lib/flatten-expansions.ts +36 -0
  82. package/src/web/lib/format-date.ts +21 -0
  83. package/src/web/styles/globals.css +12 -0
  84. package/templates/skill/SKILL.md.tmpl +74 -0
  85. package/templates/skill/common-tasks.md +139 -0
  86. package/assets/skills/ppm-guide/SKILL.md +0 -61
  87. package/bun.lock +0 -2062
  88. package/bunfig.toml +0 -2
  89. package/dist/web/assets/ai-settings-section-NNWp6nw7.js +0 -1
  90. package/dist/web/assets/architecture-PBZL5I3N-DDuzYaUV.js +0 -1
  91. package/dist/web/assets/chat-tab-BZlP1qjX.js +0 -12
  92. package/dist/web/assets/chevron-up-BWBvMZkp.js +0 -1
  93. package/dist/web/assets/gitGraph-HDMCJU4V-BURAevTc.js +0 -1
  94. package/dist/web/assets/index-BWSRKVZn.js +0 -23
  95. package/dist/web/assets/index-b6tIZImC.css +0 -2
  96. package/dist/web/assets/info-3K5VOQVL-tSD4Fpi3.js +0 -1
  97. package/dist/web/assets/keybindings-store-BvdUoEC7.js +0 -1
  98. package/dist/web/assets/packet-RMMSAZCW-DmDLZUrV.js +0 -1
  99. package/dist/web/assets/pie-UPGHQEXC-w03Pc9ZR.js +0 -1
  100. package/dist/web/assets/pre-compact-button-Dp7Hs49L.js +0 -1
  101. package/dist/web/assets/pre-compact-section-DnM5fGSR.js +0 -1
  102. package/dist/web/assets/radar-KQ55EAFF-C9XQvoey.js +0 -1
  103. package/dist/web/assets/settings-tab-zYWKTq5z.js +0 -1
  104. package/dist/web/assets/treemap-KZPCXAKY-lmftxSky.js +0 -1
  105. package/scripts/generate-ppm-guide.ts +0 -92
  106. package/src/web/components/chat/pre-compact-section.tsx +0 -69
  107. /package/dist/web/assets/{api-client-DIhJ5qVW.js → api-client-Dvzcc_EO.js} +0 -0
  108. /package/dist/web/assets/{csv-parser-B5QW8pZ6.js → csv-parser--2WJNgS7.js} +0 -0
  109. /package/dist/web/assets/{dist-GtkSekuX.js → dist-im4ynINo.js} +0 -0
  110. /package/dist/web/assets/{katex-C3cZrCvP.js → katex-CKoArbIw.js} +0 -0
  111. /package/dist/web/assets/{lib-Bu71-TFS.js → lib-DQHnkzGy.js} +0 -0
  112. /package/dist/web/assets/{react-DMIOAtcX.js → react-GqWghJ-L.js} +0 -0
  113. /package/dist/web/assets/{refresh-cw-BjrAbUJe.js → refresh-cw-LlbZDJpO.js} +0 -0
  114. /package/dist/web/assets/{sql-completion-provider-CULTsCqR.js → sql-completion-provider-C3cq9j99.js} +0 -0
  115. /package/dist/web/assets/{table-tf7pRkME.js → table-Dq575bPF.js} +0 -0
  116. /package/dist/web/assets/{text-wrap-BV-R4Vvy.js → text-wrap-Cn6BNQfq.js} +0 -0
  117. /package/dist/web/assets/{trash-2-DjQOpgUV.js → trash-2-CJYoLw7Q.js} +0 -0
  118. /package/dist/web/assets/{utils-CQux7CsO.js → utils-CTg5uAYR.js} +0 -0
  119. /package/dist/web/assets/{vendor-xterm-K3_Xwigj.js → vendor-xterm-CU2c3f0A.js} +0 -0
@@ -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
 
@@ -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>
@@ -88,6 +88,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
88
88
 
89
89
  const {
90
90
  messages,
91
+ renderedMessages,
92
+ expandCompact,
93
+ isCompactExpanded,
91
94
  messagesLoading,
92
95
  isStreaming,
93
96
  phase,
@@ -384,7 +387,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
384
387
 
385
388
  {/* Messages */}
386
389
  <MessageList
387
- messages={messages}
390
+ messages={renderedMessages}
391
+ onExpandCompact={expandCompact}
392
+ isCompactExpanded={isCompactExpanded}
388
393
  messagesLoading={messagesLoading}
389
394
  pendingApproval={pendingApproval}
390
395
  onApprovalResponse={respondToApproval}
@@ -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;
@@ -5,10 +5,7 @@ import type { ChatMessage, ChatEvent } from "../../../types/chat";
5
5
  import type { SessionPhase } from "../../../types/api";
6
6
  import type { BashPartialEntry } from "../../hooks/use-chat";
7
7
  import { ToolCard } from "./tool-cards";
8
- import { extractJsonlPath } from "./pre-compact-button";
9
- const PreCompactSection = lazy(() =>
10
- import("./pre-compact-section").then((m) => ({ default: m.PreCompactSection }))
11
- );
8
+ import { extractJsonlPath, PreCompactButton, type PreCompactStatus } from "./pre-compact-button";
12
9
  const MarkdownRenderer = lazy(() =>
13
10
  import("@/components/shared/markdown-renderer").then((m) => ({ default: m.MarkdownRenderer }))
14
11
  );
@@ -59,6 +56,10 @@ interface MessageListProps {
59
56
  onSelectSession?: (session: import("../../../types/chat").SessionInfo) => void;
60
57
  /** Partial bash output ref from useChat for real-time streaming */
61
58
  bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
59
+ /** Fetches pre-compact transcript and prepends messages. Returns loaded count. */
60
+ onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
61
+ /** Whether a given compact message has already been expanded. */
62
+ isCompactExpanded?: (compactMessageId: string) => boolean;
62
63
  }
63
64
 
64
65
  export function MessageList({
@@ -75,6 +76,8 @@ export function MessageList({
75
76
  projectName,
76
77
  onFork,
77
78
  bashPartialOutput,
79
+ onExpandCompact,
80
+ isCompactExpanded,
78
81
  }: MessageListProps) {
79
82
  // Scroll handled by StickToBottom wrapper — no manual scroll logic needed
80
83
 
@@ -106,6 +109,15 @@ export function MessageList({
106
109
  onFork?.(msgContent, msgId);
107
110
  }, [onFork]);
108
111
 
112
+ // Wrap expandCompact: bump visibleCount by loaded count so expansion is immediately visible
113
+ // in the paginated view (pre-compact messages land at top of flattened array, above pagination window).
114
+ const handleExpandCompact = useCallback(async (compactId: string, jsonlPath: string): Promise<number> => {
115
+ if (!onExpandCompact) throw new Error("Expansion not wired");
116
+ const count = await onExpandCompact(compactId, jsonlPath);
117
+ setVisibleCount((c) => c + count);
118
+ return count;
119
+ }, [onExpandCompact]);
120
+
109
121
  if (messagesLoading) {
110
122
  return (
111
123
  <div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
@@ -126,8 +138,8 @@ export function MessageList({
126
138
 
127
139
  return (
128
140
  <div className="relative flex-1 overflow-hidden flex flex-col min-h-0">
129
- <StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden [contain:strict]" resize="smooth" initial="instant">
130
- <StickToBottom.Content className="p-4 space-y-4 select-none">
141
+ <StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden [contain:strict] [overflow-anchor:auto]" resize="smooth" initial="instant">
142
+ <StickToBottom.Content className="p-4 space-y-4 select-none [&>*]:[overflow-anchor:auto]">
131
143
  {hasMore && (
132
144
  <button onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
133
145
  className="w-full py-2 text-xs text-text-secondary hover:text-text-primary bg-surface-elevated/50 hover:bg-surface-elevated rounded-md border border-border/50 transition-colors">
@@ -146,6 +158,8 @@ export function MessageList({
146
158
  onFork={msg.role === "user" && onFork ? handleFork : undefined}
147
159
  prevMsgId={prevMsg?.sdkUuid ?? prevMsg?.id}
148
160
  bashPartialOutput={bashPartialOutput}
161
+ onExpandCompact={handleExpandCompact}
162
+ isCompactExpanded={isCompactExpanded}
149
163
  />
150
164
  );
151
165
  })}
@@ -180,16 +194,25 @@ function ScrollToBottomButton() {
180
194
  );
181
195
  }
182
196
 
183
- const MessageBubble = memo(function MessageBubble({ message, isStreaming, projectName, onFork, prevMsgId, bashPartialOutput }: {
197
+ const MessageBubble = memo(function MessageBubble({ message, isStreaming, projectName, onFork, prevMsgId, bashPartialOutput, onExpandCompact, isCompactExpanded }: {
184
198
  message: ChatMessage; isStreaming: boolean; projectName?: string;
185
199
  onFork?: (content: string, messageId: string | undefined) => void;
186
200
  prevMsgId?: string;
187
201
  bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
202
+ onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
203
+ isCompactExpanded?: (compactMessageId: string) => boolean;
188
204
  }) {
189
205
  if (message.role === "user") {
190
206
  const handleFork = onFork ? () => onFork(message.content, prevMsgId) : undefined;
191
207
  return (
192
- <UserBubble content={message.content} projectName={projectName} onFork={handleFork} />
208
+ <UserBubble
209
+ content={message.content}
210
+ messageId={message.id}
211
+ projectName={projectName}
212
+ onFork={handleFork}
213
+ onExpandCompact={onExpandCompact}
214
+ isCompactExpanded={isCompactExpanded}
215
+ />
193
216
  );
194
217
  }
195
218
 
@@ -206,7 +229,7 @@ const MessageBubble = memo(function MessageBubble({ message, isStreaming, projec
206
229
  return (
207
230
  <div className="flex flex-col gap-2">
208
231
  {message.events && message.events.length > 0
209
- ? <InterleavedEvents events={message.events} isStreaming={isStreaming} projectName={projectName} bashPartialOutput={bashPartialOutput} />
232
+ ? <InterleavedEvents events={message.events} isStreaming={isStreaming} projectName={projectName} bashPartialOutput={bashPartialOutput} messageId={message.id} onExpandCompact={onExpandCompact} isCompactExpanded={isCompactExpanded} />
210
233
  : message.content && (
211
234
  <div className="text-sm text-text-primary select-text">
212
235
  <MarkdownContent content={message.content} projectName={projectName} />
@@ -313,7 +336,14 @@ function isPdfPath(path: string): boolean {
313
336
  const SYSTEM_TAG_NAMES = new Set(["task-notification", "environment_details"]);
314
337
 
315
338
  /** User message bubble — full width, collapsible, with system tag badges */
316
- function UserBubble({ content, projectName, onFork }: { content: string; projectName?: string; onFork?: () => void }) {
339
+ function UserBubble({ content, messageId, projectName, onFork, onExpandCompact, isCompactExpanded }: {
340
+ content: string;
341
+ messageId?: string;
342
+ projectName?: string;
343
+ onFork?: () => void;
344
+ onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
345
+ isCompactExpanded?: (compactMessageId: string) => boolean;
346
+ }) {
317
347
  const { files, text, tags, command, jsonlPath } = useMemo(() => {
318
348
  const parsed = parseUserAttachments(content);
319
349
  const { cleanText: noSysTags, tags } = extractSystemTags(parsed.text);
@@ -321,6 +351,23 @@ function UserBubble({ content, projectName, onFork }: { content: string; project
321
351
  return { files: parsed.files, text: cleanText, tags, command, jsonlPath: extractJsonlPath(cleanText) };
322
352
  }, [content]);
323
353
 
354
+ // Pre-compact expansion state — local per button instance
355
+ const [preCompactStatus, setPreCompactStatus] = useState<PreCompactStatus>(() =>
356
+ messageId && isCompactExpanded?.(messageId) ? "loaded" : "idle",
357
+ );
358
+ const [preCompactCount, setPreCompactCount] = useState<number | undefined>();
359
+ const handleExpand = useCallback(async () => {
360
+ if (!jsonlPath || !messageId || !onExpandCompact) return;
361
+ setPreCompactStatus("loading");
362
+ try {
363
+ const count = await onExpandCompact(messageId, jsonlPath);
364
+ setPreCompactCount(count);
365
+ setPreCompactStatus("loaded");
366
+ } catch {
367
+ setPreCompactStatus("error");
368
+ }
369
+ }, [jsonlPath, messageId, onExpandCompact]);
370
+
324
371
  const isSystemContext = tags.some((t) => SYSTEM_TAG_NAMES.has(t.name));
325
372
 
326
373
  const [expanded, setExpanded] = useState(false);
@@ -403,22 +450,14 @@ function UserBubble({ content, projectName, onFork }: { content: string; project
403
450
  {expanded ? <><ChevronUp className="size-3" />Show less</> : <><ChevronDown className="size-3" />Show more</>}
404
451
  </button>
405
452
  )}
406
- {/* Expand compacted conversation: detect JSONL path in compact summary user message */}
407
- {jsonlPath && (
408
- <Suspense fallback={<div className="mt-2 animate-pulse h-10 bg-surface/50 rounded" />}>
409
- <PreCompactSection
410
- jsonlPath={jsonlPath}
411
- projectName={projectName}
412
- renderMessage={(msg, idx) => (
413
- <MessageBubble
414
- key={msg.id ?? `pc-${idx}`}
415
- message={msg}
416
- isStreaming={false}
417
- projectName={projectName}
418
- />
419
- )}
420
- />
421
- </Suspense>
453
+ {/* Expand compacted conversation: detect JSONL path in compact summary user message.
454
+ Prepends pre-compact messages into main flattened list (see useChat.expandCompact). */}
455
+ {jsonlPath && messageId && onExpandCompact && (
456
+ <PreCompactButton
457
+ status={preCompactStatus}
458
+ onLoad={preCompactStatus === "idle" || preCompactStatus === "error" ? handleExpand : undefined}
459
+ count={preCompactCount}
460
+ />
422
461
  )}
423
462
  {/* Fork/Rewind button — only for real user messages */}
424
463
  {!isSystemContext && onFork && (
@@ -694,7 +733,31 @@ type EventGroup =
694
733
  | { kind: "thinking"; content: string }
695
734
  | { kind: "tool"; tool: ChatEvent; result?: ChatEvent; completed?: boolean };
696
735
 
697
- function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput }: { events: ChatEvent[]; isStreaming: boolean; projectName?: string; bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>> }) {
736
+ function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput, messageId, onExpandCompact, isCompactExpanded }: {
737
+ events: ChatEvent[];
738
+ isStreaming: boolean;
739
+ projectName?: string;
740
+ bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
741
+ messageId?: string;
742
+ onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
743
+ isCompactExpanded?: (compactMessageId: string) => boolean;
744
+ }) {
745
+ // Local state for the /compact slash-command path (assistant-authored summary)
746
+ const [preCompactStatus, setPreCompactStatus] = useState<PreCompactStatus>(() =>
747
+ messageId && isCompactExpanded?.(messageId) ? "loaded" : "idle",
748
+ );
749
+ const [preCompactCount, setPreCompactCount] = useState<number | undefined>();
750
+ const handleExpand = useCallback(async (jsonlPath: string) => {
751
+ if (!messageId || !onExpandCompact) return;
752
+ setPreCompactStatus("loading");
753
+ try {
754
+ const count = await onExpandCompact(messageId, jsonlPath);
755
+ setPreCompactCount(count);
756
+ setPreCompactStatus("loaded");
757
+ } catch {
758
+ setPreCompactStatus("error");
759
+ }
760
+ }, [messageId, onExpandCompact]);
698
761
  // Group: consecutive text → merged text block; tool_use + tool_result paired by toolUseId
699
762
  const groups: EventGroup[] = [];
700
763
  let textBuffer = "";
@@ -813,22 +876,12 @@ function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput
813
876
  return (
814
877
  <div key={`text-${i}`} className="text-sm text-text-primary select-text">
815
878
  <StreamingText content={group.content} animate={isLast} projectName={projectName} />
816
- {jsonlPath && (
817
- <Suspense fallback={<div className="mt-2 animate-pulse h-10 bg-surface/50 rounded" />}>
818
- <PreCompactSection
819
- jsonlPath={jsonlPath}
820
- projectName={projectName}
821
- renderMessage={(msg, idx) => (
822
- <MessageBubble
823
- key={msg.id ?? `pc-${idx}`}
824
- message={msg}
825
- isStreaming={false}
826
- projectName={projectName}
827
- bashPartialOutput={bashPartialOutput}
828
- />
829
- )}
830
- />
831
- </Suspense>
879
+ {jsonlPath && messageId && onExpandCompact && (
880
+ <PreCompactButton
881
+ status={preCompactStatus}
882
+ onLoad={preCompactStatus === "idle" || preCompactStatus === "error" ? () => handleExpand(jsonlPath) : undefined}
883
+ count={preCompactCount}
884
+ />
832
885
  )}
833
886
  </div>
834
887
  );
@@ -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
+ // 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}
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
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}
@@ -1,6 +1,7 @@
1
- import { useState, useCallback, useRef, useEffect } from "react";
1
+ import { useState, useCallback, useRef, useEffect, useMemo } from "react";
2
2
  import { useWebSocket } from "./use-websocket";
3
3
  import { api, getAuthToken, projectUrl } from "@/lib/api-client";
4
+ import { flattenWithExpansions, prefixPreCompactIds } from "@/lib/flatten-expansions";
4
5
  import { useNotificationStore } from "@/stores/notification-store";
5
6
  import { useStreamingStore } from "@/stores/streaming-store";
6
7
  import { usePanelStore } from "@/stores/panel-store";
@@ -41,6 +42,12 @@ export interface BashPartialEntry {
41
42
 
42
43
  interface UseChatReturn {
43
44
  messages: ChatMessage[];
45
+ /** Messages flattened with pre-compact expansions prepended before their compact cards. */
46
+ renderedMessages: ChatMessage[];
47
+ /** Fetch pre-compact transcript and store expansion. Returns loaded message count. */
48
+ expandCompact: (compactMessageId: string, jsonlPath: string) => Promise<number>;
49
+ /** Whether a given compactMessageId has been expanded. */
50
+ isCompactExpanded: (compactMessageId: string) => boolean;
44
51
  messagesLoading: boolean;
45
52
  isStreaming: boolean;
46
53
  phase: SessionPhase;
@@ -79,6 +86,8 @@ function isSessionTabActive(sid: string): boolean {
79
86
 
80
87
  export function useChat(sessionId: string | null, providerId = "claude", projectName = ""): UseChatReturn {
81
88
  const [messages, setMessages] = useState<ChatMessage[]>([]);
89
+ /** Map of compactMessageId → pre-compact messages (already ID-prefixed). Ephemeral. */
90
+ const [expansions, setExpansions] = useState<Map<string, ChatMessage[]>>(new Map());
82
91
  const [messagesLoading, setMessagesLoading] = useState(false);
83
92
  const [phase, setPhase] = useState<SessionPhase>("idle");
84
93
  const [isReconnecting, setIsReconnecting] = useState(false);
@@ -604,6 +613,8 @@ export function useChat(sessionId: string | null, providerId = "claude", project
604
613
  setPendingApproval(null);
605
614
  if (approvalToastRef.current != null) { toast.dismiss(approvalToastRef.current); approvalToastRef.current = null; }
606
615
  setCompactStatus(null);
616
+ // Clear ephemeral pre-compact expansions on session change
617
+ setExpansions(new Map());
607
618
  streamingContentRef.current = "";
608
619
  streamingEventsRef.current = [];
609
620
  bashOutputRef.current.clear();
@@ -796,8 +807,33 @@ export function useChat(sessionId: string | null, providerId = "claude", project
796
807
  // Keep refetchRef in sync
797
808
  refetchRef.current = refetchMessages;
798
809
 
810
+ /** Fetch pre-compact transcript. Idempotent: re-expanding same id replaces entry. */
811
+ const expandCompact = useCallback(async (compactMessageId: string, jsonlPath: string): Promise<number> => {
812
+ if (!projectName) throw new Error("No project context available");
813
+ const url = `${projectUrl(projectName)}/chat/pre-compact-messages?jsonlPath=${encodeURIComponent(jsonlPath)}`;
814
+ const loaded = await api.get<ChatMessage[]>(url);
815
+ const prefixed = prefixPreCompactIds(loaded, jsonlPath);
816
+ setExpansions((prev) => {
817
+ const next = new Map(prev);
818
+ next.set(compactMessageId, prefixed);
819
+ return next;
820
+ });
821
+ return prefixed.length;
822
+ }, [projectName]);
823
+
824
+ const isCompactExpanded = useCallback((id: string) => expansions.has(id), [expansions]);
825
+
826
+ /** Flattened view: expansions prepended before their compact cards. */
827
+ const renderedMessages = useMemo(
828
+ () => flattenWithExpansions(messages, expansions),
829
+ [messages, expansions],
830
+ );
831
+
799
832
  return {
800
833
  messages,
834
+ renderedMessages,
835
+ expandCompact,
836
+ isCompactExpanded,
801
837
  messagesLoading,
802
838
  isStreaming,
803
839
  phase,
@@ -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
  }