@hienlh/ppm 0.17.33 → 0.17.36

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 (96) 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-CZc98en9.js → ai-resource-editor-aT2nC1lK.js} +1 -1
  8. package/dist/web/assets/{audio-preview-XkBaDV-d.js → audio-preview-DPS0VpgU.js} +1 -1
  9. package/dist/web/assets/chat-tab-k7e8hE9y.js +13 -0
  10. package/dist/web/assets/{code-editor-DeqsPj8M.js → code-editor-C8ClI1kO.js} +2 -2
  11. package/dist/web/assets/{conflict-editor-CqORnyRu.js → conflict-editor-Dag7buRT.js} +1 -1
  12. package/dist/web/assets/{csv-preview-i79R-2TA.js → csv-preview-6JQDYpOU.js} +1 -1
  13. package/dist/web/assets/{database-viewer-ClrtBc_A.js → database-viewer-BFzNauDX.js} +1 -1
  14. package/dist/web/assets/{diff-viewer-CWUjtM3T.js → diff-viewer-Dub9yryk.js} +1 -1
  15. package/dist/web/assets/{docx-preview-CFjtBdLf.js → docx-preview-Bj2Xzdef.js} +1 -1
  16. package/dist/web/assets/{edit-diff-preview-BSjgyGAL.js → edit-diff-preview-D1TEK7Jz.js} +1 -1
  17. package/dist/web/assets/{extension-webview-DwRdXtK4.js → extension-webview-DUqZPzfp.js} +1 -1
  18. package/dist/web/assets/{git-log-panel-DI_uaWVT.js → git-log-panel-90_p5Qdn.js} +1 -1
  19. package/dist/web/assets/{glide-data-grid-Q2kMqNeo.js → glide-data-grid-BSSyHBRx.js} +1 -1
  20. package/dist/web/assets/group-chat-tab-j1CoraSo.js +1 -0
  21. package/dist/web/assets/image-gallery-CObzTisL.js +1 -0
  22. package/dist/web/assets/{image-preview-QyeYNiJt.js → image-preview-ChhxwmZU.js} +1 -1
  23. package/dist/web/assets/index-BA8NcuLe.js +50 -0
  24. package/dist/web/assets/index-eW0VEaRF.css +2 -0
  25. package/dist/web/assets/{keybindings-store-55cr_nFT.js → keybindings-store-Bp-oUCTT.js} +1 -1
  26. package/dist/web/assets/markdown-renderer-BaPAXsZW.js +3 -0
  27. package/dist/web/assets/markdown-renderer-CUHI3gc6.js +1 -0
  28. package/dist/web/assets/{notification-store-Bwzk7bz9.js → notification-store-COr3urzj.js} +1 -1
  29. package/dist/web/assets/panel-store-CpKvXNnm.js +1 -0
  30. package/dist/web/assets/{pdf-preview-Dd65pYcK.js → pdf-preview-cJC8Cm9R.js} +1 -1
  31. package/dist/web/assets/{postgres-viewer-BAeRjowr.js → postgres-viewer-uMVBwY8R.js} +1 -1
  32. package/dist/web/assets/{settings-store-DFMz_Oer.js → settings-store-CBLPPIts.js} +2 -2
  33. package/dist/web/assets/{settings-tab-D_Vz5vjU.js → settings-tab-BdEa4MLs.js} +1 -1
  34. package/dist/web/assets/{sql-query-editor-RrxjEhFI.js → sql-query-editor-R1WsE3n7.js} +1 -1
  35. package/dist/web/assets/{sqlite-viewer-DULpl_6h.js → sqlite-viewer-CppHnW_J.js} +1 -1
  36. package/dist/web/assets/{system-monitor-tab-DHOIu_5Y.js → system-monitor-tab-BzK46Q3D.js} +1 -1
  37. package/dist/web/assets/{tab-store-CNxCwdXw.js → tab-store-BQmDVh9c.js} +1 -1
  38. package/dist/web/assets/{terminal-tab-B64jdELv.js → terminal-tab-2CWIYa05.js} +1 -1
  39. package/dist/web/assets/tool-cards-DS4vQ3qy.js +7 -0
  40. package/dist/web/assets/use-blob-url-jSRmyXVg.js +1 -0
  41. package/dist/web/assets/{use-monaco-theme-Bf_8qog4.js → use-monaco-theme-CEAY5h6Y.js} +1 -1
  42. package/dist/web/assets/{video-preview-C-ZB6w-Z.js → video-preview-C7RTSYae.js} +1 -1
  43. package/dist/web/index.html +5 -5
  44. package/dist/web/sw.js +1 -1
  45. package/docker-compose.test.yml +5 -1
  46. package/package.json +3 -2
  47. package/src/providers/claude-agent-sdk.ts +4 -3
  48. package/src/server/routes/chat.ts +66 -1
  49. package/src/server/routes/fs-browse.ts +13 -2
  50. package/src/services/image-dimensions.ts +67 -0
  51. package/src/services/jsonl-transcript-parser.ts +2 -1
  52. package/src/services/transcript-images.ts +174 -0
  53. package/src/shared/image-extensions.ts +19 -0
  54. package/src/shared/tool-result-content.ts +81 -0
  55. package/src/web/components/chat/chat-history-bar.tsx +3 -104
  56. package/src/web/components/chat/image-preview-geometry.ts +83 -0
  57. package/src/web/components/chat/image-preview-states.tsx +51 -0
  58. package/src/web/components/chat/message-list.tsx +10 -3
  59. package/src/web/components/chat/session-debug-dialog.tsx +240 -0
  60. package/src/web/components/chat/tool-cards.tsx +27 -2
  61. package/src/web/components/chat/tool-image-preview.tsx +227 -0
  62. package/src/web/components/editor/audio-preview.tsx +1 -1
  63. package/src/web/components/editor/image-preview.tsx +1 -1
  64. package/src/web/components/editor/video-preview.tsx +1 -1
  65. package/src/web/components/group-chat/group-create-dialog.tsx +2 -1
  66. package/src/web/components/layout/editor-panel.tsx +31 -5
  67. package/src/web/components/layout/notification-bell-popover.tsx +5 -1
  68. package/src/web/components/layout/tab-bar.tsx +2 -2
  69. package/src/web/components/shared/image-overlay.tsx +188 -24
  70. package/src/web/components/shared/markdown-context.ts +1 -1
  71. package/src/web/components/shared/markdown-renderer.tsx +3 -1
  72. package/src/web/{components/editor → hooks}/use-blob-url.ts +14 -3
  73. package/src/web/hooks/use-image-transform.ts +276 -0
  74. package/src/web/lib/clipboard.ts +41 -0
  75. package/src/web/lib/file-download.ts +1 -1
  76. package/src/web/lib/image-gallery.ts +25 -0
  77. package/src/web/stores/image-overlay-store.ts +35 -7
  78. package/src/web/stores/panel-store.ts +40 -6
  79. package/src/web/stores/panel-utils.ts +19 -0
  80. package/src/web/styles/globals.css +72 -0
  81. package/dist/web/assets/chat-tab-Cpxrbm1O.js +0 -13
  82. package/dist/web/assets/group-chat-tab-2NJcUl_4.js +0 -1
  83. package/dist/web/assets/index-BI35sAVB.js +0 -50
  84. package/dist/web/assets/index-BW2cOdzp.css +0 -2
  85. package/dist/web/assets/markdown-renderer-ClDzjZ5T.js +0 -1
  86. package/dist/web/assets/markdown-renderer-DoofKFOC.js +0 -3
  87. package/dist/web/assets/panel-store-D-RxcUgH.js +0 -1
  88. package/dist/web/assets/tool-cards-DT3R2C18.js +0 -7
  89. package/dist/web/assets/use-blob-url-kvtWmJtt.js +0 -1
  90. package/skills-lock.json +0 -11
  91. package/spike-memory-region-dump.ps1 +0 -69
  92. /package/dist/web/assets/{api-themes-BpoKUPkQ.js → api-themes-BLUwA0Gh.js} +0 -0
  93. /package/dist/web/assets/{csv-parser-BSnYgPcx.js → csv-parser-DVHBcvuE.js} +0 -0
  94. /package/dist/web/assets/{lib-DA3QSc5_.js → lib-DTaN9riD.js} +0 -0
  95. /package/dist/web/assets/{libesm-Dd8zbj1v.js → libesm-DdtEjhIi.js} +0 -0
  96. /package/dist/web/assets/{vendor-xterm-CsgYwG03.js → vendor-xterm-QDr0jvpJ.js} +0 -0
