@hienlh/ppm 0.9.80 → 0.9.82

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 (162) hide show
  1. package/.opencode/.env.example +98 -0
  2. package/.opencode/skills/ads-management/scripts/.env.example +13 -0
  3. package/.opencode/skills/ai-multimodal/.env.example +230 -0
  4. package/.opencode/skills/cip-design/.env.example +6 -0
  5. package/.opencode/skills/devops/.env.example +76 -0
  6. package/.opencode/skills/docs-seeker/.env.example +15 -0
  7. package/.opencode/skills/elevenlabs/.env.example +3 -0
  8. package/.opencode/skills/marketing-dashboard/.env.example +15 -0
  9. package/.opencode/skills/marketing-dashboard/app/.env.example +2 -0
  10. package/.opencode/skills/marketing-dashboard/server/.env.example +2 -0
  11. package/.opencode/skills/mcp-management/scripts/dist/analyze-tools.js +70 -0
  12. package/.opencode/skills/mcp-management/scripts/dist/cli.js +160 -0
  13. package/.opencode/skills/mcp-management/scripts/dist/mcp-client.js +183 -0
  14. package/.opencode/skills/payment-integration/scripts/.env.example +20 -0
  15. package/.opencode/skills/sequential-thinking/.env.example +8 -0
  16. package/.repomixignore +22 -0
  17. package/AGENTS.md +62 -0
  18. package/CHANGELOG.md +17 -0
  19. package/CLAUDE.md +12 -0
  20. package/assets/skills/ppm-guide/SKILL.md +61 -0
  21. package/bun.lock +9 -1
  22. package/dist/web/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  23. package/dist/web/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  24. package/dist/web/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  25. package/dist/web/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  26. package/dist/web/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  27. package/dist/web/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  28. package/dist/web/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  29. package/dist/web/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  30. package/dist/web/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  31. package/dist/web/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  32. package/dist/web/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  33. package/dist/web/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  34. package/dist/web/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  35. package/dist/web/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  36. package/dist/web/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  37. package/dist/web/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  38. package/dist/web/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  39. package/dist/web/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  40. package/dist/web/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  41. package/dist/web/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  42. package/dist/web/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  43. package/dist/web/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  44. package/dist/web/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  45. package/dist/web/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  46. package/dist/web/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  47. package/dist/web/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  48. package/dist/web/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  49. package/dist/web/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  50. package/dist/web/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  51. package/dist/web/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  52. package/dist/web/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  53. package/dist/web/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  54. package/dist/web/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  55. package/dist/web/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  56. package/dist/web/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  57. package/dist/web/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  58. package/dist/web/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  59. package/dist/web/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  60. package/dist/web/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  61. package/dist/web/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  62. package/dist/web/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  63. package/dist/web/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  64. package/dist/web/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  65. package/dist/web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  66. package/dist/web/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  67. package/dist/web/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  68. package/dist/web/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  69. package/dist/web/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  70. package/dist/web/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  71. package/dist/web/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  72. package/dist/web/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  73. package/dist/web/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  74. package/dist/web/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  75. package/dist/web/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  76. package/dist/web/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  77. package/dist/web/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  78. package/dist/web/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  79. package/dist/web/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  80. package/dist/web/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  81. package/dist/web/assets/chat-tab-bS86TsT5.js +10 -0
  82. package/dist/web/assets/{code-editor-BFe-hnpF.js → code-editor-BaNaQ33b.js} +1 -1
  83. package/dist/web/assets/{database-viewer-BeY2V5QI.js → database-viewer-C5MVw8cJ.js} +1 -1
  84. package/dist/web/assets/{diff-viewer-D6xzs8PP.js → diff-viewer-CUbFMWVo.js} +1 -1
  85. package/dist/web/assets/{extension-webview-Cd1XYFXO.js → extension-webview-CwGufYEP.js} +1 -1
  86. package/dist/web/assets/{git-graph-D2XXpiMQ.js → git-graph-BD7A7MLo.js} +1 -1
  87. package/dist/web/assets/index-BYXjCNlK.css +2 -0
  88. package/dist/web/assets/index-CpzkPHOC.js +30 -0
  89. package/dist/web/assets/keybindings-store-DsaANvBz.js +1 -0
  90. package/dist/web/assets/markdown-renderer-C19IsITh.js +326 -0
  91. package/dist/web/assets/{port-forwarding-tab-B5rj_I66.js → port-forwarding-tab-BF79F1iL.js} +1 -1
  92. package/dist/web/assets/{postgres-viewer-DnlqzOnm.js → postgres-viewer-_nYiO_wp.js} +1 -1
  93. package/dist/web/assets/{settings-tab-CNZpuPD3.js → settings-tab-C1SQMbSu.js} +1 -1
  94. package/dist/web/assets/{sql-query-editor-Df2kzbPj.js → sql-query-editor-6OFvxxuN.js} +1 -1
  95. package/dist/web/assets/{sqlite-viewer-Cj1G70z4.js → sqlite-viewer-SNVYFXvB.js} +1 -1
  96. package/dist/web/assets/{terminal-tab-Dv9A7Xe2.js → terminal-tab-BJEkmrDt.js} +1 -1
  97. package/dist/web/assets/{use-monaco-theme-CPfIEo8t.js → use-monaco-theme-r8FzlCWr.js} +1 -1
  98. package/dist/web/index.html +2 -2
  99. package/dist/web/sw.js +1 -1
  100. package/docs/codebase-summary.md +78 -0
  101. package/docs/project-changelog.md +29 -0
  102. package/docs/system-architecture.md +2 -0
  103. package/package.json +5 -2
  104. package/release-manifest.json +15784 -0
  105. package/scripts/check-ppm-dir-usage.sh +21 -0
  106. package/scripts/generate-ppm-guide.ts +92 -0
  107. package/src/cli/commands/init.ts +2 -1
  108. package/src/cli/commands/logs.ts +11 -11
  109. package/src/cli/commands/report.ts +3 -2
  110. package/src/cli/commands/restart.ts +22 -23
  111. package/src/cli/commands/skills-cmd.ts +123 -0
  112. package/src/cli/commands/status.ts +7 -8
  113. package/src/cli/commands/stop.ts +18 -19
  114. package/src/index.ts +3 -0
  115. package/src/lib/account-crypto.ts +12 -7
  116. package/src/providers/claude-agent-sdk.ts +42 -11
  117. package/src/server/index.ts +8 -8
  118. package/src/server/routes/chat.ts +4 -2
  119. package/src/server/routes/upgrade.ts +3 -5
  120. package/src/server/ws/chat.ts +31 -0
  121. package/src/services/cloud-ws.service.ts +6 -3
  122. package/src/services/cloud.service.ts +20 -19
  123. package/src/services/cloudflared.service.ts +13 -13
  124. package/src/services/config.service.ts +5 -7
  125. package/src/services/db.service.ts +5 -6
  126. package/src/services/extension-rpc-handlers.ts +2 -2
  127. package/src/services/extension.service.ts +9 -12
  128. package/src/services/ppm-dir.ts +14 -0
  129. package/src/services/slash-discovery/builtin-commands.ts +53 -0
  130. package/src/services/slash-discovery/builtin-handlers.ts +65 -0
  131. package/src/services/slash-discovery/definition-source.ts +27 -0
  132. package/src/services/slash-discovery/discover-skill-roots.ts +128 -0
  133. package/src/services/slash-discovery/fuzzy-search.ts +76 -0
  134. package/src/services/slash-discovery/index.ts +42 -0
  135. package/src/services/slash-discovery/resolve-overrides.ts +41 -0
  136. package/src/services/slash-discovery/skill-loader.ts +156 -0
  137. package/src/services/slash-discovery/types.ts +51 -0
  138. package/src/services/slash-items.service.ts +4 -182
  139. package/src/services/supervisor-state.ts +14 -15
  140. package/src/services/supervisor-stopped-page.ts +2 -4
  141. package/src/services/supervisor.ts +15 -15
  142. package/src/services/tunnel.service.ts +22 -5
  143. package/src/services/upgrade.service.ts +2 -3
  144. package/src/types/chat.ts +3 -1
  145. package/src/web/components/chat/chat-history-bar.tsx +2 -15
  146. package/src/web/components/chat/chat-tab.tsx +5 -2
  147. package/src/web/components/chat/message-input.tsx +48 -6
  148. package/src/web/components/chat/message-list.tsx +19 -5
  149. package/src/web/components/chat/slash-command-picker.tsx +21 -12
  150. package/src/web/components/layout/mobile-nav.tsx +47 -21
  151. package/src/web/components/layout/panel-layout.tsx +11 -0
  152. package/src/web/components/layout/upgrade-banner.tsx +48 -2
  153. package/src/web/components/shared/markdown-renderer.tsx +5 -2
  154. package/src/web/hooks/use-chat.ts +33 -1
  155. package/src/web/main.tsx +1 -0
  156. package/src/web/stores/panel-store.ts +25 -1
  157. package/src/web/styles/globals.css +14 -0
  158. package/dist/web/assets/chat-tab-CmSLt4tg.js +0 -10
  159. package/dist/web/assets/index-BtwsLrdT.css +0 -2
  160. package/dist/web/assets/index-D6_wwsL_.js +0 -30
  161. package/dist/web/assets/keybindings-store-C8ryKudw.js +0 -1
  162. package/dist/web/assets/markdown-renderer-xYMhd9cE.js +0 -69
