@hienlh/ppm 0.17.54 → 0.17.56

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 (55) hide show
  1. package/CHANGELOG.md +13 -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/{ai-resource-editor-Bc1JjqQ2.js → ai-resource-editor-DL011EDu.js} +1 -1
  5. package/dist/web/assets/{audio-preview-DkMX9OJj.js → audio-preview-H16VO99g.js} +1 -1
  6. package/dist/web/assets/{chat-tab-DbLlGvwV.js → chat-tab-D2nZCpAo.js} +3 -3
  7. package/dist/web/assets/{code-editor-Dlzl3dNT.js → code-editor-DqQy2Qm8.js} +2 -2
  8. package/dist/web/assets/{conflict-editor-DUHhHqpE.js → conflict-editor-B_wAb0Kb.js} +1 -1
  9. package/dist/web/assets/{csv-preview-CS_r0b6o.js → csv-preview-BL0I3CKz.js} +1 -1
  10. package/dist/web/assets/{database-viewer-CVYeQeUc.js → database-viewer-awmnuDCt.js} +1 -1
  11. package/dist/web/assets/{diff-viewer-CCrzns7w.js → diff-viewer-_yQtutHR.js} +1 -1
  12. package/dist/web/assets/{docx-preview-BLQB-hLE.js → docx-preview-DDw3duw9.js} +1 -1
  13. package/dist/web/assets/{extension-webview-z-UAr801.js → extension-webview-DkTI51P3.js} +1 -1
  14. package/dist/web/assets/{git-log-panel-cfFgTJhr.js → git-log-panel-NkghB_rO.js} +1 -1
  15. package/dist/web/assets/{glide-data-grid-DLI4he-v.js → glide-data-grid-Bpgp72Hg.js} +1 -1
  16. package/dist/web/assets/{group-chat-tab-C2AhbwL2.js → group-chat-tab-CQOVBmVp.js} +1 -1
  17. package/dist/web/assets/{image-preview-B8al9rrY.js → image-preview-Dwg0EuJ2.js} +1 -1
  18. package/dist/web/assets/{index-DlzNfQJh.js → index-CJ0Wp_rW.js} +16 -16
  19. package/dist/web/assets/{keybindings-store-DtAKiCMw.js → keybindings-store-ClNYe9vD.js} +1 -1
  20. package/dist/web/assets/{markdown-renderer-DHnbHEFr.js → markdown-renderer-C7dlPs2S.js} +1 -1
  21. package/dist/web/assets/{markdown-renderer-CNSv3Olb.js → markdown-renderer-CwpP5Ola.js} +1 -1
  22. package/dist/web/assets/{notification-store-DGrzDj7x.js → notification-store-DwAhNzTy.js} +1 -1
  23. package/dist/web/assets/{pdf-preview-P8Ar0W1A.js → pdf-preview-CEWYMxdp.js} +1 -1
  24. package/dist/web/assets/{postgres-viewer-CqjPnnlc.js → postgres-viewer-D7PggslU.js} +1 -1
  25. package/dist/web/assets/{settings-tab-LZPNBhSb.js → settings-tab-CeSJQIfF.js} +1 -1
  26. package/dist/web/assets/{sql-query-editor-BpDMqI2d.js → sql-query-editor-ZnKcxCyj.js} +1 -1
  27. package/dist/web/assets/{sqlite-viewer-D6sC2hw7.js → sqlite-viewer-8fk_wut8.js} +1 -1
  28. package/dist/web/assets/{system-monitor-tab-C3Ef8jqO.js → system-monitor-tab-Ck3REHUg.js} +1 -1
  29. package/dist/web/assets/{terminal-tab-BR-71TAe.js → terminal-tab-D6tYDFSF.js} +1 -1
  30. package/dist/web/assets/{tool-cards-Bmr07Non.js → tool-cards-C3ka1Y7I.js} +2 -2
  31. package/dist/web/assets/{use-monaco-theme-DPVuNCPp.js → use-monaco-theme-wKbyNwIn.js} +1 -1
  32. package/dist/web/assets/{video-preview-BwnO8msG.js → video-preview-DwrfenNA.js} +1 -1
  33. package/dist/web/index.html +1 -1
  34. package/dist/web/monacoeditorwork/css.worker.bundle.js +122 -122
  35. package/dist/web/monacoeditorwork/editor.worker.bundle.js +78 -78
  36. package/dist/web/monacoeditorwork/html.worker.bundle.js +110 -110
  37. package/dist/web/monacoeditorwork/json.worker.bundle.js +108 -108
  38. package/dist/web/monacoeditorwork/ts.worker.bundle.js +81 -81
  39. package/dist/web/sw.js +1 -1
  40. package/package.json +1 -1
  41. package/src/providers/mock-provider.ts +10 -0
  42. package/src/server/index.ts +41 -15
  43. package/src/server/routes/upgrade.ts +23 -5
  44. package/src/server/ws/chat.ts +8 -1
  45. package/src/services/autostart-generator.ts +24 -0
  46. package/src/services/autostart-register.ts +33 -4
  47. package/src/services/upgrade.service.ts +13 -3
  48. package/src/web/app.tsx +1 -16
  49. package/src/web/components/layout/upgrade-button.tsx +70 -33
  50. package/src/web/hooks/use-chat.ts +3 -2
  51. package/src/web/hooks/use-global-events.ts +8 -2
  52. package/src/web/lib/changelog.ts +13 -0
  53. package/src/web/lib/sync-running-sessions.ts +27 -0
  54. package/src/web/stores/streaming-store.ts +29 -7
  55. package/.rollout-mark +0 -2
