@hienlh/ppm 0.11.2 → 0.11.4
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.
- package/CHANGELOG.md +12 -0
- package/dist/web/assets/{chat-tab-DYf6U6UF.js → chat-tab-BNOBNzph.js} +6 -6
- package/dist/web/assets/{code-editor-BPxBeu0S.js → code-editor--pNv4j1V.js} +2 -2
- package/dist/web/assets/{conflict-editor-BCkYHDUy.js → conflict-editor-BGoXs-1S.js} +1 -1
- package/dist/web/assets/{database-viewer-CCe8qa1Q.js → database-viewer-B9H3s0Xv.js} +1 -1
- package/dist/web/assets/{diff-viewer-DIjzWvaG.js → diff-viewer-CwL9hvFQ.js} +1 -1
- package/dist/web/assets/{extension-webview-HY8XueLo.js → extension-webview-dVgax0Uo.js} +1 -1
- package/dist/web/assets/index--W7nYakj.js +26 -0
- package/dist/web/assets/{markdown-renderer-BQV0AIm5.js → markdown-renderer-BLksGuhd.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-DPmTpfFX.js → port-forwarding-tab-CezzkDTK.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BUSNt_7x.js → postgres-viewer-DkZHOBg1.js} +1 -1
- package/dist/web/assets/{settings-tab-DHBG5O0C.js → settings-tab-plcYxjwZ.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-B7WnFN29.js → sqlite-viewer-BmKCNAih.js} +1 -1
- package/dist/web/assets/{terminal-tab-1K4ijyNe.js → terminal-tab-B0GxiD3-.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/server/routes/files.ts +39 -1
- package/src/server/ws/chat.ts +23 -1
- package/src/web/components/chat/tool-cards.tsx +2 -1
- package/src/web/components/explorer/file-tree.tsx +107 -4
- package/src/web/hooks/use-chat.ts +29 -4
- package/dist/web/assets/index-DpRxWGjM.js +0 -26
|
@@ -69,10 +69,11 @@ export function ToolCard({
|
|
|
69
69
|
const hasResult = result?.type === "tool_result";
|
|
70
70
|
const isError = hasResult && !!(result as any).isError;
|
|
71
71
|
const hasAnswers = toolName === "AskUserQuestion" && !!(input as any)?.answers;
|
|
72
|
+
const wasApproved = tool.type === "approval_request" && (tool as any).approved != null;
|
|
72
73
|
const isSubagent = (toolName === "Agent" || toolName === "Task") && tool.type === "tool_use";
|
|
73
74
|
const children = isSubagent ? (tool as any).children as ChatEvent[] | undefined : undefined;
|
|
74
75
|
const hasChildren = children && children.length > 0;
|
|
75
|
-
const isDone = hasResult || hasAnswers || completed;
|
|
76
|
+
const isDone = hasResult || hasAnswers || wasApproved || completed;
|
|
76
77
|
|
|
77
78
|
return (
|
|
78
79
|
<div className={`rounded border text-xs ${isSubagent ? "border-accent/30 bg-accent/5" : "border-border bg-background"}`}>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useCallback, useState, memo } from "react";
|
|
1
|
+
import { useEffect, useCallback, useState, useRef, memo } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Folder,
|
|
4
4
|
FolderOpen,
|
|
@@ -30,6 +30,12 @@ import {
|
|
|
30
30
|
} from "@/components/ui/context-menu";
|
|
31
31
|
import { FileActions } from "./file-actions";
|
|
32
32
|
import { downloadFile, downloadFolder } from "@/lib/file-download";
|
|
33
|
+
import { getAuthToken, projectUrl } from "@/lib/api-client";
|
|
34
|
+
|
|
35
|
+
/** Check if drag event is from OS files (not internal PPM drag) */
|
|
36
|
+
function isExternalFileDrag(e: React.DragEvent): boolean {
|
|
37
|
+
return e.dataTransfer.types.includes("Files") && !e.dataTransfer.types.includes("application/x-ppm-path");
|
|
38
|
+
}
|
|
33
39
|
|
|
34
40
|
/** Synthetic root node for creating files/folders at project root */
|
|
35
41
|
const ROOT_NODE: FileNode = { name: "", path: "", type: "directory" };
|
|
@@ -62,16 +68,19 @@ interface TreeNodeProps {
|
|
|
62
68
|
depth: number;
|
|
63
69
|
projectName: string;
|
|
64
70
|
onAction: (action: string, node: FileNode) => void;
|
|
71
|
+
onFileDrop: (targetDir: string, files: FileList) => void;
|
|
65
72
|
onFileOpen?: () => void;
|
|
66
73
|
}
|
|
67
74
|
|
|
68
|
-
const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, onFileOpen }: TreeNodeProps) {
|
|
75
|
+
const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, onFileDrop, onFileOpen }: TreeNodeProps) {
|
|
69
76
|
const { expandedPaths, toggleExpand, selectedFiles, toggleFileSelect } = useFileStore(useShallow((s) => ({ expandedPaths: s.expandedPaths, toggleExpand: s.toggleExpand, selectedFiles: s.selectedFiles, toggleFileSelect: s.toggleFileSelect })));
|
|
70
77
|
const openTab = useTabStore((s) => s.openTab);
|
|
71
78
|
const isExpanded = expandedPaths.has(node.path);
|
|
72
79
|
const isDir = node.type === "directory";
|
|
73
80
|
const isSelected = selectedFiles.includes(node.path);
|
|
74
81
|
const isIgnored = node.ignored === true;
|
|
82
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
83
|
+
const dragCounter = useRef(0);
|
|
75
84
|
|
|
76
85
|
function handleClick(e: React.MouseEvent) {
|
|
77
86
|
if (isDir) {
|
|
@@ -102,6 +111,36 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
|
|
|
102
111
|
e.dataTransfer.effectAllowed = "copy";
|
|
103
112
|
}
|
|
104
113
|
|
|
114
|
+
function handleNodeDragEnter(e: React.DragEvent) {
|
|
115
|
+
if (!isDir || !isExternalFileDrag(e)) return;
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
e.stopPropagation();
|
|
118
|
+
dragCounter.current++;
|
|
119
|
+
if (dragCounter.current === 1) setIsDragOver(true);
|
|
120
|
+
}
|
|
121
|
+
function handleNodeDragLeave(e: React.DragEvent) {
|
|
122
|
+
if (!isDir) return;
|
|
123
|
+
e.stopPropagation();
|
|
124
|
+
dragCounter.current--;
|
|
125
|
+
if (dragCounter.current === 0) setIsDragOver(false);
|
|
126
|
+
}
|
|
127
|
+
function handleNodeDragOver(e: React.DragEvent) {
|
|
128
|
+
if (!isDir || !isExternalFileDrag(e)) return;
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
e.stopPropagation();
|
|
131
|
+
e.dataTransfer.dropEffect = "copy";
|
|
132
|
+
}
|
|
133
|
+
function handleNodeDrop(e: React.DragEvent) {
|
|
134
|
+
if (!isDir || !isExternalFileDrag(e)) return;
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
e.stopPropagation();
|
|
137
|
+
dragCounter.current = 0;
|
|
138
|
+
setIsDragOver(false);
|
|
139
|
+
if (e.dataTransfer.files.length > 0) {
|
|
140
|
+
onFileDrop(node.path, e.dataTransfer.files);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
105
144
|
const Icon = isDir
|
|
106
145
|
? isExpanded
|
|
107
146
|
? FolderOpen
|
|
@@ -116,7 +155,12 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
|
|
|
116
155
|
: [];
|
|
117
156
|
|
|
118
157
|
return (
|
|
119
|
-
<div
|
|
158
|
+
<div
|
|
159
|
+
onDragEnter={isDir ? handleNodeDragEnter : undefined}
|
|
160
|
+
onDragLeave={isDir ? handleNodeDragLeave : undefined}
|
|
161
|
+
onDragOver={isDir ? handleNodeDragOver : undefined}
|
|
162
|
+
onDrop={isDir ? handleNodeDrop : undefined}
|
|
163
|
+
>
|
|
120
164
|
<ContextMenu>
|
|
121
165
|
<ContextMenuTrigger asChild>
|
|
122
166
|
<button
|
|
@@ -129,6 +173,7 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
|
|
|
129
173
|
"select-none",
|
|
130
174
|
isIgnored && "opacity-40",
|
|
131
175
|
isSelected && "bg-primary/15 ring-1 ring-primary/40",
|
|
176
|
+
isDragOver && "ring-1 ring-dashed ring-primary bg-primary/10",
|
|
132
177
|
)}
|
|
133
178
|
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
134
179
|
>
|
|
@@ -198,6 +243,7 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
|
|
|
198
243
|
depth={depth + 1}
|
|
199
244
|
projectName={projectName}
|
|
200
245
|
onAction={onAction}
|
|
246
|
+
onFileDrop={onFileDrop}
|
|
201
247
|
onFileOpen={onFileOpen}
|
|
202
248
|
/>
|
|
203
249
|
))}
|
|
@@ -239,6 +285,56 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
|
|
|
239
285
|
return () => window.removeEventListener("focus", handleFocus);
|
|
240
286
|
}, [activeProject, fetchTree]);
|
|
241
287
|
|
|
288
|
+
const uploadFiles = useCallback(async (targetDir: string, files: FileList) => {
|
|
289
|
+
if (!activeProject) return;
|
|
290
|
+
const form = new FormData();
|
|
291
|
+
form.append("targetDir", targetDir);
|
|
292
|
+
for (const file of files) form.append("files", file);
|
|
293
|
+
const headers: HeadersInit = {};
|
|
294
|
+
const token = getAuthToken();
|
|
295
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
296
|
+
try {
|
|
297
|
+
const res = await fetch(`${projectUrl(activeProject.name)}/files/upload`, {
|
|
298
|
+
method: "POST",
|
|
299
|
+
headers,
|
|
300
|
+
body: form,
|
|
301
|
+
});
|
|
302
|
+
if (!res.ok) {
|
|
303
|
+
const json = await res.json();
|
|
304
|
+
console.error("Upload failed:", json.error);
|
|
305
|
+
}
|
|
306
|
+
loadTree();
|
|
307
|
+
} catch (e) {
|
|
308
|
+
console.error("Upload error:", e);
|
|
309
|
+
}
|
|
310
|
+
}, [activeProject, loadTree]);
|
|
311
|
+
|
|
312
|
+
const [isRootDragOver, setIsRootDragOver] = useState(false);
|
|
313
|
+
const rootDragCounter = useRef(0);
|
|
314
|
+
|
|
315
|
+
function handleRootDragEnter(e: React.DragEvent) {
|
|
316
|
+
if (!isExternalFileDrag(e)) return;
|
|
317
|
+
e.preventDefault();
|
|
318
|
+
rootDragCounter.current++;
|
|
319
|
+
if (rootDragCounter.current === 1) setIsRootDragOver(true);
|
|
320
|
+
}
|
|
321
|
+
function handleRootDragLeave() {
|
|
322
|
+
rootDragCounter.current--;
|
|
323
|
+
if (rootDragCounter.current === 0) setIsRootDragOver(false);
|
|
324
|
+
}
|
|
325
|
+
function handleRootDragOver(e: React.DragEvent) {
|
|
326
|
+
if (!isExternalFileDrag(e)) return;
|
|
327
|
+
e.preventDefault();
|
|
328
|
+
e.dataTransfer.dropEffect = "copy";
|
|
329
|
+
}
|
|
330
|
+
function handleRootDrop(e: React.DragEvent) {
|
|
331
|
+
if (!isExternalFileDrag(e)) return;
|
|
332
|
+
e.preventDefault();
|
|
333
|
+
rootDragCounter.current = 0;
|
|
334
|
+
setIsRootDragOver(false);
|
|
335
|
+
if (e.dataTransfer.files.length > 0) uploadFiles("", e.dataTransfer.files);
|
|
336
|
+
}
|
|
337
|
+
|
|
242
338
|
function handleAction(action: string, node: FileNode) {
|
|
243
339
|
if (action === "copy-path") {
|
|
244
340
|
navigator.clipboard.writeText(node.path).catch(() => {});
|
|
@@ -310,7 +406,13 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
|
|
|
310
406
|
const toolbarBtnClass = "p-1 rounded-sm text-text-secondary hover:text-foreground hover:bg-surface-elevated transition-colors";
|
|
311
407
|
|
|
312
408
|
return (
|
|
313
|
-
<div
|
|
409
|
+
<div
|
|
410
|
+
className={cn("flex flex-col h-full", isRootDragOver && "bg-primary/5")}
|
|
411
|
+
onDragEnter={handleRootDragEnter}
|
|
412
|
+
onDragLeave={handleRootDragLeave}
|
|
413
|
+
onDragOver={handleRootDragOver}
|
|
414
|
+
onDrop={handleRootDrop}
|
|
415
|
+
>
|
|
314
416
|
{/* Toolbar */}
|
|
315
417
|
<div className="flex items-center gap-0.5 px-2 h-8 border-b border-border shrink-0">
|
|
316
418
|
<button onClick={() => handleAction("new-file", ROOT_NODE)} title="New File" className={toolbarBtnClass}>
|
|
@@ -337,6 +439,7 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
|
|
|
337
439
|
depth={0}
|
|
338
440
|
projectName={activeProject.name}
|
|
339
441
|
onAction={handleAction}
|
|
442
|
+
onFileDrop={uploadFiles}
|
|
340
443
|
onFileOpen={onFileOpen}
|
|
341
444
|
/>
|
|
342
445
|
))}
|
|
@@ -88,6 +88,8 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
88
88
|
const pendingMessageRef = useRef<string | null>(null);
|
|
89
89
|
const sendRef = useRef<(data: string) => void>(() => {});
|
|
90
90
|
const refetchRef = useRef<(() => void) | null>(null);
|
|
91
|
+
/** True while replaying turn_events — suppresses setPendingApproval */
|
|
92
|
+
const isReplayingRef = useRef(false);
|
|
91
93
|
const sessionIdRef = useRef(sessionId);
|
|
92
94
|
sessionIdRef.current = sessionId;
|
|
93
95
|
const projectNameRef = useRef(projectName);
|
|
@@ -251,6 +253,9 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
251
253
|
|
|
252
254
|
case "approval_request": {
|
|
253
255
|
streamingEventsRef.current.push(ev as ChatEvent);
|
|
256
|
+
// During turn_events replay, session_state already set the correct
|
|
257
|
+
// pendingApproval — skip re-setting it for historical (already-answered) events
|
|
258
|
+
if (isReplayingRef.current) break;
|
|
254
259
|
setPendingApproval({
|
|
255
260
|
requestId: ev.requestId,
|
|
256
261
|
tool: ev.tool,
|
|
@@ -457,12 +462,30 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
457
462
|
// Handle turn_events (reconnect sync with rAF chunking)
|
|
458
463
|
if ((data as any).type === "turn_events") {
|
|
459
464
|
const events = (data as any).events as unknown[];
|
|
460
|
-
|
|
465
|
+
const userMessage = (data as any).userMessage as string | null;
|
|
466
|
+
if (!events?.length && !userMessage) { setIsReconnecting(false); return; }
|
|
461
467
|
|
|
462
|
-
//
|
|
468
|
+
// Remove stale streaming assistant message + inject current turn's user message
|
|
463
469
|
setMessages(prev => {
|
|
464
|
-
|
|
465
|
-
|
|
470
|
+
let updated = prev;
|
|
471
|
+
// Only remove in-progress streaming assistant (not finalized or REST-loaded)
|
|
472
|
+
const last = updated[updated.length - 1];
|
|
473
|
+
if (last?.role === "assistant" && last.id.startsWith("streaming-")) {
|
|
474
|
+
updated = updated.slice(0, -1);
|
|
475
|
+
}
|
|
476
|
+
// Add the current turn's user message if not already present
|
|
477
|
+
if (userMessage) {
|
|
478
|
+
const lastAfter = updated[updated.length - 1];
|
|
479
|
+
if (lastAfter?.role !== "user" || lastAfter.content !== userMessage) {
|
|
480
|
+
updated = [...updated, {
|
|
481
|
+
id: `user-replay-${Date.now()}`,
|
|
482
|
+
role: "user" as const,
|
|
483
|
+
content: userMessage,
|
|
484
|
+
timestamp: new Date().toISOString(),
|
|
485
|
+
}];
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return updated;
|
|
466
489
|
});
|
|
467
490
|
|
|
468
491
|
// Reset streaming refs
|
|
@@ -471,6 +494,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
471
494
|
streamingAccountRef.current = null;
|
|
472
495
|
|
|
473
496
|
// Process events in chunks via requestAnimationFrame to avoid blocking main thread
|
|
497
|
+
isReplayingRef.current = true;
|
|
474
498
|
const CHUNK_SIZE = 100;
|
|
475
499
|
let offset = 0;
|
|
476
500
|
const processChunk = () => {
|
|
@@ -482,6 +506,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
482
506
|
if (offset < events.length) {
|
|
483
507
|
requestAnimationFrame(processChunk);
|
|
484
508
|
} else {
|
|
509
|
+
isReplayingRef.current = false;
|
|
485
510
|
setIsReconnecting(false);
|
|
486
511
|
}
|
|
487
512
|
};
|