@@ -32,7 +32,7 @@ interface MessageInputProps {
32
32
  projectName?: string;
33
33
  /** Slash picker state change */
34
34
  onSlashStateChange?: (visible: boolean, filter: string) => void;
35
- onSlashItemsLoaded?: (items: SlashItem[]) => void;
35
+ onSlashItemsLoaded?: (items: SlashItem[], ranked?: boolean) => void;
36
36
  slashSelected?: SlashItem | null;
37
37
  /** File picker state change */
38
38
  onFileStateChange?: (visible: boolean, filter: string) => void;
@@ -83,6 +83,9 @@ export const MessageInput = memo(function MessageInput({
83
83
  const mobileTextareaRef = useRef<HTMLTextAreaElement>(null);
84
84
  const fileInputRef = useRef<HTMLInputElement>(null);
85
85
  const slashItemsRef = useRef<SlashItem[]>([]);
86
+ const slashRankedRef = useRef(false);
87
+ const slashDebounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
88
+ const slashSearchIdRef = useRef(0);
86
89
  const fileItemsRef = useRef<FileNode[]>([]);
87
90
 
88
91
  // Voice input (Web Speech API)
@@ -147,21 +150,29 @@ export const MessageInput = memo(function MessageInput({
147
150
  useEffect(() => {
148
151
  if (!projectName) {
149
152
  slashItemsRef.current = [];
150
- onSlashItemsLoaded?.([]);
153
+ onSlashItemsLoaded?.([], false);
151
154
  return;
152
155
  }
153
156
  api
154
157
  .get<SlashItem[]>(`${projectUrl(projectName)}/chat/slash-items`)
155
158
  .then((items) => {
156
159
  slashItemsRef.current = items;
157
- onSlashItemsLoaded?.(items);
160
+ slashRankedRef.current = false;
161
+ onSlashItemsLoaded?.(items, false);
158
162
  })
159
163
  .catch(() => {
160
164
  slashItemsRef.current = [];
161
- onSlashItemsLoaded?.([]);
165
+ onSlashItemsLoaded?.([], false);
162
166
  });
163
167
  }, [projectName]); // eslint-disable-line react-hooks/exhaustive-deps
164
168
 
169
+ // Cleanup debounce timer on unmount
170
+ useEffect(() => {
171
+ return () => {
172
+ if (slashDebounceRef.current) clearTimeout(slashDebounceRef.current);
173
+ };
174
+ }, []);
175
+
165
176
  // Fetch file tree when projectName changes
166
177
  useEffect(() => {
167
178
  if (!projectName) {
@@ -387,6 +398,30 @@ export const MessageInput = memo(function MessageInput({
387
398
  [handleSend, permissionMode, onModeChange],
388
399
  );
389
400
 
401
+ /** Debounced server-side fuzzy search for slash items */
402
+ const fetchSlashSearch = useCallback(
403
+ (query: string) => {
404
+ if (slashDebounceRef.current) clearTimeout(slashDebounceRef.current);
405
+ if (!projectName || !query) return;
406
+ const requestId = ++slashSearchIdRef.current;
407
+ slashDebounceRef.current = setTimeout(() => {
408
+ api
409
+ .get<SlashItem[]>(`${projectUrl(projectName)}/chat/slash-items?q=${encodeURIComponent(query)}`)
410
+ .then((items) => {
411
+ if (requestId !== slashSearchIdRef.current) return; // stale response
412
+ slashItemsRef.current = items;
413
+ slashRankedRef.current = true;
414
+ onSlashItemsLoaded?.(items, true);
415
+ })
416
+ .catch(() => {
417
+ if (requestId !== slashSearchIdRef.current) return;
418
+ slashRankedRef.current = false;
419
+ });
420
+ }, 150);
421
+ },
422
+ [projectName, onSlashItemsLoaded],
423
+ );
424
+
390
425
  const updatePickerState = useCallback(
391
426
  (text: string, cursorPos: number) => {
392
427
  const textBefore = text.slice(0, cursorPos);
@@ -394,11 +429,18 @@ export const MessageInput = memo(function MessageInput({
394
429
  // Check for slash anywhere in text (after whitespace or at start)
395
430
  const slashMatch = textBefore.match(/(?:^|\s)\/(\S*)$/);
396
431
  if (slashMatch && slashItemsRef.current.length > 0) {
397
- onSlashStateChange?.(true, slashMatch[1] ?? "");
432
+ const filter = slashMatch[1] ?? "";
433
+ onSlashStateChange?.(true, filter);
398
434
  onFileStateChange?.(false, "");
435
+ // Trigger server-side search for non-empty filter
436
+ if (filter) fetchSlashSearch(filter);
399
437
  return;
400
438
  }
401
439
 
440
+ // Cancel pending search when slash picker closes
441
+ if (slashDebounceRef.current) clearTimeout(slashDebounceRef.current);
442
+ if (slashRankedRef.current) slashRankedRef.current = false;
443
+
402
444
  // Check for @ anywhere in text (after whitespace or at start)
403
445
  const atMatch = textBefore.match(/@(\S*)$/);
404
446
  if (atMatch && fileItemsRef.current.length > 0) {
@@ -411,7 +453,7 @@ export const MessageInput = memo(function MessageInput({
411
453
  onSlashStateChange?.(false, "");
412
454
  onFileStateChange?.(false, "");
413
455
  },
414
- [onSlashStateChange, onFileStateChange],
456
+ [onSlashStateChange, onFileStateChange, fetchSlashSearch],
415
457
  );
416
458
 
417
459
  const handleChange = useCallback(
@@ -43,6 +43,7 @@ interface MessageListProps {
43
43
  phase?: SessionPhase;
44
44
  connectingElapsed?: number;
45
45
  statusMessage?: string | null;
46
+ compactStatus?: "compacting" | null;
46
47
  projectName?: string;
47
48
  /** Called when user clicks Fork/Rewind — opens new forked chat tab */
48
49
  onFork?: (userMessage: string, messageId?: string) => void;
@@ -60,6 +61,7 @@ export function MessageList({
60
61
  onSelectSession,
61
62
  connectingElapsed,
62
63
  statusMessage,
64
+ compactStatus,
63
65
  projectName,
64
66
  onFork,
65
67
  }: MessageListProps) {
@@ -103,9 +105,9 @@ export function MessageList({
103
105
  isStreaming={isStreaming && msg.id.startsWith("streaming-")}
104
106
  projectName={projectName}
105
107
  onFork={msg.role === "user" && onFork ? () => {
106
- // Pass the previous message ID so the fork includes history up to (but not including) this user message
108
+ // Pass the SDK UUID of the previous assistant message for fork (JSONL-level message ID)
107
109
  const prevMsg = idx > 0 ? filtered[idx - 1] : undefined;
108
- onFork(msg.content, prevMsg?.id);
110
+ onFork(msg.content, prevMsg?.sdkUuid ?? prevMsg?.id);
109
111
  } : undefined}
110
112
  />
111
113
  ))}
@@ -117,6 +119,7 @@ export function MessageList({
117
119
  )}
118
120
 
119
121
  {isStreaming && <ThinkingIndicator lastMessage={messages[messages.length - 1]} phase={phase} elapsed={connectingElapsed} statusMessage={statusMessage} />}
122
+ {!isStreaming && compactStatus === "compacting" && <ThinkingIndicator lastMessage={undefined} phase="thinking" elapsed={undefined} statusMessage="Compacting messages..." />}
120
123
  </StickToBottom.Content>
121
124
  <ScrollToBottomButton />
122
125
  </StickToBottom>
@@ -675,6 +678,17 @@ function InterleavedEvents({ events, isStreaming, projectName }: { events: ChatE
675
678
  }
676
679
  }
677
680
 
681
+ // Third pass: fallback to embedded result from buffer enrichment (reconnect).
682
+ // When BE buffers tool_result, it also attaches result onto the matching tool_use event.
683
+ for (const g of groups) {
684
+ if (g.kind === "tool" && !g.result && g.tool.type === "tool_use") {
685
+ const embedded = (g.tool as any).result;
686
+ if (embedded) {
687
+ g.result = { type: "tool_result", output: embedded.output, isError: embedded.isError } as ChatEvent;
688
+ }
689
+ }
690
+ }
691
+
678
692
  // Mark tool groups without explicit tool_result as completed when:
679
693
  // 1. It's a Read and a later Edit on the same file has a result (Edit implies Read finished)
680
694
  // 2. Streaming is fully finished
@@ -762,7 +776,7 @@ function ThinkingBlock({ content, isStreaming }: { content: string; isStreaming:
762
776
  function StreamingText({ content, animate: isStreaming, projectName }: { content: string; animate: boolean; projectName?: string }) {
763
777
  return (
764
778
  <>
765
- <MarkdownContent content={content} projectName={projectName} />
779
+ <MarkdownContent content={content} projectName={projectName} isStreaming={isStreaming} />
766
780
  {isStreaming && (
767
781
  <span className="text-text-subtle text-sm animate-pulse">Thinking...</span>
768
782
  )}
@@ -826,10 +840,10 @@ function stripTeammateMessages(text: string): string {
826
840
  }
827
841
 
828
842
  /** Wrapper: delegates to shared MarkdownRenderer with code actions enabled */
829
- function MarkdownContent({ content, projectName }: { content: string; projectName?: string }) {
843
+ function MarkdownContent({ content, projectName, isStreaming }: { content: string; projectName?: string; isStreaming?: boolean }) {
830
844
  const cleaned = stripTeammateMessages(content);
831
845
  if (!cleaned) return null;
832
- return <MarkdownRenderer content={cleaned} projectName={projectName} codeActions />;
846
+ return <MarkdownRenderer content={cleaned} projectName={projectName} codeActions isStreaming={isStreaming} />;
833
847
  }
834
848
 
835
849
  /* ToolCard, ToolSummary, ToolDetails extracted to ./tool-cards.tsx */
@@ -1,12 +1,14 @@
1
1
  import { useState, useEffect, useRef, useCallback, type KeyboardEvent } from "react";
2
- import { Sparkles, Terminal } from "lucide-react";
2
+ import { Sparkles, Terminal, Zap } from "lucide-react";
3
3
 
4
4
  export interface SlashItem {
5
- type: "skill" | "command";
5
+ type: "skill" | "command" | "builtin";
6
6
  name: string;
7
7
  description: string;
8
8
  argumentHint?: string;
9
- scope?: "project" | "user";
9
+ scope?: "project" | "user" | "bundled";
10
+ category?: string;
11
+ aliases?: string[];
10
12
  }
11
13
 
12
14
  interface SlashCommandPickerProps {
@@ -15,6 +17,8 @@ interface SlashCommandPickerProps {
15
17
  onSelect: (item: SlashItem) => void;
16
18
  onClose: () => void;
17
19
  visible: boolean;
20
+ /** When true, items are pre-ranked by server — skip client-side filtering */
21
+ ranked?: boolean;
18
22
  }
19
23
 
20
24
  export function SlashCommandPicker({
@@ -23,17 +27,20 @@ export function SlashCommandPicker({
23
27
  onSelect,
24
28
  onClose,
25
29
  visible,
30
+ ranked,
26
31
  }: SlashCommandPickerProps) {
27
32
  const [selectedIndex, setSelectedIndex] = useState(0);
28
33
  const listRef = useRef<HTMLDivElement>(null);
29
34
 
30
- const filtered = items.filter((item) => {
31
- const q = filter.toLowerCase();
32
- return (
33
- item.name.toLowerCase().includes(q) ||
34
- item.description.toLowerCase().includes(q)
35
- );
36
- });
35
+ const filtered = ranked
36
+ ? items
37
+ : items.filter((item) => {
38
+ const q = filter.toLowerCase();
39
+ return (
40
+ item.name.toLowerCase().includes(q) ||
41
+ item.description.toLowerCase().includes(q)
42
+ );
43
+ });
37
44
 
38
45
  // Reset selection when filter changes
39
46
  useEffect(() => {
@@ -105,7 +112,9 @@ export function SlashCommandPicker({
105
112
  onClick={() => onSelect(item)}
106
113
  >
107
114
  <span className="shrink-0 mt-0.5">
108
- {item.type === "skill" ? (
115
+ {item.type === "builtin" ? (
116
+ <Zap className="size-4 text-emerald-500" />
117
+ ) : item.type === "skill" ? (
109
118
  <Sparkles className="size-4 text-amber-500" />
110
119
  ) : (
111
120
  <Terminal className="size-4 text-blue-500" />
@@ -118,7 +127,7 @@ export function SlashCommandPicker({
118
127
  <span className="text-xs text-text-subtle">{item.argumentHint}</span>
119
128
  )}
120
129
  <span className="text-xs text-text-subtle capitalize ml-auto">
121
- {item.scope === "user" ? "global" : item.type}
130
+ {item.scope === "bundled" ? "PPM" : item.scope === "user" ? "global" : item.type}
122
131
  </span>
123
132
  </div>
124
133
  {item.description && (
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useRef, useCallback } from "react";
1
+ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
2
2
  import {
3
3
  Terminal, MessageSquare, GitBranch, Database,
4
4
  FileDiff, FileCode, Settings, Menu, X, ArrowLeft, ArrowRight, SplitSquareVertical, MoveVertical, Layers, Plus,
@@ -9,7 +9,7 @@ import { useProjectStore, resolveOrder } from "@/stores/project-store";
9
9
  import { findPanelPosition, MAX_ROWS } from "@/stores/panel-utils";
10
10
  import { resolveProjectColor } from "@/lib/project-palette";
11
11
  import { getProjectInitials } from "@/lib/project-avatar";
12
- import type { TabType } from "@/stores/tab-store";
12
+ import type { Tab, TabType } from "@/stores/tab-store";
13
13
  import { cn } from "@/lib/utils";
14
14
  import { openCommandPalette } from "@/hooks/use-global-keybindings";
15
15
  import { useNotificationStore, notificationColor } from "@/stores/notification-store";
@@ -33,11 +33,27 @@ interface MobileNavProps { onMenuPress: () => void; onProjectsPress: () => void;
33
33
 
34
34
  export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
35
35
  const focusedPanelId = usePanelStore((s) => s.focusedPanelId);
36
- const panel = usePanelStore((s) => s.panels[s.focusedPanelId]);
37
- const panelCount = usePanelStore((s) => Object.keys(s.panels).length);
36
+ const panels = usePanelStore((s) => s.panels);
38
37
  const grid = usePanelStore((s) => s.grid);
39
- const tabs = panel?.tabs ?? [];
40
- const activeTabId = panel?.activeTabId ?? null;
38
+
39
+ // Merge tabs from all panels in grid (mobile shows single merged tab bar)
40
+ const { tabs, tabPanelMap } = useMemo(() => {
41
+ const panelIds = grid.flat();
42
+ const allTabs: Tab[] = [];
43
+ const map: Record<string, string> = {};
44
+ for (const pid of panelIds) {
45
+ const p = panels[pid];
46
+ if (p) {
47
+ for (const t of p.tabs) {
48
+ allTabs.push(t);
49
+ map[t.id] = pid;
50
+ }
51
+ }
52
+ }
53
+ return { tabs: allTabs, tabPanelMap: map };
54
+ }, [panels, grid]);
55
+
56
+ const activeTabId = panels[focusedPanelId]?.activeTabId ?? null;
41
57
  const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
42
58
  const mobileScrollRef = useRef<HTMLDivElement>(null);
43
59
  const prevTabCount = useRef(tabs.length);
@@ -65,36 +81,46 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
65
81
  if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; }
66
82
  }, []);
67
83
 
68
- // Context menu actions
69
- const pos = findPanelPosition(grid, focusedPanelId);
84
+ // Context menu actions — use the tab's actual panel (not always focused)
85
+ const menuTab = menuTabId ? tabs.find((t) => t.id === menuTabId) : null;
86
+ const menuTabPanelId = menuTabId ? tabPanelMap[menuTabId] ?? focusedPanelId : focusedPanelId;
87
+ const menuTabPanelTabs = panels[menuTabPanelId]?.tabs ?? [];
88
+ const menuTabIdx = menuTabId ? menuTabPanelTabs.findIndex((t) => t.id === menuTabId) : -1;
89
+
90
+ const pos = findPanelPosition(grid, menuTabPanelId);
70
91
  const canSplitDown = pos ? grid.length < MAX_ROWS : false;
71
- const otherPanelIds = Object.keys(usePanelStore.getState().panels).filter((id) => id !== focusedPanelId);
92
+ const otherPanelIds = grid.flat().filter((id) => id !== menuTabPanelId);
72
93
 
73
94
  function moveTabLeft(tabId: string) {
74
- const idx = tabs.findIndex((t) => t.id === tabId);
75
- if (idx > 0) usePanelStore.getState().reorderTab(tabId, focusedPanelId, idx - 1);
95
+ const pid = tabPanelMap[tabId] ?? focusedPanelId;
96
+ const pTabs = usePanelStore.getState().panels[pid]?.tabs ?? [];
97
+ const idx = pTabs.findIndex((t) => t.id === tabId);
98
+ if (idx > 0) usePanelStore.getState().reorderTab(tabId, pid, idx - 1);
76
99
  }
77
100
  function moveTabRight(tabId: string) {
78
- const idx = tabs.findIndex((t) => t.id === tabId);
79
- if (idx < tabs.length - 1) usePanelStore.getState().reorderTab(tabId, focusedPanelId, idx + 1);
101
+ const pid = tabPanelMap[tabId] ?? focusedPanelId;
102
+ const pTabs = usePanelStore.getState().panels[pid]?.tabs ?? [];
103
+ const idx = pTabs.findIndex((t) => t.id === tabId);
104
+ if (idx < pTabs.length - 1) usePanelStore.getState().reorderTab(tabId, pid, idx + 1);
80
105
  }
81
106
  function splitDown(tabId: string) {
82
- usePanelStore.getState().splitPanel("down", tabId, focusedPanelId);
107
+ const pid = tabPanelMap[tabId] ?? focusedPanelId;
108
+ usePanelStore.getState().splitPanel("down", tabId, pid);
83
109
  }
84
110
  function moveToPanel(tabId: string, targetPanelId: string) {
85
- usePanelStore.getState().moveTab(tabId, focusedPanelId, targetPanelId);
111
+ const pid = tabPanelMap[tabId] ?? focusedPanelId;
112
+ usePanelStore.getState().moveTab(tabId, pid, targetPanelId);
86
113
  }
87
114
 
88
- const menuTab = menuTabId ? tabs.find((t) => t.id === menuTabId) : null;
89
- const menuTabIdx = menuTabId ? tabs.findIndex((t) => t.id === menuTabId) : -1;
90
-
91
115
  const { activeProject: activeProjectForTab } = useProjectStore.getState();
92
116
  function handleNewTab(type: TabType) {
117
+ const state = usePanelStore.getState();
118
+ const firstPanelId = state.grid[0]?.[0] ?? state.focusedPanelId;
93
119
  const needsProject = type === "git-graph" || type === "git-diff" || type === "terminal" || type === "chat";
94
120
  const metadata = needsProject ? { projectName: activeProjectForTab?.name } : undefined;
95
- usePanelStore.getState().openTab(
121
+ state.openTab(
96
122
  { type, title: NEW_TAB_LABELS[type] ?? type, metadata, projectId: activeProjectForTab?.name ?? null, closable: true },
97
- focusedPanelId,
123
+ firstPanelId,
98
124
  );
99
125
  setNewTabSheetOpen(false);
100
126
  }
@@ -255,7 +281,7 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
255
281
  <ArrowRight className="size-4" /> Move Right
256
282
  </button>
257
283
  )}
258
- {canSplitDown && tabs.length > 1 && (
284
+ {canSplitDown && menuTabPanelTabs.length > 1 && (
259
285
  <button onClick={() => { splitDown(menuTabId!); setMenuTabId(null); }}
260
286
  className="flex items-center gap-2 w-full px-3 py-2.5 text-sm text-foreground active:bg-surface-elevated">
261
287
  <SplitSquareVertical className="size-4" /> Split to Bottom
@@ -3,6 +3,7 @@ import { Panel, Group, Separator } from "react-resizable-panels";
3
3
  import { GripVertical, GripHorizontal } from "lucide-react";
4
4
  import { usePanelStore } from "@/stores/panel-store";
5
5
  import { createPanel } from "@/stores/panel-utils";
6
+ import { useMediaQuery } from "@/hooks/use-media-query";
6
7
  import { EditorPanel } from "./editor-panel";
7
8
 
8
9
  interface PanelLayoutProps {
@@ -10,9 +11,11 @@ interface PanelLayoutProps {
10
11
  }
11
12
 
12
13
  export function PanelLayout({ projectName }: PanelLayoutProps) {
14
+ const isDesktop = useMediaQuery("(min-width: 768px)");
13
15
  const grid = usePanelStore((s) =>
14
16
  s.currentProject === projectName ? s.grid : (s.projectGrids[projectName] ?? [[]]),
15
17
  );
18
+ const focusedPanelId = usePanelStore((s) => s.focusedPanelId);
16
19
  const panelCount = grid.flat().length;
17
20
 
18
21
  // Recover from empty grid (corrupt persisted state or edge-case bug)
@@ -29,6 +32,14 @@ export function PanelLayout({ projectName }: PanelLayoutProps) {
29
32
 
30
33
  if (panelCount === 0) return null;
31
34
 
35
+ // Mobile: render only the focused panel (tabs are merged in MobileNav)
36
+ if (!isDesktop) {
37
+ const allPanelIds = grid.flat();
38
+ const panelId = allPanelIds.includes(focusedPanelId) ? focusedPanelId : allPanelIds[0];
39
+ if (!panelId) return null;
40
+ return <EditorPanel panelId={panelId} projectName={projectName} />;
41
+ }
42
+
32
43
  if (panelCount === 1 && grid[0]?.[0]) {
33
44
  return <EditorPanel panelId={grid[0][0]} projectName={projectName} />;
34
45
  }
@@ -5,6 +5,8 @@ import { Loader2, ArrowUpCircle, X } from "lucide-react";
5
5
 
6
6
  const POLL_INTERVAL_MS = 60_000;
7
7
  const DISMISS_KEY_PREFIX = "ppm-upgrade-dismissed-";
8
+ const RESTART_POLL_MS = 1_500;
9
+ const RESTART_TIMEOUT_MS = 60_000;
8
10
 
9
11
  interface UpgradeStatus {
10
12
  currentVersion: string;
@@ -12,6 +14,37 @@ interface UpgradeStatus {
12
14
  installMethod: string;
13
15
  }
14
16
 
17
+ interface UpgradeResult {
18
+ success: boolean;
19
+ newVersion?: string;
20
+ restart: boolean;
21
+ message?: string;
22
+ }
23
+
24
+ /** Poll /api/health aggressively until server goes down then back up, then reload. */
25
+ async function waitForServerRestart(): Promise<boolean> {
26
+ let serverWentDown = false;
27
+ const start = Date.now();
28
+
29
+ while (Date.now() - start < RESTART_TIMEOUT_MS) {
30
+ await new Promise((r) => setTimeout(r, RESTART_POLL_MS));
31
+ try {
32
+ const res = await fetch("/api/health", { cache: "no-store" });
33
+ if (res.ok && serverWentDown) {
34
+ if ("caches" in window) {
35
+ const keys = await caches.keys();
36
+ await Promise.all(keys.map((k) => caches.delete(k)));
37
+ }
38
+ window.location.reload();
39
+ return true;
40
+ }
41
+ } catch {
42
+ serverWentDown = true;
43
+ }
44
+ }
45
+ return false;
46
+ }
47
+
15
48
  interface UpgradeBannerProps {
16
49
  onVisibilityChange?: (visible: boolean) => void;
17
50
  }
@@ -50,8 +83,21 @@ export function UpgradeBanner({ onVisibilityChange }: UpgradeBannerProps) {
50
83
  const handleUpgrade = useCallback(async () => {
51
84
  setUpgrading(true);
52
85
  try {
53
- await api.post("/api/upgrade/apply");
54
- // useServerReload in app.tsx handles the actual page reload after restart
86
+ const data = await api.post<UpgradeResult>("/api/upgrade/apply");
87
+
88
+ if (data.restart) {
89
+ // Server will restart — poll aggressively until it comes back
90
+ const restarted = await waitForServerRestart();
91
+ if (!restarted) {
92
+ toast.warning("Upgrade installed but server hasn't restarted. Try refreshing manually.");
93
+ setUpgrading(false);
94
+ }
95
+ } else {
96
+ // No supervisor — manual restart needed
97
+ toast.info(data.message || "Upgrade installed. Restart PPM manually.");
98
+ setUpgrading(false);
99
+ setDismissed(true);
100
+ }
55
101
  } catch (e) {
56
102
  toast.error(`Upgrade failed: ${(e as Error).message}`);
57
103
  setUpgrading(false);
@@ -1,5 +1,6 @@
1
1
  import { useMemo, useRef, useEffect } from "react";
2
2
  import { marked } from "marked";
3
+ import markedKatex from "marked-katex-extension";
3
4
  import { useTabStore } from "@/stores/tab-store";
4
5
  import { useFileStore, type FileNode } from "@/stores/file-store";
5
6
  import { useImageOverlay } from "@/stores/image-overlay-store";
@@ -29,6 +30,7 @@ const LOCAL_PATH_RE = /^(\/|[A-Za-z]:[/\\])/;
29
30
 
30
31
  // Configure marked globally
31
32
  marked.use({ gfm: true, breaks: true });
33
+ marked.use(markedKatex({ throwOnError: false }));
32
34
 
33
35
  /** Common text file extensions that PPM can open as editor tabs */
34
36
  const FILE_EXTS = "ts|tsx|js|jsx|mjs|cjs|py|json|md|mdx|yaml|yml|toml|css|scss|less|html|htm|sh|bash|zsh|go|rs|sql|rb|java|kt|swift|c|cpp|h|hpp|cs|vue|svelte|txt|env|cfg|conf|ini|xml|csv|log|dockerfile|makefile|gradle";
@@ -41,6 +43,7 @@ interface MarkdownRendererProps {
41
43
  projectName?: string;
42
44
  className?: string;
43
45
  codeActions?: boolean;
46
+ isStreaming?: boolean;
44
47
  }
45
48
 
46
49
  /**
@@ -93,7 +96,7 @@ function transformHtml(raw: string): string {
93
96
  return html;
94
97
  }
95
98
 
96
- export function MarkdownRenderer({ content, projectName, className = "", codeActions = false }: MarkdownRendererProps) {
99
+ export function MarkdownRenderer({ content, projectName, className = "", codeActions = false, isStreaming = false }: MarkdownRendererProps) {
97
100
  const html = useMemo(() => {
98
101
  try {
99
102
  const raw = marked.parse(content) as string;
@@ -321,7 +324,7 @@ export function MarkdownRenderer({ content, projectName, className = "", codeAct
321
324
  return (
322
325
  <div
323
326
  ref={containerRef}
324
- className={`markdown-content prose-sm ${className}`}
327
+ className={`markdown-content prose-sm ${isStreaming ? "is-streaming" : ""} ${className}`}
325
328
  dangerouslySetInnerHTML={{ __html: html }}
326
329
  />
327
330
  );
@@ -4,6 +4,7 @@ import { api, getAuthToken, projectUrl } from "@/lib/api-client";
4
4
  import { useNotificationStore } from "@/stores/notification-store";
5
5
  import { usePanelStore } from "@/stores/panel-store";
6
6
  import { playNotificationSound } from "@/lib/notification-sounds";
7
+ import { toast } from "sonner";
7
8
  import type { ChatMessage, ChatEvent } from "../../types/chat";
8
9
  import type { ChatWsServerMessage, SessionPhase } from "../../types/api";
9
10
 
@@ -91,6 +92,8 @@ export function useChat(sessionId: string | null, providerId = "claude", project
91
92
  sessionIdRef.current = sessionId;
92
93
  const projectNameRef = useRef(projectName);
93
94
  projectNameRef.current = projectName;
95
+ /** Toast ID for the current pending approval notification */
96
+ const approvalToastRef = useRef<string | number | null>(null);
94
97
 
95
98
  // Team activity tracking
96
99
  const teamActivityRef = useRef<{
@@ -247,6 +250,29 @@ export function useChat(sessionId: string | null, providerId = "claude", project
247
250
  const nType = ev.tool === "AskUserQuestion" ? "question" : "approval_request";
248
251
  useNotificationStore.getState().addNotification(sessionIdRef.current, nType, projectNameRef.current);
249
252
  playNotificationSound(nType);
253
+ // Persistent toast with action to navigate to the waiting session
254
+ const sid = sessionIdRef.current;
255
+ const isQuestion = ev.tool === "AskUserQuestion";
256
+ approvalToastRef.current = toast[isQuestion ? "info" : "warning"](
257
+ isQuestion ? "AI has a question" : `${ev.tool} needs permission`,
258
+ {
259
+ description: projectNameRef.current || `Session ${sid.slice(0, 8)}`,
260
+ duration: Infinity,
261
+ action: {
262
+ label: "Go to session",
263
+ onClick: () => {
264
+ const { panels } = usePanelStore.getState();
265
+ for (const [panelId, panel] of Object.entries(panels)) {
266
+ const tab = panel.tabs.find((t) => t.metadata?.sessionId === sid);
267
+ if (tab) {
268
+ usePanelStore.getState().setActiveTab(tab.id, panelId);
269
+ break;
270
+ }
271
+ }
272
+ },
273
+ },
274
+ },
275
+ );
250
276
  }
251
277
  break;
252
278
  }
@@ -317,9 +343,10 @@ export function useChat(sessionId: string | null, providerId = "claude", project
317
343
  useNotificationStore.getState().addNotification(sessionIdRef.current, "done", projectNameRef.current);
318
344
  playNotificationSound("done");
319
345
  }
320
- // Finalize the streaming message
346
+ // Finalize the streaming message — preserve SDK UUID for fork/rewind
321
347
  const finalContent = streamingContentRef.current;
322
348
  const finalEvents = [...streamingEventsRef.current];
349
+ const doneUuid = ev.lastMessageUuid as string | undefined;
323
350
  setMessages((prev) => {
324
351
  const last = prev[prev.length - 1];
325
352
  if (last?.role === "assistant") {
@@ -328,6 +355,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
328
355
  id: `final-${Date.now()}`,
329
356
  content: finalContent || last.content,
330
357
  events: finalEvents.length > 0 ? finalEvents : last.events,
358
+ ...(doneUuid && { sdkUuid: doneUuid }),
331
359
  }];
332
360
  }
333
361
  return prev;
@@ -464,6 +492,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
464
492
  setPhase("idle");
465
493
  phaseRef.current = "idle";
466
494
  setPendingApproval(null);
495
+ if (approvalToastRef.current != null) { toast.dismiss(approvalToastRef.current); approvalToastRef.current = null; }
467
496
  setCompactStatus(null);
468
497
  streamingContentRef.current = "";
469
498
  streamingEventsRef.current = [];
@@ -548,6 +577,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
548
577
  phaseRef.current = "thinking";
549
578
  }
550
579
  setPendingApproval(null);
580
+ if (approvalToastRef.current != null) { toast.dismiss(approvalToastRef.current); approvalToastRef.current = null; }
551
581
 
552
582
  send(JSON.stringify({
553
583
  type: "message",
@@ -590,6 +620,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
590
620
  }
591
621
 
592
622
  setPendingApproval(null);
623
+ if (approvalToastRef.current != null) { toast.dismiss(approvalToastRef.current); approvalToastRef.current = null; }
593
624
  },
594
625
  [send],
595
626
  );
@@ -620,6 +651,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
620
651
  setPhase("idle");
621
652
  phaseRef.current = "idle";
622
653
  setPendingApproval(null);
654
+ if (approvalToastRef.current != null) { toast.dismiss(approvalToastRef.current); approvalToastRef.current = null; }
623
655
  }, [send]);
624
656
 
625
657
  const reconnect = useCallback(() => {
package/src/web/main.tsx CHANGED
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
3
  import { App } from "./app.tsx";
4
4
  import "./styles/globals.css";
5
+ import "katex/dist/katex.min.css";
5
6
 
6
7
  createRoot(document.getElementById("root")!).render(
7
8
  <StrictMode>
@@ -209,7 +209,11 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
209
209
  },
210
210
 
211
211
  openTab: (tabDef, panelId?) => {
212
- const pid = resolvePanel(panelId);
212
+ const mobile = get().isMobile();
213
+ // On mobile, always open in first panel (tabs merged in mobile nav)
214
+ const pid = mobile
215
+ ? (get().grid[0]?.[0] ?? resolvePanel(panelId))
216
+ : resolvePanel(panelId);
213
217
  const panel = get().panels[pid];
214
218
  if (!panel) return "";
215
219
 
@@ -250,6 +254,26 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
250
254
  }
251
255
  }
252
256
 
257
+ // Mobile: dedup across all panels (merged tab bar shows all tabs)
258
+ if (mobile) {
259
+ for (const gpid of get().grid.flat()) {
260
+ const p = get().panels[gpid];
261
+ if (!p) continue;
262
+ const existing = p.tabs.find((t) => t.id === baseId || t.id.startsWith(`${baseId}@`));
263
+ if (existing) {
264
+ set((s) => ({
265
+ focusedPanelId: p.id,
266
+ panels: {
267
+ ...s.panels,
268
+ [p.id]: { ...p, activeTabId: existing.id, tabHistory: pushHistory(p.tabHistory, existing.id) },
269
+ },
270
+ }));
271
+ persist();
272
+ return existing.id;
273
+ }
274
+ }
275
+ }
276
+
253
277
  // Non-singleton: dedup within SAME panel only
254
278
  const currentPanel = get().panels[pid]!;
255
279
  const existingInPanel = currentPanel.tabs.find((t) => t.id === baseId);