@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +53 -3
  2. package/dist/web/assets/chat-tab-mOQXOUVI.js +6 -0
  3. package/dist/web/assets/code-editor-CRgH4vbS.js +1 -0
  4. package/dist/web/assets/diff-viewer-D3qUDVXh.js +4 -0
  5. package/dist/web/assets/git-graph-D1SOZKP7.js +1 -0
  6. package/dist/web/assets/index-C_yeSRZ0.css +2 -0
  7. package/dist/web/assets/index-CgNJBFj4.js +21 -0
  8. package/dist/web/assets/input-AESbQWjx.js +41 -0
  9. package/dist/web/assets/markdown-renderer-BwjbbSR0.js +59 -0
  10. package/dist/web/assets/settings-store-DWYkr_a3.js +1 -0
  11. package/dist/web/assets/settings-tab-C-UYksUh.js +1 -0
  12. package/dist/web/assets/tab-store-B1wzyDLQ.js +1 -0
  13. package/dist/web/assets/{terminal-tab-BEFAYT4S.js → terminal-tab-BeFf07MH.js} +1 -1
  14. package/dist/web/assets/use-monaco-theme-Bb9W0CI2.js +11 -0
  15. package/dist/web/index.html +7 -5
  16. package/dist/web/sw.js +1 -1
  17. package/package.json +1 -1
  18. package/src/providers/claude-agent-sdk.ts +83 -10
  19. package/src/server/index.ts +81 -1
  20. package/src/server/ws/chat.ts +10 -0
  21. package/src/types/api.ts +3 -3
  22. package/src/types/chat.ts +3 -3
  23. package/src/web/app.tsx +11 -3
  24. package/src/web/components/chat/chat-history-bar.tsx +231 -0
  25. package/src/web/components/chat/chat-tab.tsx +19 -66
  26. package/src/web/components/chat/message-list.tsx +4 -114
  27. package/src/web/components/chat/tool-cards.tsx +54 -14
  28. package/src/web/components/editor/code-editor.tsx +26 -39
  29. package/src/web/components/editor/diff-viewer.tsx +0 -21
  30. package/src/web/components/layout/command-palette.tsx +145 -15
  31. package/src/web/components/layout/draggable-tab.tsx +2 -0
  32. package/src/web/components/layout/editor-panel.tsx +44 -5
  33. package/src/web/components/layout/sidebar.tsx +53 -7
  34. package/src/web/components/layout/tab-bar.tsx +30 -48
  35. package/src/web/components/settings/ai-settings-section.tsx +28 -19
  36. package/src/web/components/settings/settings-tab.tsx +24 -21
  37. package/src/web/components/shared/markdown-renderer.tsx +223 -0
  38. package/src/web/components/ui/scroll-area.tsx +2 -2
  39. package/src/web/hooks/use-chat.ts +78 -83
  40. package/src/web/hooks/use-global-keybindings.ts +30 -2
  41. package/src/web/stores/panel-store.ts +2 -9
  42. package/src/web/stores/settings-store.ts +12 -2
  43. package/src/web/styles/globals.css +14 -4
  44. package/dist/web/assets/chat-tab-C_U7EwM9.js +0 -6
  45. package/dist/web/assets/code-editor-DuarTBEe.js +0 -1
  46. package/dist/web/assets/columns-2-DFQ3yid7.js +0 -1
  47. package/dist/web/assets/diff-viewer-sBWBgb7U.js +0 -4
  48. package/dist/web/assets/git-graph-fOKEZiot.js +0 -1
  49. package/dist/web/assets/index-3zt5mBwZ.css +0 -2
  50. package/dist/web/assets/index-CaUQy3Zs.js +0 -21
  51. package/dist/web/assets/input-CTnwfHVN.js +0 -41
  52. package/dist/web/assets/marked.esm-DhBtkBa8.js +0 -59
  53. package/dist/web/assets/settings-tab-C5aWMqIA.js +0 -1
  54. package/dist/web/assets/use-monaco-theme-BxaccPmI.js +0 -11
  55. /package/dist/web/assets/{api-client-BCjah751.js → api-client-BsHoRDAn.js} +0 -0
  56. /package/dist/web/assets/{copy-B-kLwqzg.js → copy-BNk4Z75P.js} +0 -0
  57. /package/dist/web/assets/{external-link-Dim3NH6h.js → external-link-CrtbmtJ6.js} +0 -0
  58. /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 { UsageBadge, UsageDetailPanel } from "./usage-badge";
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-sdk",
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
- {/* Session bar */}
262
- <div className="flex items-center justify-between px-3 py-1.5 border-b border-border/50">
263
- <div className="flex items-center gap-1.5 text-xs text-text-secondary px-1">
264
- <Bot className="size-3.5" />
265
- <span className="truncate max-w-[180px]">{modelName || "AI"}</span>
266
- </div>
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
- /** Configure marked for safe rendering */
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
- const html = useMemo(() => {
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 { marked } from "marked";
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="rounded border border-border bg-background text-xs">
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
- /** Inline markdown renderer for tool details (prompt, result) */
399
- function MiniMarkdown({ content, maxHeight = "max-h-48" }: { content: string; maxHeight?: string }) {
400
- const html = useMemo(() => {
401
- try {
402
- return marked.parse(content, { gfm: true, breaks: true }) as string;
403
- } catch {
404
- return content;
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
- }, [content]);
432
+ }
433
+ if (textBuffer) groups.push({ kind: "text", content: textBuffer });
407
434
 
408
435
  return (
409
- <div
410
- className={`markdown-content prose-sm text-text-secondary overflow-auto ${maxHeight}`}
411
- dangerouslySetInnerHTML={{ __html: html }}
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, useMemo } from "react";
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 { marked } from "marked";
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 || !projectName) return;
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: string }>(
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 || !projectName) return;
101
+ if (!filePath) return;
102
+ if (!isExternalFile && !projectName) return;
96
103
  try {
97
- await api.put(`${projectUrl(projectName)}/files/write`, { path: filePath, content: text });
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
- const html = useMemo(() => {
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
  }