@hienlh/ppm 0.17.34 → 0.17.37

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 (102) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/CLAUDE.md +1 -0
  3. package/Dockerfile.test +5 -1
  4. package/assets/skills/ppm/SKILL.md +1 -1
  5. package/assets/skills/ppm/references/http-api.md +1 -1
  6. package/bun.lock +5 -0
  7. package/dist/web/assets/{ai-resource-editor-QNfSU0Fi.js → ai-resource-editor-DAIaB4mX.js} +1 -1
  8. package/dist/web/assets/{api-client-DhXpjLdi.js → api-client-BgM59GxL.js} +1 -1
  9. package/dist/web/assets/api-client-BwMdPtp6.js +1 -0
  10. package/dist/web/assets/{api-settings-BUYvGBdi.js → api-settings-D108koFG.js} +1 -1
  11. package/dist/web/assets/api-settings-D2fY0GUc.js +1 -0
  12. package/dist/web/assets/{api-themes-BpoKUPkQ.js → api-themes-cPGCYGB_.js} +1 -1
  13. package/dist/web/assets/{audio-preview-IokB77Cj.js → audio-preview-Df6_blMs.js} +1 -1
  14. package/dist/web/assets/chat-tab-yPXWmX01.js +13 -0
  15. package/dist/web/assets/{code-editor-B8YiBPOX.js → code-editor-CSDB2W8N.js} +2 -2
  16. package/dist/web/assets/{conflict-editor-CY1o5kBO.js → conflict-editor-BqMdj782.js} +1 -1
  17. package/dist/web/assets/{csv-preview-AEg0GB-V.js → csv-preview-BC_BuOzK.js} +1 -1
  18. package/dist/web/assets/{database-viewer-C5Meg0s_.js → database-viewer-DZQDQOye.js} +1 -1
  19. package/dist/web/assets/diff-viewer-CaKD5pR5.js +4 -0
  20. package/dist/web/assets/docx-preview-MBY4op8y.js +12 -0
  21. package/dist/web/assets/{edit-diff-preview-_CXUdwsF.js → edit-diff-preview-D1TEK7Jz.js} +1 -1
  22. package/dist/web/assets/{extension-webview-CQceYb7O.js → extension-webview-CMtucoeg.js} +1 -1
  23. package/dist/web/assets/git-log-panel-DWZUIupS.js +1 -0
  24. package/dist/web/assets/{glide-data-grid-cGxkgluR.js → glide-data-grid-SLs8Xp6G.js} +1 -1
  25. package/dist/web/assets/{group-chat-tab-B-eA5ytm.js → group-chat-tab-Celp6ihW.js} +1 -1
  26. package/dist/web/assets/image-gallery-CObzTisL.js +1 -0
  27. package/dist/web/assets/{image-preview-DRUzYptI.js → image-preview-lRcpEoEk.js} +1 -1
  28. package/dist/web/assets/index-BkH2b_6W.js +50 -0
  29. package/dist/web/assets/index-eW0VEaRF.css +2 -0
  30. package/dist/web/assets/keybindings-store-CtfEQL3U.js +1 -0
  31. package/dist/web/assets/markdown-renderer-D9r_4-qe.js +3 -0
  32. package/dist/web/assets/markdown-renderer-DkYo6o9f.js +1 -0
  33. package/dist/web/assets/notification-store-OF6L8lWu.js +1 -0
  34. package/dist/web/assets/panel-store-CpKvXNnm.js +1 -0
  35. package/dist/web/assets/pdf-preview-D17y9l8U.js +1 -0
  36. package/dist/web/assets/{postgres-viewer-BeantJZe.js → postgres-viewer-DgpX_Kyl.js} +1 -1
  37. package/dist/web/assets/project-store-8_AdCduJ.js +1 -0
  38. package/dist/web/assets/{settings-store-DFMz_Oer.js → settings-store-BhZ7VYJD.js} +2 -2
  39. package/dist/web/assets/settings-tab-Cou9P1gO.js +1 -0
  40. package/dist/web/assets/{sql-query-editor-DqU9zfVy.js → sql-query-editor-BIeAQego.js} +1 -1
  41. package/dist/web/assets/sqlite-viewer-SipxlqWM.js +1 -0
  42. package/dist/web/assets/{system-monitor-tab-Bue4p4Vb.js → system-monitor-tab-Cp-rP5HZ.js} +1 -1
  43. package/dist/web/assets/{tab-store-CNxCwdXw.js → tab-store-BQmDVh9c.js} +1 -1
  44. package/dist/web/assets/{terminal-tab-BfNnKU71.js → terminal-tab-C9T_X4oo.js} +1 -1
  45. package/dist/web/assets/tool-cards-BFVmoF_j.js +7 -0
  46. package/dist/web/assets/use-blob-url-BYDGMRMU.js +1 -0
  47. package/dist/web/assets/{use-monaco-theme-5XjAzOzE.js → use-monaco-theme-Bd1GBgB3.js} +1 -1
  48. package/dist/web/assets/{video-preview-DMqLOqmc.js → video-preview-DT8B2-re.js} +1 -1
  49. package/dist/web/index.html +8 -8
  50. package/dist/web/sw.js +1 -1
  51. package/docker-compose.test.yml +5 -1
  52. package/package.json +4 -3
  53. package/src/server/routes/chat.ts +66 -1
  54. package/src/services/image-dimensions.ts +67 -0
  55. package/src/services/transcript-images.ts +174 -0
  56. package/src/shared/tool-result-content.ts +14 -3
  57. package/src/web/components/chat/chat-history-bar.tsx +3 -104
  58. package/src/web/components/chat/message-input.tsx +4 -2
  59. package/src/web/components/chat/message-list.tsx +10 -3
  60. package/src/web/components/chat/session-debug-dialog.tsx +240 -0
  61. package/src/web/components/chat/tool-image-preview.tsx +4 -2
  62. package/src/web/components/group-chat/group-create-dialog.tsx +2 -1
  63. package/src/web/components/layout/editor-panel.tsx +31 -5
  64. package/src/web/components/layout/notification-bell-popover.tsx +5 -1
  65. package/src/web/components/layout/tab-bar.tsx +2 -2
  66. package/src/web/components/shared/image-overlay.tsx +188 -24
  67. package/src/web/components/shared/markdown-context.ts +1 -1
  68. package/src/web/components/shared/markdown-renderer.tsx +3 -1
  69. package/src/web/hooks/use-draft.ts +17 -2
  70. package/src/web/hooks/use-image-transform.ts +276 -0
  71. package/src/web/lib/api-client.ts +50 -7
  72. package/src/web/lib/clipboard.ts +41 -0
  73. package/src/web/lib/file-download.ts +1 -1
  74. package/src/web/lib/image-gallery.ts +25 -0
  75. package/src/web/stores/image-overlay-store.ts +35 -7
  76. package/src/web/stores/panel-store.ts +40 -6
  77. package/src/web/stores/panel-utils.ts +19 -0
  78. package/dist/web/assets/api-client-DNTkryX1.js +0 -1
  79. package/dist/web/assets/api-settings-Bp5HXQCW.js +0 -1
  80. package/dist/web/assets/chat-tab-BZNEvS88.js +0 -13
  81. package/dist/web/assets/diff-viewer-BRRGvZnp.js +0 -4
  82. package/dist/web/assets/docx-preview-DqTX7NDp.js +0 -12
  83. package/dist/web/assets/git-log-panel-D7QlAJqu.js +0 -1
  84. package/dist/web/assets/index-BuX7JkCt.js +0 -50
  85. package/dist/web/assets/index-PX2pZ3bH.css +0 -2
  86. package/dist/web/assets/keybindings-store-B3plqyxF.js +0 -1
  87. package/dist/web/assets/markdown-renderer-BKDbjc3g.js +0 -3
  88. package/dist/web/assets/markdown-renderer-C9Gy8tM2.js +0 -1
  89. package/dist/web/assets/notification-store-jFDauzse.js +0 -1
  90. package/dist/web/assets/panel-store-D-RxcUgH.js +0 -1
  91. package/dist/web/assets/pdf-preview-CzHUqXJB.js +0 -1
  92. package/dist/web/assets/project-store-C1jInVih.js +0 -1
  93. package/dist/web/assets/settings-tab-CqQHhqV1.js +0 -1
  94. package/dist/web/assets/sqlite-viewer-BH891DC0.js +0 -1
  95. package/dist/web/assets/tool-cards-CSOwe7_i.js +0 -7
  96. package/dist/web/assets/use-blob-url-jSRmyXVg.js +0 -1
  97. package/skills-lock.json +0 -11
  98. package/spike-memory-region-dump.ps1 +0 -69
  99. /package/dist/web/assets/{csv-parser-BPS94T2h.js → csv-parser-DVHBcvuE.js} +0 -0
  100. /package/dist/web/assets/{lib-DA3QSc5_.js → lib-DTaN9riD.js} +0 -0
  101. /package/dist/web/assets/{libesm-BS7D9de3.js → libesm-DdtEjhIi.js} +0 -0
  102. /package/dist/web/assets/{vendor-xterm-CsgYwG03.js → vendor-xterm-QDr0jvpJ.js} +0 -0