@@ -1,9 +1,9 @@
1
- import { useState, useEffect, useCallback } from "react";
1
+ import { useState, useEffect, useCallback, useMemo } from "react";
2
2
  import { api } from "@/lib/api-client";
3
3
  import { toast } from "sonner";
4
4
  import { Loader2, ArrowUpCircle, RefreshCw, Download, ExternalLink, History } from "lucide-react";
5
5
  import { useSettingsStore } from "@/stores/settings-store";
6
- import { fetchChangelogSince, fetchRecentChangelog, compareSemver, type ChangelogSection } from "@/lib/changelog";
6
+ import { fetchRecentChangelog, newestSectionVersion, compareSemver, type ChangelogSection } from "@/lib/changelog";
7
7
  import { cn } from "@/lib/utils";
8
8
 
9
9
  const POLL_INTERVAL_MS = 60_000;
@@ -113,26 +113,36 @@ export function UpgradeButton({ align = "right" }: { align?: "left" | "right" })
113
113
  const [notes, setNotes] = useState<ChangelogSection[] | null>(null);
114
114
  const [notesLoading, setNotesLoading] = useState(false);
115
115
 
116
- useEffect(() => {
117
- let timer: ReturnType<typeof setInterval>;
118
- async function check() {
119
- try {
120
- const data = await api.get<UpgradeStatus>("/api/upgrade");
121
- setCurrentVersion(data.currentVersion);
122
- if (data.availableVersion) {
123
- setAvailableVersion(data.availableVersion);
124
- setDismissed(!!sessionStorage.getItem(DISMISS_KEY_PREFIX + data.availableVersion));
125
- } else {
126
- setAvailableVersion(null);
127
- }
128
- } catch {
129
- // ignore — chip just shows the known version
116
+ // `force` asks the server to bypass its registry cache. The background poll
117
+ // stays cached (one request per client per minute must not hit the registry),
118
+ // but a user opening the popover is asking "is there one *now*" and must not
119
+ // be answered from an answer taken up to five minutes ago.
120
+ const check = useCallback(async (force = false) => {
121
+ try {
122
+ const data = await api.get<UpgradeStatus>(force ? "/api/upgrade?refresh=1" : "/api/upgrade");
123
+ setCurrentVersion(data.currentVersion);
124
+ if (data.availableVersion) {
125
+ setAvailableVersion(data.availableVersion);
126
+ setDismissed(!!sessionStorage.getItem(DISMISS_KEY_PREFIX + data.availableVersion));
127
+ } else {
128
+ setAvailableVersion(null);
130
129
  }
130
+ } catch {
131
+ // ignore — chip just shows the known version
131
132
  }
133
+ }, []);
134
+
135
+ useEffect(() => {
132
136
  check();
133
- timer = setInterval(check, POLL_INTERVAL_MS);
137
+ const timer = setInterval(() => check(), POLL_INTERVAL_MS);
134
138
  return () => clearInterval(timer);
135
- }, []);
139
+ }, [check]);
140
+
141
+ // Opening the popover re-checks immediately, so the panel never disagrees
142
+ // with itself: release notes and the Update button are resolved together.
143
+ useEffect(() => {
144
+ if (open) check(true);
145
+ }, [open, check]);
136
146
 
137
147
  const current = currentVersion ?? version;
138
148
  // QA hook (latched in sessionStorage — see getTestTarget). Forces the update
@@ -145,25 +155,44 @@ export function UpgradeButton({ align = "right" }: { align?: "left" | "right" })
145
155
  const realUpdate = !!availableVersion && !!current && compareSemver(availableVersion, current) > 0
146
156
  ? availableVersion
147
157
  : null;
148
- const effectiveAvailable = realUpdate ?? (isTest ? testTarget : null);
149
- const hasUpdate = !!effectiveAvailable && !dismissed;
150
158
 
151
159
  // Load release notes each time the popover opens. Refetching (not caching for
152
160
  // the session) avoids latching a stale/empty result from a transient fetch:
153
161
  // the changelog source can briefly lag the version signal after a release.
162
+ // Always the recent list, unfiltered — the sections themselves are what tell
163
+ // us whether a newer release exists, so filtering here would hide the signal.
154
164
  useEffect(() => {
155
165
  if (!open || !current) return;
156
166
  setNotesLoading(true);
157
- // Update mode → sections newer than installed (test hook shows all).
158
- // No update → recent releases so clicking the version chip still shows notes.
159
- const load = hasUpdate
160
- ? fetchChangelogSince(isTest ? "0.0.0" : current)
161
- : fetchRecentChangelog();
162
- load
167
+ fetchRecentChangelog()
163
168
  .then(setNotes)
164
169
  .catch(() => setNotes([]))
165
170
  .finally(() => setNotesLoading(false));
166
- }, [open, current, hasUpdate, isTest]);
171
+ }, [open, current]);
172
+
173
+ // The published CHANGELOG is the earliest signal of a release: it updates the
174
+ // moment the release is pushed, while the registry only flips once the
175
+ // package is published. Whenever the notes name a version newer than the
176
+ // installed one, offer the upgrade — seeing a release in this panel and not
177
+ // being able to take it is never the right outcome, so the panel's own
178
+ // content is a first-class update signal, not decoration.
179
+ const changelogUpdate = useMemo(() => {
180
+ if (!notes || !current) return null;
181
+ const newest = newestSectionVersion(notes);
182
+ return newest && compareSemver(newest, current) > 0 ? newest : null;
183
+ }, [notes, current]);
184
+
185
+ const effectiveAvailable = realUpdate ?? changelogUpdate ?? (isTest ? testTarget : null);
186
+ const hasUpdate = !!effectiveAvailable && !dismissed;
187
+
188
+ // In update mode the list answers "what am I about to get", so drop anything
189
+ // already installed. If that leaves nothing (registry ahead of the changelog)
190
+ // keep the full list rather than an empty panel.
191
+ const visibleNotes = useMemo(() => {
192
+ if (!notes || !hasUpdate || isTest || !current) return notes;
193
+ const newer = notes.filter((s) => compareSemver(s.version, current) > 0);
194
+ return newer.length > 0 ? newer : notes;
195
+ }, [notes, hasUpdate, isTest, current]);
167
196
 
