@hienlh/ppm 0.2.21 → 0.4.0
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 +53 -3
- package/dist/web/assets/chat-tab-mOQXOUVI.js +6 -0
- package/dist/web/assets/code-editor-CRgH4vbS.js +1 -0
- package/dist/web/assets/diff-viewer-D3qUDVXh.js +4 -0
- package/dist/web/assets/git-graph-D1SOZKP7.js +1 -0
- package/dist/web/assets/index-C_yeSRZ0.css +2 -0
- package/dist/web/assets/index-CgNJBFj4.js +21 -0
- package/dist/web/assets/input-AESbQWjx.js +41 -0
- package/dist/web/assets/markdown-renderer-BwjbbSR0.js +59 -0
- package/dist/web/assets/settings-store-DWYkr_a3.js +1 -0
- package/dist/web/assets/settings-tab-C-UYksUh.js +1 -0
- package/dist/web/assets/tab-store-B1wzyDLQ.js +1 -0
- package/dist/web/assets/{terminal-tab-BEFAYT4S.js → terminal-tab-BeFf07MH.js} +1 -1
- package/dist/web/assets/use-monaco-theme-Bb9W0CI2.js +11 -0
- package/dist/web/index.html +7 -5
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +83 -10
- package/src/server/index.ts +81 -1
- package/src/server/ws/chat.ts +10 -0
- package/src/types/api.ts +3 -3
- package/src/types/chat.ts +3 -3
- package/src/web/app.tsx +11 -3
- package/src/web/components/chat/chat-history-bar.tsx +231 -0
- package/src/web/components/chat/chat-tab.tsx +19 -66
- package/src/web/components/chat/message-list.tsx +4 -114
- package/src/web/components/chat/tool-cards.tsx +54 -14
- package/src/web/components/editor/code-editor.tsx +26 -39
- package/src/web/components/editor/diff-viewer.tsx +0 -21
- package/src/web/components/layout/command-palette.tsx +145 -15
- package/src/web/components/layout/draggable-tab.tsx +2 -0
- package/src/web/components/layout/editor-panel.tsx +44 -5
- package/src/web/components/layout/sidebar.tsx +53 -7
- package/src/web/components/layout/tab-bar.tsx +30 -48
- package/src/web/components/settings/ai-settings-section.tsx +28 -19
- package/src/web/components/settings/settings-tab.tsx +24 -21
- package/src/web/components/shared/markdown-renderer.tsx +223 -0
- package/src/web/components/ui/scroll-area.tsx +2 -2
- package/src/web/hooks/use-chat.ts +78 -83
- package/src/web/hooks/use-global-keybindings.ts +30 -2
- package/src/web/stores/panel-store.ts +2 -9
- package/src/web/stores/settings-store.ts +12 -2
- package/src/web/styles/globals.css +14 -4
- package/dist/web/assets/chat-tab-C_U7EwM9.js +0 -6
- package/dist/web/assets/code-editor-DuarTBEe.js +0 -1
- package/dist/web/assets/columns-2-DFQ3yid7.js +0 -1
- package/dist/web/assets/diff-viewer-sBWBgb7U.js +0 -4
- package/dist/web/assets/git-graph-fOKEZiot.js +0 -1
- package/dist/web/assets/index-3zt5mBwZ.css +0 -2
- package/dist/web/assets/index-CaUQy3Zs.js +0 -21
- package/dist/web/assets/input-CTnwfHVN.js +0 -41
- package/dist/web/assets/marked.esm-DhBtkBa8.js +0 -59
- package/dist/web/assets/settings-tab-C5aWMqIA.js +0 -1
- package/dist/web/assets/use-monaco-theme-BxaccPmI.js +0 -11
- /package/dist/web/assets/{api-client-BCjah751.js → api-client-BsHoRDAn.js} +0 -0
- /package/dist/web/assets/{copy-B-kLwqzg.js → copy-BNk4Z75P.js} +0 -0
- /package/dist/web/assets/{external-link-Dim3NH6h.js → external-link-CrtbmtJ6.js} +0 -0
- /package/dist/web/assets/{utils-B-_GCz7E.js → utils-bntUtdc7.js} +0 -0
|
@@ -8,10 +8,9 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|
|
8
8
|
import { buildBugReport, openGithubIssue, copyToClipboard } from "@/lib/report-bug";
|
|
9
9
|
import { MessageList } from "./message-list";
|
|
10
10
|
import { MessageInput, type ChatAttachment } from "./message-input";
|
|
11
|
-
import { Bot } from "lucide-react";
|
|
12
11
|
import { SlashCommandPicker, type SlashItem } from "./slash-command-picker";
|
|
13
12
|
import { FilePicker } from "./file-picker";
|
|
14
|
-
import {
|
|
13
|
+
import { ChatHistoryBar } from "./chat-history-bar";
|
|
15
14
|
import type { FileNode } from "../../../types/project";
|
|
16
15
|
import type { Session, SessionInfo } from "../../../types/chat";
|
|
17
16
|
|
|
@@ -25,7 +24,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
25
24
|
(metadata?.sessionId as string) ?? null,
|
|
26
25
|
);
|
|
27
26
|
const [providerId, setProviderId] = useState<string>(
|
|
28
|
-
(metadata?.providerId as string) ?? "claude
|
|
27
|
+
(metadata?.providerId as string) ?? "claude",
|
|
29
28
|
);
|
|
30
29
|
|
|
31
30
|
// Slash picker state
|
|
@@ -40,9 +39,6 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
40
39
|
const [fileFilter, setFileFilter] = useState("");
|
|
41
40
|
const [fileSelected, setFileSelected] = useState<FileNode | null>(null);
|
|
42
41
|
|
|
43
|
-
// Usage detail panel
|
|
44
|
-
const [showUsageDetail, setShowUsageDetail] = useState(false);
|
|
45
|
-
|
|
46
42
|
// Bug report popup
|
|
47
43
|
const [bugReportText, setBugReportText] = useState<string | null>(null);
|
|
48
44
|
const [copied, setCopied] = useState(false);
|
|
@@ -57,17 +53,6 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
57
53
|
const updateTab = useTabStore((s) => s.updateTab);
|
|
58
54
|
const version = useSettingsStore((s) => s.version);
|
|
59
55
|
|
|
60
|
-
// Fetch AI model name
|
|
61
|
-
const [modelName, setModelName] = useState<string>("");
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
api.get<{ default_provider: string; providers: Record<string, { model?: string }> }>("/api/settings/ai")
|
|
64
|
-
.then((ai) => {
|
|
65
|
-
const provider = ai.providers[ai.default_provider];
|
|
66
|
-
setModelName(provider?.model ?? ai.default_provider);
|
|
67
|
-
})
|
|
68
|
-
.catch(() => {});
|
|
69
|
-
}, []);
|
|
70
|
-
|
|
71
56
|
// Usage runs independently — auto-refreshes on interval
|
|
72
57
|
const { usageInfo, usageLoading, lastUpdatedAt, refreshUsage, mergeUsage } =
|
|
73
58
|
useUsage(projectName, providerId);
|
|
@@ -258,56 +243,24 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
258
243
|
|
|
259
244
|
{/* Bottom toolbar */}
|
|
260
245
|
<div className="border-t border-border bg-background shrink-0">
|
|
261
|
-
{/*
|
|
262
|
-
<
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
<div className="flex items-center gap-2">
|
|
268
|
-
<UsageBadge
|
|
269
|
-
usage={usageInfo}
|
|
270
|
-
loading={usageLoading}
|
|
271
|
-
onClick={() => setShowUsageDetail((v) => !v)}
|
|
272
|
-
/>
|
|
273
|
-
{sessionId && (
|
|
274
|
-
<button
|
|
275
|
-
onClick={async () => {
|
|
276
|
-
const text = await buildBugReport(version, { sessionId, projectName: projectName });
|
|
277
|
-
setBugReportText(text);
|
|
278
|
-
setCopied(false);
|
|
279
|
-
}}
|
|
280
|
-
className="p-0.5 rounded hover:bg-surface-elevated text-text-subtle hover:text-text-secondary transition-colors"
|
|
281
|
-
title="Report bug for this chat session"
|
|
282
|
-
>
|
|
283
|
-
<Bug className="size-3.5" />
|
|
284
|
-
</button>
|
|
285
|
-
)}
|
|
286
|
-
<button
|
|
287
|
-
onClick={() => {
|
|
288
|
-
if (!isConnected) reconnect();
|
|
289
|
-
refetchMessages();
|
|
290
|
-
}}
|
|
291
|
-
className="group relative size-4 flex items-center justify-center rounded-full hover:bg-surface-hover transition-colors"
|
|
292
|
-
title={isConnected ? "Connected — click to refetch messages" : "Disconnected — click to reconnect"}
|
|
293
|
-
>
|
|
294
|
-
<span
|
|
295
|
-
className={`size-2 rounded-full transition-colors ${
|
|
296
|
-
isConnected ? "bg-green-500" : "bg-red-500 animate-pulse"
|
|
297
|
-
}`}
|
|
298
|
-
/>
|
|
299
|
-
</button>
|
|
300
|
-
</div>
|
|
301
|
-
</div>
|
|
302
|
-
|
|
303
|
-
{/* Usage detail panel (in-flow) */}
|
|
304
|
-
<UsageDetailPanel
|
|
305
|
-
usage={usageInfo}
|
|
306
|
-
visible={showUsageDetail}
|
|
307
|
-
onClose={() => setShowUsageDetail(false)}
|
|
308
|
-
onReload={refreshUsage}
|
|
309
|
-
loading={usageLoading}
|
|
246
|
+
{/* Unified toolbar: History, Config, Usage, Bug report, Connection */}
|
|
247
|
+
<ChatHistoryBar
|
|
248
|
+
projectName={projectName}
|
|
249
|
+
usageInfo={usageInfo}
|
|
250
|
+
usageLoading={usageLoading}
|
|
251
|
+
refreshUsage={refreshUsage}
|
|
310
252
|
lastUpdatedAt={lastUpdatedAt}
|
|
253
|
+
sessionId={sessionId}
|
|
254
|
+
onBugReport={sessionId ? async () => {
|
|
255
|
+
const text = await buildBugReport(version, { sessionId, projectName });
|
|
256
|
+
setBugReportText(text);
|
|
257
|
+
setCopied(false);
|
|
258
|
+
} : undefined}
|
|
259
|
+
isConnected={isConnected}
|
|
260
|
+
onReconnect={() => {
|
|
261
|
+
if (!isConnected) reconnect();
|
|
262
|
+
refetchMessages();
|
|
263
|
+
}}
|
|
311
264
|
/>
|
|
312
265
|
|
|
313
266
|
{/* Pickers (in-flow, above input — only one visible at a time) */}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
|
2
|
-
import { marked } from "marked";
|
|
3
2
|
import { getAuthToken } from "@/lib/api-client";
|
|
4
3
|
import type { ChatMessage, ChatEvent } from "../../../types/chat";
|
|
5
|
-
import { useTabStore } from "@/stores/tab-store";
|
|
6
4
|
import { ToolCard } from "./tool-cards";
|
|
5
|
+
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
|
6
|
+
|
|
7
7
|
import {
|
|
8
8
|
AlertCircle,
|
|
9
9
|
ShieldAlert,
|
|
@@ -435,119 +435,9 @@ function ThinkingIndicator({ lastMessage }: { lastMessage?: ChatMessage }) {
|
|
|
435
435
|
return null;
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
-
/**
|
|
439
|
-
marked.setOptions({
|
|
440
|
-
gfm: true,
|
|
441
|
-
breaks: true,
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
/** Renders markdown content with interactive code blocks and file links */
|
|
438
|
+
/** Wrapper: delegates to shared MarkdownRenderer with code actions enabled */
|
|
445
439
|
function MarkdownContent({ content, projectName }: { content: string; projectName?: string }) {
|
|
446
|
-
|
|
447
|
-
try {
|
|
448
|
-
return marked.parse(content) as string;
|
|
449
|
-
} catch {
|
|
450
|
-
return content;
|
|
451
|
-
}
|
|
452
|
-
}, [content]);
|
|
453
|
-
|
|
454
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
455
|
-
const { openTab } = useTabStore();
|
|
456
|
-
|
|
457
|
-
// After render: inject copy/run buttons into <pre> blocks, handle file link clicks
|
|
458
|
-
useEffect(() => {
|
|
459
|
-
const container = containerRef.current;
|
|
460
|
-
if (!container) return;
|
|
461
|
-
|
|
462
|
-
// --- Code block copy/run buttons ---
|
|
463
|
-
container.querySelectorAll("pre").forEach((pre) => {
|
|
464
|
-
if (pre.querySelector(".code-actions")) return; // already added
|
|
465
|
-
const code = pre.querySelector("code");
|
|
466
|
-
const text = code?.textContent ?? pre.textContent ?? "";
|
|
467
|
-
// Detect language from class (e.g. "language-bash")
|
|
468
|
-
const langClass = code?.className ?? "";
|
|
469
|
-
const isBash = /language-(bash|sh|shell|zsh)/.test(langClass)
|
|
470
|
-
|| (!langClass.includes("language-") && text.startsWith("$"));
|
|
471
|
-
|
|
472
|
-
// Wrapper for relative positioning
|
|
473
|
-
pre.style.position = "relative";
|
|
474
|
-
|
|
475
|
-
const actions = document.createElement("div");
|
|
476
|
-
actions.className = "code-actions absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity";
|
|
477
|
-
// Always visible on touch devices
|
|
478
|
-
pre.classList.add("group");
|
|
479
|
-
|
|
480
|
-
// Copy button
|
|
481
|
-
const copyBtn = document.createElement("button");
|
|
482
|
-
copyBtn.className = "flex items-center justify-center size-6 rounded bg-surface-elevated/80 hover:bg-surface-elevated text-text-secondary hover:text-text-primary transition-colors border border-border/50";
|
|
483
|
-
copyBtn.title = "Copy";
|
|
484
|
-
copyBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
|
|
485
|
-
copyBtn.addEventListener("click", () => {
|
|
486
|
-
navigator.clipboard.writeText(text);
|
|
487
|
-
copyBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
|
|
488
|
-
setTimeout(() => {
|
|
489
|
-
copyBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
|
|
490
|
-
}, 2000);
|
|
491
|
-
});
|
|
492
|
-
actions.appendChild(copyBtn);
|
|
493
|
-
|
|
494
|
-
// Run in terminal button (bash only)
|
|
495
|
-
if (isBash) {
|
|
496
|
-
const runBtn = document.createElement("button");
|
|
497
|
-
runBtn.className = "flex items-center justify-center size-6 rounded bg-surface-elevated/80 hover:bg-surface-elevated text-text-secondary hover:text-text-primary transition-colors border border-border/50";
|
|
498
|
-
runBtn.title = "Run in terminal";
|
|
499
|
-
runBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`;
|
|
500
|
-
runBtn.addEventListener("click", () => {
|
|
501
|
-
// Copy to clipboard and open terminal
|
|
502
|
-
navigator.clipboard.writeText(text.replace(/^\$\s*/gm, ""));
|
|
503
|
-
if (projectName) {
|
|
504
|
-
openTab({
|
|
505
|
-
type: "terminal",
|
|
506
|
-
title: "Terminal",
|
|
507
|
-
metadata: { projectName },
|
|
508
|
-
projectId: projectName,
|
|
509
|
-
closable: true,
|
|
510
|
-
});
|
|
511
|
-
}
|
|
512
|
-
});
|
|
513
|
-
actions.appendChild(runBtn);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
pre.appendChild(actions);
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
// --- File link click handling: open in editor tab ---
|
|
520
|
-
const handleClick = (e: MouseEvent) => {
|
|
521
|
-
const target = e.target as HTMLElement;
|
|
522
|
-
const link = target.closest("a");
|
|
523
|
-
if (!link || !container.contains(link)) return;
|
|
524
|
-
|
|
525
|
-
const href = link.getAttribute("href") ?? "";
|
|
526
|
-
// Detect file paths: starts with / or ./ or contains common extensions
|
|
527
|
-
const isFilePath = /^(\/|\.\/|\.\.\/)/.test(href)
|
|
528
|
-
|| /\.(ts|tsx|js|jsx|py|json|md|yaml|yml|toml|css|html|sh|go|rs|sql)$/i.test(href);
|
|
529
|
-
if (isFilePath && projectName) {
|
|
530
|
-
e.preventDefault();
|
|
531
|
-
openTab({
|
|
532
|
-
type: "editor",
|
|
533
|
-
title: href.split("/").pop() ?? href,
|
|
534
|
-
metadata: { filePath: href, projectName },
|
|
535
|
-
projectId: projectName,
|
|
536
|
-
closable: true,
|
|
537
|
-
});
|
|
538
|
-
}
|
|
539
|
-
};
|
|
540
|
-
container.addEventListener("click", handleClick);
|
|
541
|
-
return () => container.removeEventListener("click", handleClick);
|
|
542
|
-
}, [html, projectName, openTab]);
|
|
543
|
-
|
|
544
|
-
return (
|
|
545
|
-
<div
|
|
546
|
-
ref={containerRef}
|
|
547
|
-
className="markdown-content prose-sm"
|
|
548
|
-
dangerouslySetInnerHTML={{ __html: html }}
|
|
549
|
-
/>
|
|
550
|
-
);
|
|
440
|
+
return <MarkdownRenderer content={content} projectName={projectName} codeActions />;
|
|
551
441
|
}
|
|
552
442
|
|
|
553
443
|
/* ToolCard, ToolSummary, ToolDetails extracted to ./tool-cards.tsx */
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Handles summary + details for all SDK tool types.
|
|
4
4
|
*/
|
|
5
5
|
import { useState, useMemo } from "react";
|
|
6
|
-
import {
|
|
6
|
+
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
|
7
7
|
import {
|
|
8
8
|
ChevronDown,
|
|
9
9
|
ChevronRight,
|
|
@@ -65,10 +65,13 @@ export function ToolCard({
|
|
|
65
65
|
const hasResult = result?.type === "tool_result";
|
|
66
66
|
const isError = hasResult && !!(result as any).isError;
|
|
67
67
|
const hasAnswers = toolName === "AskUserQuestion" && !!(input as any)?.answers;
|
|
68
|
+
const isSubagent = (toolName === "Agent" || toolName === "Task") && tool.type === "tool_use";
|
|
69
|
+
const children = isSubagent ? (tool as any).children as ChatEvent[] | undefined : undefined;
|
|
70
|
+
const hasChildren = children && children.length > 0;
|
|
68
71
|
const isDone = hasResult || hasAnswers || completed;
|
|
69
72
|
|
|
70
73
|
return (
|
|
71
|
-
<div className=
|
|
74
|
+
<div className={`rounded border text-xs ${isSubagent ? "border-accent/30 bg-accent/5" : "border-border bg-background"}`}>
|
|
72
75
|
<button
|
|
73
76
|
onClick={() => setExpanded(!expanded)}
|
|
74
77
|
className="flex items-center gap-2 px-2 py-1.5 w-full text-left hover:bg-surface transition-colors min-w-0"
|
|
@@ -82,12 +85,19 @@ export function ToolCard({
|
|
|
82
85
|
<span className="truncate text-text-primary">
|
|
83
86
|
<ToolSummary name={toolName} input={input} />
|
|
84
87
|
</span>
|
|
88
|
+
{hasChildren && (
|
|
89
|
+
<span className="ml-auto text-[10px] text-text-subtle shrink-0">{children!.length} steps</span>
|
|
90
|
+
)}
|
|
85
91
|
</button>
|
|
86
92
|
{expanded && (
|
|
87
93
|
<div className="px-2 pb-2 space-y-1.5">
|
|
88
94
|
{(tool.type === "tool_use" || tool.type === "approval_request") && (
|
|
89
95
|
<ToolDetails name={toolName} input={input} projectName={projectName} />
|
|
90
96
|
)}
|
|
97
|
+
{/* Subagent children: render nested tool events */}
|
|
98
|
+
{hasChildren && (
|
|
99
|
+
<SubagentChildren events={children!} projectName={projectName} />
|
|
100
|
+
)}
|
|
91
101
|
{hasResult && (
|
|
92
102
|
<ToolResultView toolName={toolName} output={(result as any).output} />
|
|
93
103
|
)}
|
|
@@ -395,24 +405,54 @@ function CollapsibleOutput({ output }: { output: string }) {
|
|
|
395
405
|
);
|
|
396
406
|
}
|
|
397
407
|
|
|
398
|
-
/**
|
|
399
|
-
function
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
408
|
+
/** Render subagent child events — nested tool_use/tool_result + text */
|
|
409
|
+
function SubagentChildren({ events, projectName }: { events: ChatEvent[]; projectName?: string }) {
|
|
410
|
+
// Group children similar to InterleavedEvents: pair tool_use + tool_result, merge text
|
|
411
|
+
type ChildGroup =
|
|
412
|
+
| { kind: "text"; content: string }
|
|
413
|
+
| { kind: "tool"; tool: ChatEvent; result?: ChatEvent };
|
|
414
|
+
|
|
415
|
+
const groups: ChildGroup[] = [];
|
|
416
|
+
let textBuffer = "";
|
|
417
|
+
|
|
418
|
+
for (const ev of events) {
|
|
419
|
+
if (ev.type === "text") {
|
|
420
|
+
textBuffer += ev.content;
|
|
421
|
+
} else if (ev.type === "tool_use") {
|
|
422
|
+
if (textBuffer) { groups.push({ kind: "text", content: textBuffer }); textBuffer = ""; }
|
|
423
|
+
groups.push({ kind: "tool", tool: ev });
|
|
424
|
+
} else if (ev.type === "tool_result") {
|
|
425
|
+
// Match to last unmatched tool_use by toolUseId
|
|
426
|
+
const trId = (ev as any).toolUseId;
|
|
427
|
+
const match = trId
|
|
428
|
+
? groups.find((g) => g.kind === "tool" && g.tool.type === "tool_use" && (g.tool as any).toolUseId === trId && !g.result) as (ChildGroup & { kind: "tool" }) | undefined
|
|
429
|
+
: groups.findLast((g) => g.kind === "tool" && !g.result) as (ChildGroup & { kind: "tool" }) | undefined;
|
|
430
|
+
if (match) match.result = ev;
|
|
405
431
|
}
|
|
406
|
-
}
|
|
432
|
+
}
|
|
433
|
+
if (textBuffer) groups.push({ kind: "text", content: textBuffer });
|
|
407
434
|
|
|
408
435
|
return (
|
|
409
|
-
<div
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
436
|
+
<div className="border-l-2 border-accent/20 pl-2 space-y-1 mt-1">
|
|
437
|
+
{groups.map((g, i) => {
|
|
438
|
+
if (g.kind === "text") {
|
|
439
|
+
return (
|
|
440
|
+
<div key={`st-${i}`} className="text-text-secondary text-[11px]">
|
|
441
|
+
<MiniMarkdown content={g.content} maxHeight="max-h-24" />
|
|
442
|
+
</div>
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
return <ToolCard key={`sc-${i}`} tool={g.tool} result={g.result} completed={!!(g.result)} projectName={projectName} />;
|
|
446
|
+
})}
|
|
447
|
+
</div>
|
|
413
448
|
);
|
|
414
449
|
}
|
|
415
450
|
|
|
451
|
+
/** Inline markdown renderer for tool details (prompt, result) */
|
|
452
|
+
function MiniMarkdown({ content, maxHeight = "max-h-48" }: { content: string; maxHeight?: string }) {
|
|
453
|
+
return <MarkdownRenderer content={content} className={`text-text-secondary overflow-auto ${maxHeight}`} />;
|
|
454
|
+
}
|
|
455
|
+
|
|
416
456
|
function basename(path?: string): string {
|
|
417
457
|
if (!path) return "";
|
|
418
458
|
return path.split("/").pop() ?? path;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { useEffect, useState, useCallback, useRef
|
|
1
|
+
import { useEffect, useState, useCallback, useRef } from "react";
|
|
2
2
|
import Editor, { type OnMount } from "@monaco-editor/react";
|
|
3
3
|
import type * as MonacoType from "monaco-editor";
|
|
4
|
-
import {
|
|
4
|
+
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
|
5
5
|
import { api, projectUrl, getAuthToken } from "@/lib/api-client";
|
|
6
6
|
import { useTabStore } from "@/stores/tab-store";
|
|
7
7
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
@@ -56,21 +56,27 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
|
|
|
56
56
|
const isMarkdown = ext === "md" || ext === "mdx";
|
|
57
57
|
const [mdMode, setMdMode] = useState<"edit" | "preview">("preview");
|
|
58
58
|
|
|
59
|
+
// Detect external (absolute) file path — not relative to project
|
|
60
|
+
const isExternalFile = filePath ? /^(\/|[A-Za-z]:[/\\])/.test(filePath) : false;
|
|
61
|
+
|
|
59
62
|
// Load file content
|
|
60
63
|
useEffect(() => {
|
|
61
|
-
if (!filePath
|
|
64
|
+
if (!filePath) return;
|
|
65
|
+
if (!isExternalFile && !projectName) return;
|
|
62
66
|
if (isImage || isPdf) { setLoading(false); return; }
|
|
63
67
|
|
|
64
68
|
setLoading(true);
|
|
65
69
|
setError(null);
|
|
66
70
|
|
|
71
|
+
const readUrl = isExternalFile
|
|
72
|
+
? `/api/fs/read?path=${encodeURIComponent(filePath)}`
|
|
73
|
+
: `${projectUrl(projectName!)}/files/read?path=${encodeURIComponent(filePath)}`;
|
|
74
|
+
|
|
67
75
|
api
|
|
68
|
-
.get<{ content: string; encoding
|
|
69
|
-
`${projectUrl(projectName)}/files/read?path=${encodeURIComponent(filePath)}`,
|
|
70
|
-
)
|
|
76
|
+
.get<{ content: string; encoding?: string }>(readUrl)
|
|
71
77
|
.then((data) => {
|
|
72
78
|
setContent(data.content);
|
|
73
|
-
setEncoding(data.encoding);
|
|
79
|
+
if (data.encoding) setEncoding(data.encoding);
|
|
74
80
|
latestContentRef.current = data.content;
|
|
75
81
|
setLoading(false);
|
|
76
82
|
})
|
|
@@ -80,7 +86,7 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
|
|
|
80
86
|
});
|
|
81
87
|
|
|
82
88
|
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); };
|
|
83
|
-
}, [filePath, projectName, isImage, isPdf]);
|
|
89
|
+
}, [filePath, projectName, isImage, isPdf, isExternalFile]);
|
|
84
90
|
|
|
85
91
|
// Update tab title unsaved indicator
|
|
86
92
|
useEffect(() => {
|
|
@@ -92,13 +98,18 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
|
|
|
92
98
|
|
|
93
99
|
const saveFile = useCallback(
|
|
94
100
|
async (text: string) => {
|
|
95
|
-
if (!filePath
|
|
101
|
+
if (!filePath) return;
|
|
102
|
+
if (!isExternalFile && !projectName) return;
|
|
96
103
|
try {
|
|
97
|
-
|
|
104
|
+
if (isExternalFile) {
|
|
105
|
+
await api.put("/api/fs/write", { path: filePath, content: text });
|
|
106
|
+
} else {
|
|
107
|
+
await api.put(`${projectUrl(projectName!)}/files/write`, { path: filePath, content: text });
|
|
108
|
+
}
|
|
98
109
|
setUnsaved(false);
|
|
99
110
|
} catch { /* Silent — unsaved indicator persists */ }
|
|
100
111
|
},
|
|
101
|
-
[filePath, projectName],
|
|
112
|
+
[filePath, projectName, isExternalFile],
|
|
102
113
|
);
|
|
103
114
|
|
|
104
115
|
function handleChange(value: string | undefined) {
|
|
@@ -119,7 +130,7 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
|
|
|
119
130
|
);
|
|
120
131
|
}, []);
|
|
121
132
|
|
|
122
|
-
if (!filePath || !projectName) {
|
|
133
|
+
if (!filePath || (!isExternalFile && !projectName)) {
|
|
123
134
|
return (
|
|
124
135
|
<div className="flex items-center justify-center h-full text-text-secondary text-sm">
|
|
125
136
|
No file selected.
|
|
@@ -142,8 +153,8 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
|
|
|
142
153
|
);
|
|
143
154
|
}
|
|
144
155
|
|
|
145
|
-
if (isImage) return <ImagePreview filePath={filePath} projectName={projectName} />;
|
|
146
|
-
if (isPdf) return <PdfPreview filePath={filePath} projectName={projectName} />;
|
|
156
|
+
if (isImage) return <ImagePreview filePath={filePath!} projectName={projectName!} />;
|
|
157
|
+
if (isPdf) return <PdfPreview filePath={filePath!} projectName={projectName!} />;
|
|
147
158
|
|
|
148
159
|
if (encoding === "base64") {
|
|
149
160
|
return (
|
|
@@ -181,13 +192,6 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
|
|
|
181
192
|
|
|
182
193
|
return (
|
|
183
194
|
<div className="flex flex-col h-full w-full overflow-hidden">
|
|
184
|
-
{/* Desktop toolbar */}
|
|
185
|
-
<div className="hidden md:flex items-center gap-1 px-2 py-1 border-b shrink-0 bg-background">
|
|
186
|
-
{mdModeButtons}
|
|
187
|
-
<div className="flex-1" />
|
|
188
|
-
{wrapBtn}
|
|
189
|
-
</div>
|
|
190
|
-
|
|
191
195
|
{isMarkdown && mdMode === "preview" ? (
|
|
192
196
|
<MarkdownPreview content={content ?? ""} />
|
|
193
197
|
) : (
|
|
@@ -214,29 +218,12 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
|
|
|
214
218
|
/>
|
|
215
219
|
</div>
|
|
216
220
|
)}
|
|
217
|
-
|
|
218
|
-
{/* Mobile toolbar */}
|
|
219
|
-
<div className="md:hidden flex items-center gap-1 px-2 py-1 border-t shrink-0 bg-background">
|
|
220
|
-
{mdModeButtons}
|
|
221
|
-
<div className="flex-1" />
|
|
222
|
-
{wrapBtn}
|
|
223
|
-
</div>
|
|
224
221
|
</div>
|
|
225
222
|
);
|
|
226
223
|
}
|
|
227
224
|
|
|
228
225
|
function MarkdownPreview({ content }: { content: string }) {
|
|
229
|
-
|
|
230
|
-
try { return marked.parse(content, { gfm: true, breaks: true }) as string; }
|
|
231
|
-
catch { return content; }
|
|
232
|
-
}, [content]);
|
|
233
|
-
|
|
234
|
-
return (
|
|
235
|
-
<div
|
|
236
|
-
className="flex-1 overflow-auto p-4 markdown-content prose-sm"
|
|
237
|
-
dangerouslySetInnerHTML={{ __html: html }}
|
|
238
|
-
/>
|
|
239
|
-
);
|
|
226
|
+
return <MarkdownRenderer content={content} className="flex-1 overflow-auto p-4" />;
|
|
240
227
|
}
|
|
241
228
|
|
|
242
229
|
function ImagePreview({ filePath, projectName }: { filePath: string; projectName: string }) {
|
|
@@ -162,22 +162,6 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
|
|
|
162
162
|
|
|
163
163
|
return (
|
|
164
164
|
<div className="flex flex-col h-full">
|
|
165
|
-
{/* Header */}
|
|
166
|
-
<div className="flex items-center gap-2 px-3 py-1.5 border-b text-xs text-muted-foreground">
|
|
167
|
-
<FileCode className="size-3.5" />
|
|
168
|
-
{isFileCompare ? (
|
|
169
|
-
<span className="font-mono truncate flex-1">{file1} vs {file2}</span>
|
|
170
|
-
) : (
|
|
171
|
-
<span className="flex-1 truncate">
|
|
172
|
-
<span className="font-mono">{filePath ?? "Working tree changes"}</span>
|
|
173
|
-
{(ref1 || ref2) && (
|
|
174
|
-
<span> ({ref1?.slice(0, 7) ?? "HEAD"} vs {ref2?.slice(0, 7) ?? "working tree"})</span>
|
|
175
|
-
)}
|
|
176
|
-
</span>
|
|
177
|
-
)}
|
|
178
|
-
<div className="hidden md:block">{expandToggle}</div>
|
|
179
|
-
</div>
|
|
180
|
-
|
|
181
165
|
{/* Monaco DiffEditor */}
|
|
182
166
|
<div className="flex-1 overflow-hidden">
|
|
183
167
|
<DiffEditor
|
|
@@ -198,11 +182,6 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
|
|
|
198
182
|
loading={<Loader2 className="size-5 animate-spin text-text-subtle" />}
|
|
199
183
|
/>
|
|
200
184
|
</div>
|
|
201
|
-
|
|
202
|
-
{/* Mobile expand toggle */}
|
|
203
|
-
<div className="md:hidden flex justify-center border-t py-1 bg-background shrink-0">
|
|
204
|
-
{expandToggle}
|
|
205
|
-
</div>
|
|
206
185
|
</div>
|
|
207
186
|
);
|
|
208
187
|
}
|