@@ -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
+ }
@@ -67,6 +67,9 @@ import type { ChatEvent } from "../../../types/chat";
67
67
  import { useShallow } from "zustand/react/shallow";
68
68
  import { useTabStore } from "@/stores/tab-store";
69
69
  import { basename } from "@/lib/utils";
70
+ import { isImageExtension } from "../../../shared/image-extensions";
71
+ import { resultHasImagePlaceholder } from "../../../shared/tool-result-content";
72
+ import { ToolImagePreview } from "./tool-image-preview";
70
73
 
71
74
  /** Extract tool name and input from a ChatEvent */
72
75
  function extractToolInfo(tool: ChatEvent): { toolName: string; input: Record<string, unknown> } {
@@ -84,6 +87,18 @@ function extractToolInfo(tool: ChatEvent): { toolName: string; input: Record<str
84
87
  return { toolName, input };
85
88
  }
86
89
 
90
+ /**
91
+ * Path of the image a Read call targeted, or null when it is not a displayable image read.
92
+ * Only absolute paths qualify, matching what useBlobUrl can resolve to the external
93
+ * raw-file endpoint — and what the Read tool always supplies.
94
+ */
95
+ function imageReadPath(tool: ChatEvent): string | null {
96
+ if (tool.type !== "tool_use" || tool.tool !== "Read") return null;
97
+ const path = (tool.input as Record<string, unknown> | undefined)?.file_path;
98
+ if (typeof path !== "string" || !/^(\/|[A-Za-z]:[/\\])/.test(path)) return null;
99
+ return isImageExtension(path) ? path : null;
100
+ }
101
+
87
102
  /** Unified tool card: shows tool-specific summary + expandable details */
88
103
  export function ToolCard({
89
104
  tool,
@@ -103,7 +118,10 @@ export function ToolCard({
103
118
  // a click — unless the edit failed (diff was never applied, so it's noise).
104
119
  const t = tool.type === "tool_use" ? tool.tool : (tool as any).tool;
105
120
  const failed = result?.type === "tool_result" && !!(result as any).isError;
106
- return (t === "Edit" || t === "MultiEdit") && !failed;
121
+ if ((t === "Edit" || t === "MultiEdit") && !failed) return true;
122
+ // Same reasoning for reading an image: the thumbnail is the point of the card,
123
+ // so show it without requiring a click.
124
+ return !!imageReadPath(tool);
107
125
  });
108
126
 
109
127
  if (tool.type === "error") {
@@ -127,6 +145,10 @@ export function ToolCard({
127
145
  // File-mutation tools show their change via the inline diff/content preview — the SDK's
128
146
  // "file updated successfully" boilerplate is noise, so suppress it (but keep error output).
129
147
  const isFileMutation = ["Edit", "MultiEdit", "Write", "NotebookEdit"].includes(toolName);
148
+ const imagePath = imageReadPath(tool);
149
+ // Hide the text output only when the result really carried an image. Extension is not
150
+ // enough: an SVG or an undecodable format comes back as text that must stay visible.
151
+ const resultIsImage = hasResult && resultHasImagePlaceholder(String((result as any).output ?? ""));
130
152
 
131
153
  // Read partial output for streaming Bash/PowerShell tools
132
154
  const toolUseId = tool.type === "tool_use" ? (tool as any).toolUseId as string | undefined : undefined;
@@ -194,7 +216,10 @@ export function ToolCard({
194
216
  {hasChildren && (
195
217
  <SubagentChildren events={children!} projectName={projectName} />
196
218
  )}
197
- {hasResult && !(isFileMutation && !isError) && (
219
+ {imagePath && (
220
+ <ToolImagePreview filePath={imagePath} projectName={projectName ?? ""} />
221
+ )}
222
+ {hasResult && !(isFileMutation && !isError) && !(resultIsImage && !isError) && (
198
223
  <ToolResultView toolName={toolName} output={(result as any).output} />
199
224
  )}
200
225
  </div>
@@ -0,0 +1,227 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useShallow } from "zustand/react/shallow";
3
+ import { useBlobUrl } from "@/hooks/use-blob-url";
4
+ import { useImageOverlay } from "@/stores/image-overlay-store";
5
+ import { collectGallery, GALLERY_ITEM_ATTR } from "@/lib/image-gallery";
6
+ import { useTabStore } from "@/stores/tab-store";
7
+ import { copyToClipboard } from "@/lib/clipboard";
8
+ import { basename } from "@/lib/utils";
9
+ import { ImagePreviewFailure, ImagePreviewSkeleton } from "./image-preview-states";
10
+ import {
11
+ cacheNatural,
12
+ dimensionsLabel,
13
+ formatBytes,
14
+ getCachedNatural,
15
+ previewLayout,
16
+ TILE_WIDTH,
17
+ type NaturalSize,
18
+ } from "./image-preview-geometry";
19
+
20
+ /** Ratio the skeleton assumes before the image has ever been measured. */
21
+ const FALLBACK_ASPECT = 4 / 5;
22
+
23
+ const LABEL = "text-[var(--img-meta-label)] @max-[470px]:hidden";
24
+ const VALUE = "truncate text-text @max-[470px]:text-text-2";
25
+ const GHOST =
26
+ "h-[30px] @max-[470px]:h-[36px] shrink-0 rounded-[7px] px-[9px] font-mono text-[11.5px] " +
27
+ "text-text-2 bg-[var(--img-ghost)] border border-[var(--img-ghost-border)] backdrop-blur-[6px] " +
28
+ "transition-colors can-hover:hover:bg-[var(--img-ghost-hover)] " +
29
+ "can-hover:hover:border-[var(--img-ghost-hover-border)] can-hover:hover:text-text";
30
+
31
+ /**
32
+ * The image a Read call opened, shown on a stage lit by a blurred copy of itself, beside
33
+ * the file facts needed to decide whether to open it. Fetched as a blob with an
34
+ * Authorization header, so no auth token ends up in an image URL.
35
+ */
36
+ export function ToolImagePreview({
37
+ filePath,
38
+ projectName,
39
+ }: {
40
+ filePath: string;
41
+ projectName: string;
42
+ }) {
43
+ const [attempt, setAttempt] = useState(0);
44
+ const { blobUrl, blob, error, errorCode } = useBlobUrl(filePath, projectName, undefined, attempt);
45
+ const [natural, setNatural] = useState<NaturalSize | undefined>(() => getCachedNatural(filePath));
46
+ const [decodeFailed, setDecodeFailed] = useState(false);
47
+ const [copied, setCopied] = useState(false);
48
+ const openOverlay = useImageOverlay((s) => s.open);
49
+ const { openTab } = useTabStore(useShallow((s) => ({ openTab: s.openTab })));
50
+ const name = basename(filePath);
51
+
52
+ useEffect(() => {
53
+ if (!copied) return;
54
+ const timer = setTimeout(() => setCopied(false), 1200);
55
+ return () => clearTimeout(timer);
56
+ }, [copied]);
57
+
58
+ const measure = (img: HTMLImageElement) => {
59
+ const size = { w: img.naturalWidth, h: img.naturalHeight };
60
+ if (!size.w || !size.h) return setDecodeFailed(true);
61
+ cacheNatural(filePath, size);
62
+ setNatural(size);
63
+ };
64
+
65
+ const retry = () => {
66
+ setDecodeFailed(false);
67
+ setAttempt((n) => n + 1);
68
+ };
69
+
70
+ if (error || decodeFailed) {
71
+ return (
72
+ <div className="border-t border-border pt-1.5">
73
+ <ImagePreviewFailure code={decodeFailed ? undefined : errorCode ?? undefined} onRetry={retry} />
74
+ </div>
75
+ );
76
+ }
77
+
78
+ const layout = natural ? previewLayout(natural, TILE_WIDTH) : null;
79
+ const skeletonAspect = layout?.aspect ?? FALLBACK_ASPECT;
80
+
81
+ const actions = (
82
+ <div className="flex flex-wrap gap-[6px]">
83
+ <button type="button" onClick={openInEditor} className={GHOST}>
84
+ <span className="@max-[470px]:hidden">open in editor</span>
85
+ <span className="hidden @max-[470px]:inline">open</span>
86
+ </button>
87
+ <button type="button" onClick={copyPath} className={GHOST}>
88
+ {copied ? "copied" : "copy path"}
89
+ </button>
90
+ <button type="button" onClick={openFull} className={GHOST}>
91
+ full size
92
+ </button>
93
+ </div>
94
+ );
95
+
96
+ function openInEditor() {
97
+ if (!projectName) return;
98
+ openTab({
99
+ type: "editor",
100
+ title: name,
101
+ metadata: { filePath, projectName },
102
+ projectId: projectName,
103
+ closable: true,
104
+ });
105
+ }
106
+
107
+ function copyPath() {
108
+ void copyToClipboard(filePath).then(() => setCopied(true));
109
+ }
110
+
111
+ function openFull(e: { currentTarget: Element }) {
112
+ if (blobUrl) openOverlay(blobUrl, name, collectGallery(e.currentTarget));
113
+ }
114
+
115
+ // Loading: the same box as the loaded state, so the transcript never shifts under the
116
+ // reader once the image resolves.
117
+ if (!blobUrl || !layout || !natural) {
118
+ return (
119
+ <div className="@container border-t border-border pt-1.5">
120
+ {blobUrl && (
121
+ <img
122
+ src={blobUrl}
123
+ alt=""
124
+ className="hidden"
125
+ onLoad={(e) => measure(e.currentTarget)}
126
+ onError={() => setDecodeFailed(true)}
127
+ />
128
+ )}
129
+ <ImagePreviewSkeleton aspect={skeletonAspect} actions={actions} />
130
+ </div>
131
+ );
132
+ }
133
+
134
+ const metadata = (
135
+ <>
136
+ <dt className={LABEL}>dimensions</dt>
137
+ <dd className={VALUE}>{dimensionsLabel(natural, layout.fit)}</dd>
138
+ {blob && (
139
+ <>
140
+ <dt className={LABEL}>size</dt>
141
+ <dd className={VALUE}>
142
+ {formatBytes(blob.size)}
143
+ {blob.type ? ` · ${blob.type}` : ""}
144
+ </dd>
145
+ </>
146
+ )}
147
+ <dt className={LABEL}>path</dt>
148
+ {/* rtl puts the ellipsis at the head of the path, keeping the filename readable */}
149
+ <dd dir="rtl" className={`${VALUE} text-left`}>
150
+ {filePath}
151
+ </dd>
152
+ </>
153
+ );
154
+
155
+ const picture = (
156
+ <img
157
+ src={blobUrl}
158
+ alt={name}
159
+ {...{ [GALLERY_ITEM_ATTR]: "" }}
160
+ onLoad={(e) => measure(e.currentTarget)}
161
+ onError={() => setDecodeFailed(true)}
162
+ className={
163
+ layout.fit === "cover"
164
+ ? "size-full object-cover"
165
+ : layout.fit === "contain"
166
+ ? "size-full object-contain"
167
+ : "max-h-full max-w-full"
168
+ }
169
+ />
170
+ );
171
+
172
+ return (
173
+ <div className="@container border-t border-border pt-1.5">
174
+ <div className="relative overflow-hidden rounded-lg border border-border p-[14px] @max-[470px]:p-[11px]">
175
+ <img
176
+ aria-hidden="true"
177
+ alt=""
178
+ src={blobUrl}
179
+ className="absolute inset-0 size-full object-cover"
180
+ style={{ filter: "var(--img-backdrop)", transform: "scale(1.3)" }}
181
+ />
182
+ <div className="absolute inset-0" style={{ background: "var(--img-veil)" }} />
183
+
184
+ {layout.variant === "band" ? (
185
+ <div className="relative grid gap-[11px]">
186
+ <button
187
+ type="button"
188
+ onClick={openFull}
189
+ title={`Open ${name}`}
190
+ className="img-checkerboard w-full cursor-zoom-in overflow-hidden rounded-lg border border-[var(--img-plate-border)]"
191
+ style={{ aspectRatio: layout.aspect }}
192
+ >
193
+ {picture}
194
+ </button>
195
+ <dl className="grid grid-cols-[auto_auto_1fr] gap-x-[18px] font-mono text-[11.5px] [&_dt]:hidden">
196
+ {metadata}
197
+ </dl>
198
+ {actions}
199
+ </div>
200
+ ) : (
201
+ <div className="relative grid grid-cols-[132px_1fr] items-start gap-[14px] @max-[470px]:grid-cols-[64px_1fr] @max-[470px]:gap-[11px]">
202
+ <button
203
+ type="button"
204
+ onClick={openFull}
205
+ title={`Open ${name}`}
206
+ className={`cursor-zoom-in overflow-hidden rounded-lg border border-[var(--img-plate-border)] bg-[var(--img-plate)] shadow-[var(--img-plate-shadow)] ${
207
+ layout.fit === "cover" ? "" : "img-checkerboard flex items-center justify-center"
208
+ }`}
209
+ style={{ aspectRatio: layout.aspect }}
210
+ >
211
+ {picture}
212
+ </button>
213
+ <div className="flex min-w-0 flex-col gap-[9px]">
214
+ <p className="truncate text-[13px] font-semibold text-text @max-[470px]:hidden">
215
+ {name}
216
+ </p>
217
+ <dl className="grid grid-cols-[auto_1fr] gap-x-[14px] gap-y-[4px] font-mono text-[11.5px] @max-[470px]:grid-cols-[1fr]">
218
+ {metadata}
219
+ </dl>
220
+ {actions}
221
+ </div>
222
+ </div>
223
+ )}
224
+ </div>
225
+ </div>
226
+ );
227
+ }
@@ -1,5 +1,5 @@
1
1
  import { Loader2, FileWarning, Music } from "lucide-react";
2
- import { useBlobUrl } from "./use-blob-url";
2
+ import { useBlobUrl } from "@/hooks/use-blob-url";
3
3
  import { basename } from "@/lib/utils";
4
4
 
5
5
  export function AudioPreview({ filePath, projectName }: { filePath: string; projectName: string }) {
@@ -1,5 +1,5 @@
1
1
  import { Loader2, FileWarning } from "lucide-react";
2
- import { useBlobUrl } from "./use-blob-url";
2
+ import { useBlobUrl } from "@/hooks/use-blob-url";
3
3
 
4
4
  export function ImagePreview({ filePath, projectName }: { filePath: string; projectName: string }) {
5
5
  const { blobUrl, error } = useBlobUrl(filePath, projectName);
@@ -1,5 +1,5 @@
1
1
  import { Loader2, FileWarning } from "lucide-react";
2
- import { useBlobUrl } from "./use-blob-url";
2
+ import { useBlobUrl } from "@/hooks/use-blob-url";
3
3
 
4
4
  export function VideoPreview({ filePath, projectName }: { filePath: string; projectName: string }) {
5
5
  const { blobUrl, error } = useBlobUrl(filePath, projectName);
@@ -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",