168
197
  // The server is restarting into the new version — stop showing "Updating…",
169
198
  // switch to the reconnect state, and auto-reload once it's listening again
@@ -185,7 +214,7 @@ export function UpgradeButton({ align = "right" }: { align?: "left" | "right" })
185
214
  } else {
186
215
  toast.info(data.message || "Upgrade installed. Restart PPM manually.");
187
216
  setUpgrading(false);
188
- if (availableVersion) sessionStorage.setItem(DISMISS_KEY_PREFIX + availableVersion, "1");
217
+ if (effectiveAvailable) sessionStorage.setItem(DISMISS_KEY_PREFIX + effectiveAvailable, "1");
189
218
  setDismissed(true);
190
219
  }
191
220
  } catch (e) {
@@ -193,18 +222,26 @@ export function UpgradeButton({ align = "right" }: { align?: "left" | "right" })
193
222
  // Expected: self-replace killed the server before the response flushed.
194
223
  // The upgrade is in progress — wait for it, don't report a failure.
195
224
  enterRestartWait();
225
+ } else if (/already on latest/i.test((e as Error).message)) {
226
+ // Offered from the changelog, but the package registry has not caught up
227
+ // yet — the release is announced and not yet installable. Say so, and
228
+ // leave the offer standing so the next attempt can succeed.
229
+ toast.info(`v${effectiveAvailable} is announced but not published yet — try again shortly.`);
230
+ setUpgrading(false);
196
231
  } else {
197
232
  toast.error(`Upgrade failed: ${(e as Error).message}`);
198
233
  setUpgrading(false);
199
234
  }
