@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
@@ -29,11 +29,22 @@ function isImageBlock(block: unknown): block is { source?: { data?: unknown } }
29
29
 
30
30
  function imagePlaceholder(block: { source?: { data?: unknown } }): string {
31
31
  const data = block.source?.data;
32
- if (typeof data !== "string" || data.length === 0) return "[image]";
33
- return `[image · ${formatBytes(base64ByteLength(data))}]`;
32
+ if (typeof data !== "string" || data.length === 0) return imagePlaceholderText();
33
+ return imagePlaceholderText(base64ByteLength(data));
34
34
  }
35
35
 
36
- function base64ByteLength(data: string): number {
36
+ /**
37
+ * The exact text that stands in for an image block.
38
+ *
39
+ * Anything that removes an image from a transcript must use this, so that
40
+ * `resultHasImagePlaceholder` still recognises the result as having carried an image and the
41
+ * chat card keeps hiding the textual output in favour of the rendered file.
42
+ */
43
+ export function imagePlaceholderText(bytes?: number): string {
44
+ return bytes == null ? "[image]" : `[image · ${formatBytes(bytes)}]`;
45
+ }
46
+
47
+ export function base64ByteLength(data: string): number {
37
48
  const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
38
49
  return Math.max(0, Math.floor((data.length * 3) / 4) - padding);
39
50
  }
@@ -1,23 +1,19 @@
1
1
  import { useState, useEffect, useCallback, useRef, type MouseEvent } from "react";
2
- import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Circle, Bug, ClipboardCheck, Copy, Pin, PinOff, Trash2, Users, Bot, Tags, CalendarX2 } from "lucide-react";
2
+ import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Circle, Pin, PinOff, Trash2, Users, Bot, Tags, CalendarX2 } from "lucide-react";
3
3
  import { Activity } from "lucide-react";
4
4
  import { api, projectUrl } from "@/lib/api-client";
5
- import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
6
- import { BottomSheet } from "@/components/ui/mobile-bottom-sheet";
7
- import { Button } from "@/components/ui/button";
8
- import { useIsMobile } from "@/hooks/use-is-mobile";
9
5
  import { useTabStore } from "@/stores/tab-store";
10
6
  import { useNotificationStore, notificationTint } from "@/stores/notification-store";
11
7
  import { cn } from "@/lib/utils";
12
8
  import { AISettingsSection } from "@/components/settings/ai-settings-section";
13
9
  import { TagSettingsSection } from "@/components/settings/tag-settings-section";
14
10
  import { SessionContextMenu } from "./session-context-menu";
11
+ import { SessionDebugButton } from "./session-debug-dialog";
15
12
  import { UsageDetailPanel } from "./usage-badge";
16
13
  import { CodexUsagePanel } from "./codex-usage-panel";
17
14
  import { TeamActivityPanel } from "./team-activity-panel";
18
15
  import { ProviderBadge } from "./provider-selector";
19
16
  import { formatRelativeDate } from "@/lib/format-date";
20
- import { copyToClipboard } from "@/lib/clipboard";
21
17
  import { useDebouncedValue } from "@/hooks/use-debounced-value";
22
18
  import type { SessionInfo, SessionListResponse, ProjectTag } from "../../../types/chat";
23
19
  import type { UsageInfo } from "../../../types/chat";
@@ -64,103 +60,6 @@ function pctColor(pct: number): string {
64
60
  return "text-success";
65
61
  }
66
62
 