@@ -20,6 +20,47 @@ export async function copyToClipboard(text: string): Promise<boolean> {
20
20
  return legacyCopy(text);
21
21
  }
22
22
 
23
+ /**
24
+ * Whether an image can be placed on the clipboard at all on this origin.
25
+ *
26
+ * Unlike text there is no `execCommand` fallback for binary data, so on the plain-HTTP
27
+ * origins PPM is often reached from there is nothing to try — callers should disable the
28
+ * action and say why rather than offer a button that always fails.
29
+ */
30
+ export function canCopyImage(): boolean {
31
+ return typeof ClipboardItem !== "undefined" && !!navigator.clipboard?.write;
32
+ }
33
+
34
+ /**
35
+ * Copy an image to the clipboard, converting to PNG first.
36
+ *
37
+ * Browsers accept a very short list of image types on the clipboard — PNG is the only one
38
+ * supported everywhere — so a JPEG or WebP has to be repainted through a canvas.
39
+ */
40
+ export async function copyImageToClipboard(src: string): Promise<boolean> {
41
+ if (!canCopyImage()) return false;
42
+ try {
43
+ const blob = await fetch(src).then((r) => r.blob());
44
+ const png = blob.type === "image/png" ? blob : await toPng(blob);
45
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ async function toPng(blob: Blob): Promise<Blob> {
53
+ const bitmap = await createImageBitmap(blob);
54
+ const canvas = document.createElement("canvas");
55
+ canvas.width = bitmap.width;
56
+ canvas.height = bitmap.height;
57
+ canvas.getContext("2d")?.drawImage(bitmap, 0, 0);
58
+ bitmap.close();
59
+ return new Promise((resolve, reject) => {
60
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("canvas produced no blob"))), "image/png");
61
+ });
62
+ }
63
+
23
64
  function legacyCopy(text: string): boolean {
24
65
  const textarea = document.createElement("textarea");
25
66
  textarea.value = text;
@@ -24,7 +24,7 @@ export async function downloadFolder(projectName: string, folderPath: string): P
24
24
  }