200
235
  }
201
- }, [availableVersion, enterRestartWait]);
236
+ }, [effectiveAvailable, enterRestartWait]);
202
237
 
203
238
  const handleDismiss = useCallback(() => {
204
- if (availableVersion) sessionStorage.setItem(DISMISS_KEY_PREFIX + availableVersion, "1");
239
+ // Key on the offered version, not just the server-reported one: an update
240
+ // known only from the changelog must be dismissable too.
241
+ if (effectiveAvailable) sessionStorage.setItem(DISMISS_KEY_PREFIX + effectiveAvailable, "1");
205
242
  setDismissed(true);
206
243
  setOpen(false);
207
- }, [availableVersion]);
244
+ }, [effectiveAvailable]);
208
245
 
209
246
  if (!current && !hasUpdate) return null;
210
247
 
@@ -277,9 +314,9 @@ export function UpgradeButton({ align = "right" }: { align?: "left" | "right" })
277
314
  <span className="flex items-center gap-2 text-text-3">
278
315
  <Loader2 className="size-3.5 animate-spin" /> Loading release notes…
279
316
  </span>
280
- ) : notes && notes.length > 0 ? (
317
+ ) : visibleNotes && visibleNotes.length > 0 ? (
281
318
  <div className="space-y-2.5">
282
- {notes.map((s) => (
319
+ {visibleNotes.map((s) => (
283
320
  <ChangelogEntry key={s.version} version={s.version} body={s.body} />
284
321
  ))}
285
322
  </div>
@@ -216,9 +216,10 @@ export function useChat(sessionId: string | null, providerId = "claude", project
216
216
  // Sync streaming state to global store (for favicon + tab icon indicators)
217
217
  useEffect(() => {
218
218
  if (!sessionId) return;
219
- useStreamingStore.getState().setStreaming(sessionId, phase !== "idle");
219
+ // projectName tags the entry so a project-scoped sync can reconcile it.
220
+ useStreamingStore.getState().setStreaming(sessionId, phase !== "idle", projectName);
220
221
  return () => { useStreamingStore.getState().setStreaming(sessionId, false); };
221
- }, [sessionId, phase]);
222
+ }, [sessionId, phase, projectName]);
222
223
 
223
224
  /**
224
225
  * Route a child event to its parent Agent/Task tool_use's children array.
@@ -3,6 +3,7 @@ import { WsClient } from "@/lib/ws-client";
3
3
  import { getAuthToken } from "@/lib/api-client";
4
4
  import { useNotificationStore } from "@/stores/notification-store";
5
5
  import { useStreamingStore } from "@/stores/streaming-store";
6
+ import { syncRunningSessions } from "@/lib/sync-running-sessions";
6
7
 
7
8
  /**
8
9
  * App-wide event bus client (`/ws/global`).
@@ -18,6 +19,9 @@ import { useStreamingStore } from "@/stores/streaming-store";
18
19
  * - `session:phase_changed` → keeps the tab-strip spinner and title indicator
19
20
  * correct for sessions whose tab is not mounted, and — critically — clears them
20
21
  * when the turn ends. Nothing else can: `useChat` only runs while mounted.
22
+ * On every (re)connect the indicators are also reconciled against the server
23
+ * registry, since a phase change that happened while this socket was down was
24
+ * never delivered and would otherwise stick until a full reload.
21
25
  * - `jira:*` → re-dispatched as window events.
22
26
  *
23
27
  * Also tells the server which project to watch, so file watching follows the
@@ -53,6 +57,7 @@ export function useGlobalEvents(enabled: boolean, projectName?: string): void {
53
57
  if (projectRef.current) {
54
58
  client.send(JSON.stringify({ type: "watch", projectName: projectRef.current }));
55
59
  }
60
+ void syncRunningSessions(projectRef.current);
56
61
  return;
57
62
  }
58
63
 
@@ -73,8 +78,8 @@ export function useGlobalEvents(enabled: boolean, projectName?: string): void {
73
78
  }
74
79
 
75
80
  if (type === "session:phase_changed") {
76
- const d = data as unknown as { sessionId: string; phase: string };
77
- useStreamingStore.getState().setStreaming(d.sessionId, d.phase !== "idle");
81
+ const d = data as unknown as { sessionId: string; phase: string; projectName?: string };
82
+ useStreamingStore.getState().setStreaming(d.sessionId, d.phase !== "idle", d.projectName);
78
83
  return;
79
84
  }
80
85
 
@@ -97,5 +102,6 @@ export function useGlobalEvents(enabled: boolean, projectName?: string): void {
97
102
  useEffect(() => {
98
103
  if (!enabled || !projectName) return;
99
104
  clientRef.current?.send(JSON.stringify({ type: "watch", projectName }));
105
+ void syncRunningSessions(projectName);
100
106
  }, [enabled, projectName]);
101
107
  }
@@ -51,6 +51,19 @@ export function parseChangelog(md: string): ChangelogSection[] {
51
51
  return sections;
52
52
  }
53
53
 
54
+ /**
55
+ * Newest version among the given sections, or null when there are none.
56
+ * Order-independent on purpose: the file is authored newest-first by
57
+ * convention, and a release that lands out of order must not read as "no newer
58
+ * version" to the upgrade check that consumes this.
59
+ */
60
+ export function newestSectionVersion(sections: ChangelogSection[]): string | null {
61
+ return sections.reduce<string | null>(
62
+ (best, s) => (best && compareSemver(s.version, best) <= 0 ? best : s.version),
63
+ null,
64
+ );
65
+ }
66
+
54
67
  /** Parse CHANGELOG markdown into version sections strictly newer than `since`. */
55
68
  export function parseChangelogSince(md: string, since: string): ChangelogSection[] {
56
69
  return parseChangelog(md).filter((s) => compareSemver(s.version, since) > 0);
@@ -0,0 +1,27 @@
1
+ import { api, projectUrl } from "@/lib/api-client";
2
+ import { useStreamingStore } from "@/stores/streaming-store";
3
+
4
+ /**
5
+ * Reconcile a project's streaming indicators (tab-strip spinner, title/favicon)
6
+ * against the server's session registry.
7
+ *
8
+ * Runs on every `/ws/global` (re)connect and on project switch. Two gaps it
9
+ * closes, both of which nothing else can:
10
+ * - a turn running in a tab that is not mounted (tabs mount lazily, so its phase
11
+ * never reaches this client) → indicator missing;
12
+ * - an `idle` broadcast that landed while the global socket was down → indicator
13
+ * stuck on until a full page reload.
14
+ */
15
+ export async function syncRunningSessions(projectName: string | undefined): Promise<void> {
16
+ if (!projectName) return;
17
+ try {
18
+ const running = await api.get<{ sessionId: string }[]>(
19
+ `${projectUrl(projectName)}/chat/sessions/running`,
20
+ );
21
+ useStreamingStore
22
+ .getState()
23
+ .replaceProjectStreaming(projectName, running.map((s) => s.sessionId));
24
+ } catch {
25
+ // Never block boot or a reconnect on an indicator.
26
+ }
27
+ }
@@ -2,21 +2,43 @@ import { create } from "zustand";
2
2
 
3
3
  /** Tracks which chat sessions are currently streaming AI responses */
4
4
  interface StreamingStore {
5
- /** Set of sessionIds that are actively streaming */
6
- sessions: Set<string>;
5
+ /** sessionId projectName ("" when unknown) for sessions actively streaming.
6
+ * The project is recorded so a project-scoped sync can drop stale entries
7
+ * without touching sessions that belong to another project. */
8
+ sessions: Map<string, string>;
7
9
  /** Mark a session as streaming or idle */
8
- setStreaming: (sessionId: string, streaming: boolean) => void;
10
+ setStreaming: (sessionId: string, streaming: boolean, projectName?: string) => void;
11
+ /**
12
+ * Reconcile one project's streaming set against the server's registry, which
13
+ * is authoritative. Needed because an `idle` broadcast missed while
14
+ * `/ws/global` was down would otherwise leave a spinner running forever —
15
+ * nothing else ever clears it. Scoped per project: the running list only
16
+ * covers one project, so entries from other projects must survive.
17
+ */
18
+ replaceProjectStreaming: (projectName: string, sessionIds: string[]) => void;
9
19
  }
10
20
 
11
21
  export const useStreamingStore = create<StreamingStore>((set) => ({
12
- sessions: new Set(),
13
- setStreaming: (sessionId, streaming) =>
22
+ sessions: new Map(),
23
+ setStreaming: (sessionId, streaming, projectName) =>
14
24
  set((state) => {
15
- const next = new Set(state.sessions);
16
- if (streaming) next.add(sessionId);
25
+ const next = new Map(state.sessions);
26
+ // Keep a previously recorded project when a caller omits it, so the entry
27
+ // stays reconcilable.
28
+ if (streaming) next.set(sessionId, projectName ?? next.get(sessionId) ?? "");
17
29
  else next.delete(sessionId);
18
30
  return { sessions: next };
19
31
  }),
32
+ replaceProjectStreaming: (projectName, sessionIds) =>
33
+ set((state) => {
34
+ const running = new Set(sessionIds);
35
+ const next = new Map(state.sessions);
36
+ for (const [id, project] of state.sessions) {
37
+ if (project === projectName && !running.has(id)) next.delete(id);
38
+ }
39
+ for (const id of sessionIds) next.set(id, projectName);
40
+ return { sessions: next };
41
+ }),
20
42
  }));
21
43
 
22
44
  /** Selector: true if any session is streaming */
package/.rollout-mark DELETED
@@ -1,2 +0,0 @@
1
- logmark=10732315
2
- {"v":"0.17.50","port":3214,"url":"https://promo-laughing-postposted-personals.trycloudflare.com","tunnelPid":53404,"edgePid":56920,"serverPort":51159}