67
- /** Live render weight of the currently open transcript (this button sits in the
68
- * active session's toolbar, so the visible chat scroller IS this session). */
69
- function gatherRenderStats(): string[] {
70
- let scroller: HTMLElement | null = null;
71
- for (const el of document.querySelectorAll<HTMLElement>(".overflow-y-auto")) {
72
- if (el.clientHeight < 100 || !el.querySelector("[data-msg-index]")) continue;
73
- if (!scroller || el.scrollHeight > scroller.scrollHeight) scroller = el;
74
- }
75
- const lines: string[] = [];
76
- if (scroller) {
77
- lines.push(`Rendered messages: ${scroller.querySelectorAll("[data-msg-index]").length}`);
78
- lines.push(`Transcript DOM nodes: ${scroller.querySelectorAll("*").length}`);
79
- lines.push(`Transcript height: ${Math.round(scroller.scrollHeight)}px`);
80
- }
81
- // Chrome-only; page-wide (not just the transcript) but the best in-app proxy.
82
- const mem = (performance as unknown as { memory?: { usedJSHeapSize: number } }).memory;
83
- if (mem) lines.push(`JS heap (page): ${(mem.usedJSHeapSize / 1048576).toFixed(1)} MB`);
84
- return lines;
85
- }
86
-
87
- const fmtMB = (bytes: number) => `${(bytes / 1048576).toFixed(2)} MB`;
88
-
89
- function DebugCopyButton({ sessionId, projectName }: { sessionId: string; projectName: string }) {
90
- const [open, setOpen] = useState(false);
91
- const [info, setInfo] = useState<string | null>(null);
92
- const [copied, setCopied] = useState(false);
93
- const isMobile = useIsMobile();
94
-
95
- const openPopup = () => {
96
- setOpen(true);
97
- setCopied(false);
98
- setInfo(null);
99
- api.get<{ ppmSessionId: string; sdkSessionId: string; jsonlPath: string | null; projectPath: string; jsonlSizeBytes: number | null; jsonlLines: number | null }>(
100
- `${projectUrl(projectName)}/chat/sessions/${sessionId}/debug?project=${encodeURIComponent(projectName)}`,
101
- ).then((data) => {
102
- const weight = data.jsonlSizeBytes != null
103
- ? ` (${fmtMB(data.jsonlSizeBytes)}${data.jsonlLines != null ? `, ${data.jsonlLines} records` : ""})`
104
- : "";
105
- setInfo([
106
- `PPM Session: ${data.ppmSessionId}`,
107
- `SDK Session: ${data.sdkSessionId}`,
108
- data.jsonlPath ? `JSONL: ${data.jsonlPath}${weight}` : `JSONL: not found`,
109
- data.projectPath ? `Project: ${data.projectPath}` : null,
110
- ...gatherRenderStats(),
111
- ].filter(Boolean).join("\n"));
112
- }).catch(() => setInfo("Failed to load debug info"));
113
- };
114
-
115
- const copy = () => {
116
- if (!info) return;
117
- copyToClipboard(info).then((ok) => {
118
- if (!ok) return;
119
- setCopied(true);
120
- setTimeout(() => setCopied(false), 1500);
121
- });
122
- };
123
-
124
- const body = (
125
- <div className="flex flex-col gap-3">
126
- <h2 className="flex items-center gap-2 text-sm font-semibold text-foreground">
127
- <Bug className="size-4 text-primary" />
128
- Session debug info
129
- </h2>
130
- <pre className="max-h-[50vh] overflow-y-auto whitespace-pre-wrap break-all rounded-md border border-border bg-surface p-2.5 font-mono text-[11px] leading-relaxed text-text-secondary select-text">
131
- {info ?? "Loading..."}
132
- </pre>
133
- <Button size="sm" onClick={copy} disabled={!info} className="w-full">
134
- {copied ? <><ClipboardCheck className="size-3.5" /> Copied!</> : <><Copy className="size-3.5" /> Copy</>}
135
- </Button>
136
- </div>
137
- );
138
-
139
- return (
140
- <>
141
- <button
142
- onClick={openPopup}
143
- className="p-1 rounded text-text-subtle hover:text-text-secondary hover:bg-surface-elevated transition-colors"
144
- title="Session debug info"
145
- >
146
- <Bug className="size-3" />
147
- </button>
148
- {isMobile ? (
149
- <BottomSheet open={open} onClose={() => setOpen(false)} className="popover-solid">
150
- <div className="px-4 pb-4 pt-1">{body}</div>
151
- </BottomSheet>
152
- ) : (
153
- <Dialog open={open} onOpenChange={setOpen}>
154
- <DialogContent className="sm:max-w-lg">
155
- <DialogTitle className="sr-only">Session debug info</DialogTitle>
156
- {body}
157
- </DialogContent>
158
- </Dialog>
159
- )}
160
- </>
161
- );
162
- }
163
-
164
63
  export function ChatHistoryBar({
165
64
  projectName, usageInfo, usageLoading, refreshUsage, lastFetchedAt,
166
65
  sessionId, providerId, onSelectSession, onBugReport, isConnected, onReload,
@@ -477,7 +376,7 @@ export function ChatHistoryBar({
477
376
 
478
377
  {/* Debug info — copy session IDs + JSONL path */}
479
378
  {sessionId && (
480
- <DebugCopyButton sessionId={sessionId} projectName={projectName} />
379
+ <SessionDebugButton sessionId={sessionId} projectName={projectName} />
481
380
  )}
482
381
 
483
382
  {/* Reload messages + connection status */}
@@ -252,9 +252,11 @@ export const MessageInput = memo(function MessageInput({
252
252
  return () => window.removeEventListener("ppm:send-to-chat", handler);
253
253
  }, [getVisibleTextarea]);
254
254
 
255
- // Apply initialValue when it changes (e.g. "Ask AI" from command palette)
255
+ // Apply initialValue when it changes (e.g. "Ask AI" from command palette).
256
+ // A restored draft can land after the input is already on screen, so never
257
+ // write over text that is already there — what was typed wins.
256
258
  useEffect(() => {
257
- if (initialValue) {
259
+ if (initialValue && !valueRef.current) {
258
260
  writeTextareas(initialValue);
259
261
  // Focus and move cursor to end
260
262
  setTimeout(() => {
@@ -57,6 +57,7 @@ import { useTabStore } from "@/stores/tab-store";
57
57
  import { api } from "@/lib/api-client";
58
58
  import { useProjectStore } from "@/stores/project-store";
59
59
  import { useImageOverlay } from "@/stores/image-overlay-store";
60
+ import { collectGallery, GALLERY_ITEM_ATTR, GALLERY_ROOT_ATTR } from "@/lib/image-gallery";
60
61
 
61
62
  interface MessageListProps {
62
63
  messages: ChatMessage[];
@@ -359,6 +360,7 @@ export function MessageList({
359
360
  )}
360
361
  <div
361
362
  ref={setScrollRef}
363
+ {...{ [GALLERY_ROOT_ATTR]: "" }}
362
364
  className="flex-1 overflow-y-auto overflow-x-hidden [overflow-anchor:none]"
363
365
  style={{ WebkitOverflowScrolling: "touch", overscrollBehavior: "contain" }}
364
366
  >
@@ -981,11 +983,16 @@ function AuthImage({ src, alt }: { src: string; alt: string }) {
981
983
  }
982
984
 
983
985
  return (
984
- <button type="button" onClick={() => openOverlay(blobUrl, alt)} className="block text-left">
986
+ <button
987
+ type="button"
988
+ onClick={(e) => openOverlay(blobUrl, alt, collectGallery(e.currentTarget))}
989
+ className="block text-left"
990
+ >
985
991
  <img
986
992
  src={blobUrl}
987
993
  alt={alt}
988
994
  style={box}
995
+ {...{ [GALLERY_ITEM_ATTR]: "" }}
989
996
  onLoad={(e) => {
990
997
  const el = e.currentTarget;
991
998
  const c = imageBlobCache.get(src);
@@ -1007,11 +1014,11 @@ function AuthImageThumbnail({ filePath, projectName }: { filePath: string; proje
1007
1014
  return (
1008
1015
  <button
1009
1016
  type="button"
1010
- onClick={() => blobUrl && openOverlay(blobUrl, name)}
1017
+ onClick={(e) => blobUrl && openOverlay(blobUrl, name, collectGallery(e.currentTarget))}
1011
1018
  className="flex items-center gap-1 rounded-md border border-border/60 bg-background/40 px-1.5 py-0.5 text-[11px] text-text-secondary hover:bg-surface transition-colors cursor-pointer"
1012
1019
  >
1013
1020
  {blobUrl ? (
1014
- <img src={blobUrl} alt={name} className="size-4 rounded-sm object-cover shrink-0" />
1021
+ <img src={blobUrl} alt={name} {...{ [GALLERY_ITEM_ATTR]: "" }} className="size-4 rounded-sm object-cover shrink-0" />
1015
1022
  ) : error ? (
1016
1023
  <ImageIcon className="size-3 shrink-0" />
1017
1024
  ) : (
@@ -0,0 +1,240 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Bug, ClipboardCheck, Copy, ImageOff, Loader2, TriangleAlert } from "lucide-react";
3
+ import { api, projectUrl } from "@/lib/api-client";
4
+ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
5
+ import { BottomSheet } from "@/components/ui/mobile-bottom-sheet";
6
+ import { Button } from "@/components/ui/button";
7
+ import { useIsMobile } from "@/hooks/use-is-mobile";
8
+ import { copyToClipboard } from "@/lib/clipboard";
9
+
10
+ interface DebugInfo {
11
+ ppmSessionId: string;
12
+ sdkSessionId: string;
13
+ jsonlPath: string | null;
14
+ projectPath: string;
15
+ jsonlSizeBytes: number | null;
16
+ jsonlLines: number | null;
17
+ }
18
+
19
+ interface ImageAudit {
20
+ total: number;
21
+ oversized: number;
22
+ bytes: number;
23
+ oversizedBytes: number;
24
+ largestSide: number;
25
+ limit: number;
26
+ }
27
+
28
+ const fmtMB = (bytes: number) => `${(bytes / 1048576).toFixed(2)} MB`;
29
+
30
+ function fmtSize(bytes: number): string {
31
+ if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
32
+ return `${Math.max(1, Math.round(bytes / 1024))} KB`;
33
+ }
34
+
35
+ /**
36
+ * Live render weight of the currently open transcript.
37
+ *
38
+ * This dialog opens from the active session's toolbar, so the tallest scroller holding
39
+ * message nodes is that session's transcript.
40
+ */
41
+ function gatherRenderStats(): string[] {
42
+ let scroller: HTMLElement | null = null;
43
+ for (const el of document.querySelectorAll<HTMLElement>(".overflow-y-auto")) {
44
+ if (el.clientHeight < 100 || !el.querySelector("[data-msg-index]")) continue;
45
+ if (!scroller || el.scrollHeight > scroller.scrollHeight) scroller = el;
46
+ }
47
+ const lines: string[] = [];
48
+ if (scroller) {
49
+ lines.push(`Rendered messages: ${scroller.querySelectorAll("[data-msg-index]").length}`);
50
+ lines.push(`Transcript DOM nodes: ${scroller.querySelectorAll("*").length}`);
51
+ lines.push(`Transcript height: ${Math.round(scroller.scrollHeight)}px`);
52
+ }
53
+ // Chrome-only; page-wide (not just the transcript) but the best in-app proxy.
54
+ const mem = (performance as unknown as { memory?: { usedJSHeapSize: number } }).memory;
55
+ if (mem) lines.push(`JS heap (page): ${(mem.usedJSHeapSize / 1048576).toFixed(1)} MB`);
56
+ return lines;
57
+ }
58
+
59
+ /**
60
+ * Removal of the image payloads a transcript replays on every turn.
61
+ *
62
+ * The transcript is re-sent in full each time the session resumes, so every image it
63
+ * carries is paid for again. Images past the API's per-image dimension cap are rejected
64
+ * outright once a request holds several of them, so those cost bandwidth and raise an
65
+ * error while adding nothing — which is why they get their own button.
66
+ *
67
+ * Nothing here runs on its own: a transcript is the record of a session, and trimming it
68
+ * is the user's call.
69
+ */
70
+ function TranscriptImages({ sessionId, projectName }: { sessionId: string; projectName: string }) {
71
+ const [audit, setAudit] = useState<ImageAudit | null>(null);
72
+ const [error, setError] = useState<string | null>(null);
73
+ const [busy, setBusy] = useState<"oversized" | "all" | null>(null);
74
+ const [freed, setFreed] = useState<{ removed: number; bytes: number } | null>(null);
75
+
76
+ useEffect(() => {
77
+ let live = true;
78
+ api.get<ImageAudit>(
79
+ `${projectUrl(projectName)}/chat/sessions/${sessionId}/images?project=${encodeURIComponent(projectName)}`,
80
+ ).then((a) => { if (live) setAudit(a); })
81
+ .catch((e: Error) => { if (live) setError(e.message); });
82
+ return () => { live = false; };
83
+ }, [sessionId, projectName]);
84
+
85
+ const strip = (mode: "oversized" | "all") => {
86
+ setBusy(mode);
87
+ setError(null);
88
+ api.post<{ removed: number; bytesFreed: number; remaining: ImageAudit }>(
89
+ `${projectUrl(projectName)}/chat/sessions/${sessionId}/images/strip?project=${encodeURIComponent(projectName)}`,
90
+ { mode },
91
+ ).then((r) => {
92
+ setAudit(r.remaining);
93
+ setFreed({ removed: r.removed, bytes: r.bytesFreed });
94
+ }).catch((e: Error) => setError(e.message)).finally(() => setBusy(null));
95
+ };
96
+
97
+ if (error && !audit) return <p className="text-[11px] text-error">{error}</p>;
98
+ if (!audit) return <p className="text-[11px] text-text-subtle">Scanning transcript for images...</p>;
99
+
100
+ return (
101
+ <div className="flex flex-col gap-2">
102
+ <h3 className="text-xs font-semibold text-foreground">Transcript images</h3>
103
+
104
+ {audit.total === 0 ? (
105
+ <p className="text-[11px] text-text-subtle">
106
+ No images in tool results{freed ? " — transcript is clean." : "."}
107
+ </p>
108
+ ) : (
109
+ <p className="text-[11px] text-text-secondary">
110
+ {audit.total} image{audit.total === 1 ? "" : "s"} in tool results · {fmtSize(audit.bytes)} of base64,
111
+ replayed on every turn.
112
+ </p>
113
+ )}
114
+
115
+ {audit.oversized > 0 && (
116
+ <div className="flex gap-2 rounded-md border border-warning/40 bg-warning/10 p-2 text-[11px] text-text-secondary">
117
+ <TriangleAlert className="mt-px size-3.5 shrink-0 text-warning" />
118
+ <span>
119
+ {audit.oversized} image{audit.oversized === 1 ? " is" : "s are"} wider than {audit.limit}px
120
+ (largest {audit.largestSide}px). The API rejects those once a request carries several images,
121
+ so they already contribute nothing — removing them loses no context.
122
+ </span>
123
+ </div>
124
+ )}
125
+
126
+ {freed && (
127
+ <p className="text-[11px] text-success">
128
+ Removed {freed.removed} image{freed.removed === 1 ? "" : "s"}, freed {fmtSize(freed.bytes)}.
129
+ </p>
130
+ )}
131
+ {error && <p className="text-[11px] text-error">{error}</p>}
132
+
133
+ <div className="flex flex-col gap-1.5 sm:flex-row">
134
+ <Button
135
+ size="sm"
136
+ variant="outline"
137
+ className="flex-1"
138
+ disabled={busy !== null || audit.oversized === 0}
139
+ onClick={() => strip("oversized")}
140
+ >
141
+ {busy === "oversized" ? <Loader2 className="size-3.5 animate-spin" /> : <ImageOff className="size-3.5" />}
142
+ Remove oversized{audit.oversized > 0 ? ` (${audit.oversized} · ${fmtSize(audit.oversizedBytes)})` : ""}
143
+ </Button>
144
+ <Button
145
+ size="sm"
146
+ variant="destructive"
147
+ className="flex-1"
148
+ disabled={busy !== null || audit.total === 0}
149
+ onClick={() => strip("all")}
150
+ >
151
+ {busy === "all" ? <Loader2 className="size-3.5 animate-spin" /> : <ImageOff className="size-3.5" />}
152
+ Remove all{audit.total > 0 ? ` (${audit.total} · ${fmtSize(audit.bytes)})` : ""}
153
+ </Button>
154
+ </div>
155
+ <p className="text-[11px] text-text-subtle">
156
+ Images you attached to a message are never touched. A removed tool image is replaced by its
157
+ placeholder text; the chat still shows the file, re-read from disk.
158
+ </p>
159
+ </div>
160
+ );
161
+ }
162
+
163
+ /** Toolbar button opening the session's debug facts and transcript maintenance. */
164
+ export function SessionDebugButton({ sessionId, projectName }: { sessionId: string; projectName: string }) {
165
+ const [open, setOpen] = useState(false);
166
+ const [info, setInfo] = useState<string | null>(null);
167
+ const [copied, setCopied] = useState(false);
168
+ const isMobile = useIsMobile();
169
+
170
+ const openPopup = () => {
171
+ setOpen(true);
172
+ setCopied(false);
173
+ setInfo(null);
174
+ api.get<DebugInfo>(
175
+ `${projectUrl(projectName)}/chat/sessions/${sessionId}/debug?project=${encodeURIComponent(projectName)}`,
176
+ ).then((data) => {
177
+ const weight = data.jsonlSizeBytes != null
178
+ ? ` (${fmtMB(data.jsonlSizeBytes)}${data.jsonlLines != null ? `, ${data.jsonlLines} records` : ""})`
179
+ : "";
180
+ setInfo([
181
+ `PPM Session: ${data.ppmSessionId}`,
182
+ `SDK Session: ${data.sdkSessionId}`,
183
+ data.jsonlPath ? `JSONL: ${data.jsonlPath}${weight}` : `JSONL: not found`,
184
+ data.projectPath ? `Project: ${data.projectPath}` : null,
185
+ ...gatherRenderStats(),
186
+ ].filter(Boolean).join("\n"));
187
+ }).catch(() => setInfo("Failed to load debug info"));
188
+ };
189
+
190
+ const copy = () => {
191
+ if (!info) return;
192
+ copyToClipboard(info).then((ok) => {
193
+ if (!ok) return;
194
+ setCopied(true);
195
+ setTimeout(() => setCopied(false), 1500);
196
+ });
197
+ };
198
+
199
+ const body = (
200
+ <div className="flex flex-col gap-3">
201
+ <h2 className="flex items-center gap-2 text-sm font-semibold text-foreground">
202
+ <Bug className="size-4 text-primary" />
203
+ Session debug info
204
+ </h2>
205
+ <pre className="max-h-[35vh] overflow-y-auto whitespace-pre-wrap break-all rounded-md border border-border bg-surface p-2.5 font-mono text-[11px] leading-relaxed text-text-secondary select-text">
206
+ {info ?? "Loading..."}
207
+ </pre>
208
+ <Button size="sm" onClick={copy} disabled={!info} className="w-full">
209
+ {copied ? <><ClipboardCheck className="size-3.5" /> Copied!</> : <><Copy className="size-3.5" /> Copy</>}
210
+ </Button>
211
+ <div className="border-t border-border pt-3">
212
+ {open && <TranscriptImages sessionId={sessionId} projectName={projectName} />}
213
+ </div>
214
+ </div>
215
+ );
216
+
217
+ return (
218
+ <>
219
+ <button
220
+ onClick={openPopup}
221
+ className="p-1 rounded text-text-subtle hover:text-text-secondary hover:bg-surface-elevated transition-colors"
222
+ title="Session debug info"
223
+ >
224
+ <Bug className="size-3" />
225
+ </button>
226
+ {isMobile ? (
227
+ <BottomSheet open={open} onClose={() => setOpen(false)} className="popover-solid">
228
+ <div className="max-h-[85vh] overflow-y-auto px-4 pb-4 pt-1">{body}</div>
229
+ </BottomSheet>
230
+ ) : (
231
+ <Dialog open={open} onOpenChange={setOpen}>
232
+ <DialogContent className="sm:max-w-lg">
233
+ <DialogTitle className="sr-only">Session debug info</DialogTitle>
234
+ {body}
235
+ </DialogContent>
236
+ </Dialog>
237
+ )}
238
+ </>
239
+ );
240
+ }
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
2
2
  import { useShallow } from "zustand/react/shallow";
3
3
  import { useBlobUrl } from "@/hooks/use-blob-url";
4
4
  import { useImageOverlay } from "@/stores/image-overlay-store";
5
+ import { collectGallery, GALLERY_ITEM_ATTR } from "@/lib/image-gallery";
5
6
  import { useTabStore } from "@/stores/tab-store";
6
7
  import { copyToClipboard } from "@/lib/clipboard";
7
8
  import { basename } from "@/lib/utils";
@@ -107,8 +108,8 @@ export function ToolImagePreview({
107
108
  void copyToClipboard(filePath).then(() => setCopied(true));
108
109
  }
109
110
 
110
- function openFull() {
111
- if (blobUrl) openOverlay(blobUrl, name);
111
+ function openFull(e: { currentTarget: Element }) {
112
+ if (blobUrl) openOverlay(blobUrl, name, collectGallery(e.currentTarget));
112
113
  }
113
114
 
114
115
  // Loading: the same box as the loaded state, so the transcript never shifts under the
@@ -155,6 +156,7 @@ export function ToolImagePreview({
155
156
  <img
156
157
  src={blobUrl}
157
158
  alt={name}
159
+ {...{ [GALLERY_ITEM_ATTR]: "" }}
158
160
  onLoad={(e) => measure(e.currentTarget)}
159
161
  onError={() => setDecodeFailed(true)}
160
162
  className={
@@ -6,6 +6,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
6
6
  import { Button } from "@/components/ui/button";
7
7
  import { ModelSelector } from "@/components/chat/model-selector";
8
8
  import { createGroup, type CreateMemberInput } from "@/lib/api-group-chat";
9
+ import { randomId } from "@/lib/utils";
9
10
  import type { Group } from "../../../types/group-chat";
10
11
 
11
12
  interface GroupCreateDialogProps {
@@ -27,7 +28,7 @@ interface DraftMember {
27
28
  const PALETTE = ["#6366f1", "#ec4899", "#14b8a6", "#f59e0b", "#8b5cf6", "#ef4444"];
28
29
 
29
30
  function newMember(): DraftMember {
30
- return { key: crypto.randomUUID(), name: "", persona: "", model: null };
31
+ return { key: randomId(), name: "", persona: "", model: null };
31
32
  }
32
33
 
33
34
  export function GroupCreateDialog({
@@ -1,5 +1,5 @@
1
1
  import { useCallback } from "react";
2
- import { Terminal, MessageSquare, FilePlus } from "lucide-react";
2
+ import { Terminal, MessageSquare, FilePlus, X } from "lucide-react";
3
3
  import { usePanelStore } from "@/stores/panel-store";
4
4
  import { useProjectStore } from "@/stores/project-store";
5
5
  import { useTabStore, type TabType } from "@/stores/tab-store";
@@ -8,6 +8,7 @@ import type { SessionInfo } from "../../../types/chat";
8
8
  import { TabBar } from "./tab-bar";
9
9
  import { SplitDropOverlay } from "./split-drop-overlay";
10
10
  import { registerPanelSlot } from "./tab-pool";
11
+ import { visibleTabs } from "@/stores/panel-utils";
11
12
  import { cn } from "@/lib/utils";
12
13
 
13
14
  const QUICK_OPEN_TABS: { type: TabType; label: string; icon: React.ElementType }[] = [
@@ -40,6 +41,12 @@ export function EditorPanel({ panelId, projectName }: EditorPanelProps) {
40
41
 
41
42
  if (!panel) return null;
42
43
 
44
+ // Judge emptiness the same way TabBar does. A panel holding only another
45
+ // project's tab shows no tab chips, so treating it as non-empty here would
46
+ // render neither tabs nor the empty state — a blank panel with nothing to
47
+ // close. See visibleTabs().
48
+ const isEmpty = visibleTabs(panel.tabs, projectName).length === 0;
49
+
43
50
  return (
44
51
  <div
45
52
  className={cn(
@@ -51,18 +58,27 @@ export function EditorPanel({ panelId, projectName }: EditorPanelProps) {
51
58
  >
52
59
  <TabBar panelId={panelId} />
53
60
 
54
- <div className="flex-1 overflow-hidden relative" data-panel-drop-zone={panelId}>
55
- {panel.tabs.length === 0 && <EmptyPanel panelId={panelId} />}
61
+ <div
62
+ className="flex-1 overflow-hidden relative"
63
+ data-panel-drop-zone={panelId}
64
+ // Middle-click an empty panel to close it (matches VS Code).
65
+ onAuxClick={(e) => {
66
+ if (e.button !== 1 || !isEmpty) return;
67
+ e.preventDefault();
68
+ usePanelStore.getState().closePanel(panelId);
69
+ }}
70
+ >
71
+ {isEmpty && <EmptyPanel panelId={panelId} canClose={panelCount > 1} />}
56
72
  {/* Always render the slot so TabPool can portal into it immediately.
57
73
  Hidden when empty to let EmptyPanel show through. */}
58
- <div ref={slotCallbackRef} className="absolute inset-0" style={panel.tabs.length === 0 ? { display: "none" } : undefined} />
74
+ <div ref={slotCallbackRef} className="absolute inset-0" style={isEmpty ? { display: "none" } : undefined} />
59
75
  <SplitDropOverlay panelId={panelId} />
60
76
  </div>
61
77
  </div>
62
78
  );
63
79
  }
64
80
 
65
- function EmptyPanel({ panelId }: { panelId: string }) {
81
+ function EmptyPanel({ panelId, canClose }: { panelId: string; canClose: boolean }) {
66
82
  const activeProject = useProjectStore((s) => s.activeProject);
67
83
 
68
84
  function openTab(type: TabType) {
@@ -111,6 +127,16 @@ function EmptyPanel({ panelId }: { panelId: string }) {
111
127
  })}
112
128
  </div>
113
129
 
130
+ {canClose && (
131
+ <button
132
+ onClick={() => usePanelStore.getState().closePanel(panelId)}
133
+ className="flex items-center gap-1.5 px-4 py-3.5 rounded-md text-xs text-text-subtle hover:text-foreground active:bg-surface-elevated transition-colors"
134
+ >
135
+ <X className="size-3.5" />
136
+ Close Panel
137
+ </button>
138
+ )}
139
+
114
140
  <SessionListPanel
115
141
  projectName={activeProject?.name}
116
142
  onSelectSession={openSession}
@@ -30,7 +30,11 @@ export function NotificationBellPopover({ expanded }: { expanded: boolean }) {
30
30
 
31
31
  const handleGoToSession = (sessionId: string, projectName: string) => {
32
32
  const target = projects.find((p) => p.name === projectName);
33
- if (target) setActiveProject(target);
33
+ // projectName is a grouping key, and notifications with no project fall into the
34
+ // "Unknown" bucket. Opening a tab stamped with a project that does not exist makes
35
+ // it invisible in every project — and unclosable, which wedges its panel forever.
36
+ if (!target) return;
37
+ setActiveProject(target);
34
38
  openTab({
35
39
  type: "chat",
36
40
  title: "Chat",
@@ -16,7 +16,7 @@ import {
16
16
  } from "lucide-react";
17
17
  import { useTabStore, type TabType } from "@/stores/tab-store";
18
18
  import { usePanelStore } from "@/stores/panel-store";
19
- import { DOCK_PANEL_ID } from "@/stores/panel-utils";
19
+ import { DOCK_PANEL_ID, visibleTabs } from "@/stores/panel-utils";
20
20
  import { PanelBottom, Grid2x2 } from "lucide-react";
21
21
  import { useProjectStore } from "@/stores/project-store";
22
22
  import { useFileStore, type FileNode } from "@/stores/file-store";
@@ -85,7 +85,7 @@ export const TabBar = memo(function TabBar({ panelId }: TabBarProps) {
85
85
  const panel = usePanelStore((s) => panelId ? s.panels[panelId] : s.panels[s.focusedPanelId]);
86
86
  const projectName = activeProject?.name ?? null;
87
87
  // Filter out cross-project tabs (race condition in openTab during project switch)
88
- const tabs = (panel?.tabs ?? []).filter((t) => !t.projectId || !projectName || t.projectId === projectName);
88
+ const tabs = visibleTabs(panel?.tabs ?? [], projectName);
89
89
  const activeTabId = panel?.activeTabId ?? null;
90
90
  const effectivePanelId = panel?.id ?? usePanelStore.getState().focusedPanelId;
91
91