25
25
 
26
26
  /** Hidden <a> tag download trigger — avoids popup blockers */
27
- function triggerDownload(url: string, filename: string): void {
27
+ export function triggerDownload(url: string, filename: string): void {
28
28
  const a = document.createElement("a");
29
29
  a.href = url;
30
30
  a.download = filename;
@@ -0,0 +1,25 @@
1
+ import type { GalleryImage } from "@/stores/image-overlay-store";
2
+
3
+ /** Marks a container whose tagged images form one navigable set. */
4
+ export const GALLERY_ROOT_ATTR = "data-image-gallery";
5
+ /** Marks an `<img>` that belongs to the enclosing gallery. */
6
+ export const GALLERY_ITEM_ATTR = "data-gallery-item";
7
+
8
+ /**
9
+ * Collect the images a viewer may move between, in the order they appear on screen.
10
+ *
11
+ * Read from the DOM rather than a registry because every image already owns a blob URL that
12
+ * is revoked when its component unmounts — so the mounted elements are exactly the set whose
13
+ * sources are still valid, and the DOM already holds them in the order the user sees.
14
+ *
15
+ * The reach is therefore whatever is mounted. The chat transcript is deliberately not
16
+ * virtualised (`message-list.tsx`), so that is the whole conversation today; if it ever gains
17
+ * data windowing, navigation narrows to the loaded window along with everything else.
18
+ */
19
+ export function collectGallery(from: Element | null): GalleryImage[] {
20
+ const root = from?.closest(`[${GALLERY_ROOT_ATTR}]`);
21
+ if (!root) return [];
22
+ return [...root.querySelectorAll<HTMLImageElement>(`img[${GALLERY_ITEM_ATTR}]`)]
23
+ .filter((img) => !!img.src)
24
+ .map((img) => ({ src: img.src, alt: img.alt }));
25
+ }
@@ -1,18 +1,46 @@
1
1
  import { create } from "zustand";
2
2
 
3
+ export interface GalleryImage {
4
+ /** Blob URL or data URL of the image. */
5
+ src: string;
6
+ alt: string;
7
+ }
8
+
3
9
  interface ImageOverlayState {
4
- /** Blob URL or data URL of the image to display */
10
+ /** Images the overlay can move between; a single entry when opened without a gallery. */
11
+ images: GalleryImage[];
12
+ index: number;
13
+ /** Convenience mirrors of the current entry — every existing consumer reads these. */
5
14
  src: string | null;
6
15
  alt: string;
7
- /** Open the overlay with a given image source */
8
- open: (src: string, alt?: string) => void;
9
- /** Close the overlay */
16
+ /**
17
+ * Open the overlay. Passing a gallery lets the arrows walk its siblings; the clicked
18
+ * source decides where in that list the viewer starts.
19
+ */
20
+ open: (src: string, alt?: string, gallery?: GalleryImage[]) => void;
10
21
  close: () => void;
22
+ /** Move by a signed offset, stopping at the ends rather than wrapping. */
23
+ go: (delta: number) => void;
11
24
  }
12
25
 
13
- export const useImageOverlay = create<ImageOverlayState>((set) => ({
26
+ export const useImageOverlay = create<ImageOverlayState>((set, get) => ({
27
+ images: [],
28
+ index: 0,
14
29
  src: null,
15
30
  alt: "",
16
- open: (src, alt = "") => set({ src, alt }),
17
- close: () => set({ src: null, alt: "" }),
31
+ open: (src, alt = "", gallery) => {
32
+ const images = gallery?.length ? gallery : [{ src, alt }];
33
+ const found = images.findIndex((i) => i.src === src);
34
+ const index = found >= 0 ? found : 0;
35
+ const current = images[index]!;
36
+ set({ images, index, src: current.src, alt: current.alt || alt });
37
+ },
38
+ close: () => set({ images: [], index: 0, src: null, alt: "" }),
39
+ go: (delta) => {
40
+ const { images, index } = get();
41
+ const next = index + delta;
42
+ if (next < 0 || next >= images.length) return;
43
+ const current = images[next]!;
44
+ set({ index: next, src: current.src, alt: current.alt });
45
+ },
18
46
  }));
@@ -15,6 +15,7 @@ import {
15
15
  savePanelLayout,
16
16
  loadPanelLayout,
17
17
  deriveTabId,
18
+ visibleTabs,
18
19
  DOCK_PANEL_ID,
19
20
  } from "./panel-utils";
20
21
  import {
@@ -248,10 +249,16 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
248
249
  // Load from localStorage
249
250
  const loaded = loadPanelLayout(projectName);
250
251
  if (loaded && Object.keys(loaded.panels).length > 0) {
251
- // Migrate: remove obsolete tab types from grid panels
252
+ // Migrate: drop obsolete tab types, and tabs belonging to another project.
253
+ // A foreign tab is filtered out of the tab bar, so it can never be closed —
254
+ // and closeTab is the only thing that removes an emptied panel, so leaving
255
+ // one behind wedges its panel permanently (it renders as a blank slot).
256
+ let healed = false;
252
257
  const migratedPanels: typeof loaded.panels = {};
253
258
  for (const [pid, panel] of Object.entries(loaded.panels)) {
254
- const filteredTabs = panel.tabs.filter((t) => !OBSOLETE_TAB_TYPES.has(t.type));
259
+ const filteredTabs = visibleTabs(panel.tabs, projectName)
260
+ .filter((t) => !OBSOLETE_TAB_TYPES.has(t.type));
261
+ if (filteredTabs.length !== panel.tabs.length) healed = true;
255
262
  const filteredHistory = panel.tabHistory.filter(
256
263
  (id) => filteredTabs.some((t) => t.id === id),
257
264
  );
@@ -261,6 +268,22 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
261
268
  migratedPanels[pid] = { ...panel, tabs: filteredTabs, tabHistory: filteredHistory, activeTabId };
262
269
  }
263
270
 
271
+ // Apply the "no empty panel" invariant on restore, not just in closeTab:
272
+ // the filtering above can empty a panel, and a grid id with no panel behind
273
+ // it renders nothing either. The last panel always stays — the grid needs
274
+ // something to render into.
275
+ let migratedGrid = loaded.grid;
276
+ for (const pid of migratedGrid.flat()) {
277
+ if ((migratedPanels[pid]?.tabs.length ?? 0) > 0) continue;
278
+ if (migratedGrid.flat().length <= 1) break;
279
+ migratedGrid = gridRemovePanel(migratedGrid, pid);
280
+ delete migratedPanels[pid];
281
+ healed = true;
282
+ }
283
+ const migratedFocused = migratedGrid.flat().includes(loaded.focusedPanelId)
284
+ ? loaded.focusedPanelId
285
+ : (migratedGrid.flat()[0] ?? loaded.focusedPanelId);
286
+
264
287
  // Merge into flat panels map (keep-alive: old panels stay).
265
288
  // The shared __dock__ panel holds live tabs from ALL projects — union the
266
289
  // loaded project's persisted dock tabs into it rather than replacing, so a
@@ -283,8 +306,8 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
283
306
  // Focus the switched-to project's own dock tab (not another project's).
284
307
  mergedDockPanel.activeTabId = pickDockActiveTab(mergedDockPanel, projectName);
285
308
  const mergedPanels = { ...panels, ...migratedPanels, [DOCK_PANEL_ID]: mergedDockPanel };
286
- newProjectGrids[projectName] = loaded.grid;
287
- newProjectFocused[projectName] = loaded.focusedPanelId;
309
+ newProjectGrids[projectName] = migratedGrid;
310
+ newProjectFocused[projectName] = migratedFocused;
288
311
  const restoredDock = collapseRestoredDockOnMobile(
289
312
  loaded.dock ?? restoreDockForProject(projectName, newProjectDock),
290
313
  get().isMobile(),
@@ -292,13 +315,24 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
292
315
  set({
293
316
  currentProject: projectName,
294
317
  panels: mergedPanels,
295
- grid: loaded.grid,
296
- focusedPanelId: loaded.focusedPanelId,
318
+ grid: migratedGrid,
319
+ focusedPanelId: migratedFocused,
297
320
  projectGrids: newProjectGrids,
298
321
  projectFocused: newProjectFocused,
299
322
  dock: restoredDock,
300
323
  projectDock: newProjectDock,
301
324
  });
325
+ // Write the healed layout back so the repair sticks (and reaches the server
326
+ // blob), instead of being redone on every load.
327
+ if (healed) {
328
+ savePanelLayout(projectName, {
329
+ panels: migratedPanels,
330
+ grid: migratedGrid,
331
+ focusedPanelId: migratedFocused,
332
+ dock: restoredDock,
333
+ dockPanel: mergedDockPanel,
334
+ });
335
+ }
302
336
  } else {
303
337
  // Create empty layout — EmptyPanel will show quick-open buttons
304
338
  const p = createPanel();
@@ -103,6 +103,25 @@ export function gridRemovePanel(grid: string[][], panelId: string): string[][] {
103
103
  .filter((col) => col.length > 0);
104
104
  }
105
105
 
106
+ /**
107
+ * The subset of `tabs` that the given project is allowed to see.
108
+ *
109
+ * A grid panel can end up holding another project's tab (a race in openTab during
110
+ * a project switch, or a tab stamped with a project name that does not exist). The
111
+ * tab bar hides those, so every other "is this panel empty?" check must agree —
112
+ * otherwise the panel renders neither tabs nor the empty state, and since it has no
113
+ * closable tab, the auto-close in closeTab can never fire and the panel is stuck.
114
+ *
115
+ * Callers spell "no active project" two ways: TabBar passes null, while the
116
+ * per-project layout key is the "__global__" sentinel. Both mean unfiltered, so
117
+ * they are normalized here — a split between them would resurrect the exact
118
+ * disagreement this helper exists to prevent.
119
+ */
120
+ export function visibleTabs(tabs: Tab[], projectName: string | null): Tab[] {
121
+ if (!projectName || projectName === "__global__") return tabs;
122
+ return tabs.filter((t) => !t.projectId || t.projectId === projectName);
123
+ }
124
+
106
125
  export function findPanelPosition(grid: string[][], panelId: string): { row: number; col: number } | null {
107
126
  for (let r = 0; r < grid.length; r++) {
108
127
  const c = grid[r]!.indexOf(panelId);
@@ -1 +0,0 @@
1
- import"./vendor-markdown-CKTlmXbz.js";import"./vendor-ui-DMRGWRuc.js";import{r as e,t}from"./api-client-DhXpjLdi.js";export{t as api,e as projectUrl};
@@ -1 +0,0 @@
1
- import"./vendor-markdown-CKTlmXbz.js";import"./vendor-ui-DMRGWRuc.js";import"./api-client-DhXpjLdi.js";import{v as e}from"./api-settings-BUYvGBdi.js";export{e as updateDeviceName};