@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
@@ -23,7 +23,7 @@ const EFFORT_OPTIONS = [
23
23
  { value: "max", label: "Max" },
24
24
  ];
25
25
 
26
- export function AISettingsSection() {
26
+ export function AISettingsSection({ compact }: { compact?: boolean } = {}) {
27
27
  const [settings, setSettings] = useState<AISettings | null>(null);
28
28
  const [saving, setSaving] = useState(false);
29
29
  const [error, setError] = useState<string | null>(null);
@@ -54,11 +54,17 @@ export function AISettingsSection() {
54
54
  }
55
55
  };
56
56
 
57
+ const labelSize = compact ? "text-[11px]" : "text-sm";
58
+ const headingSize = compact ? "text-xs" : "text-sm";
59
+ const gapSize = compact ? "space-y-2" : "space-y-4";
60
+ const innerGap = compact ? "space-y-1.5" : "space-y-3";
61
+ const fieldGap = compact ? "space-y-1" : "space-y-1.5";
62
+
57
63
  if (!settings) {
58
64
  return (
59
- <div className="space-y-3">
60
- <h3 className="text-sm font-medium text-text-secondary">AI Provider</h3>
61
- <p className="text-sm text-text-subtle">
65
+ <div className={innerGap}>
66
+ <h3 className={`${headingSize} font-medium text-text-secondary`}>AI Provider</h3>
67
+ <p className={`${labelSize} text-text-subtle`}>
62
68
  {error ? `Error: ${error}` : "Loading..."}
63
69
  </p>
64
70
  </div>
@@ -66,17 +72,17 @@ export function AISettingsSection() {
66
72
  }
67
73
 
68
74
  return (
69
- <div className="space-y-4">
70
- <h3 className="text-sm font-medium text-text-secondary">AI Provider</h3>
75
+ <div className={gapSize}>
76
+ <h3 className={`${headingSize} font-medium text-text-secondary`}>AI Provider</h3>
71
77
 
72
- <div className="space-y-3">
73
- <div className="space-y-1.5">
74
- <Label htmlFor="ai-model">Model</Label>
78
+ <div className={innerGap}>
79
+ <div className={fieldGap}>
80
+ <Label htmlFor="ai-model" className={compact ? labelSize : undefined}>Model</Label>
75
81
  <Select
76
82
  value={config?.model ?? "claude-sonnet-4-6"}
77
83
  onValueChange={(v) => handleSave("model", v)}
78
84
  >
79
- <SelectTrigger id="ai-model" className="w-full">
85
+ <SelectTrigger id="ai-model" className={`w-full ${compact ? "h-7 text-[11px]" : ""}`}>
80
86
  <SelectValue />
81
87
  </SelectTrigger>
82
88
  <SelectContent>
@@ -89,13 +95,13 @@ export function AISettingsSection() {
89
95
  </Select>
90
96
  </div>
91
97
 
92
- <div className="space-y-1.5">
93
- <Label htmlFor="ai-effort">Effort</Label>
98
+ <div className={fieldGap}>
99
+ <Label htmlFor="ai-effort" className={compact ? labelSize : undefined}>Effort</Label>
94
100
  <Select
95
101
  value={config?.effort ?? "high"}
96
102
  onValueChange={(v) => handleSave("effort", v)}
97
103
  >
98
- <SelectTrigger id="ai-effort" className="w-full">
104
+ <SelectTrigger id="ai-effort" className={`w-full ${compact ? "h-7 text-[11px]" : ""}`}>
99
105
  <SelectValue />
100
106
  </SelectTrigger>
101
107
  <SelectContent>
@@ -108,8 +114,8 @@ export function AISettingsSection() {
108
114
  </Select>
109
115
  </div>
110
116
 
111
- <div className="space-y-1.5">
112
- <Label htmlFor="ai-max-turns">Max Turns (1-500)</Label>
117
+ <div className={fieldGap}>
118
+ <Label htmlFor="ai-max-turns" className={compact ? labelSize : undefined}>Max Turns (1-500)</Label>
113
119
  <Input
114
120
  key={`turns-${revision}`}
115
121
  id="ai-max-turns"
@@ -117,6 +123,7 @@ export function AISettingsSection() {
117
123
  min={1}
118
124
  max={500}
119
125
  defaultValue={config?.max_turns ?? 100}
126
+ className={compact ? "h-7 text-[11px]" : undefined}
120
127
  onBlur={(e) => {
121
128
  const val = parseInt(e.target.value);
122
129
  if (!isNaN(val)) handleSave("max_turns", val);
@@ -124,8 +131,8 @@ export function AISettingsSection() {
124
131
  />
125
132
  </div>
126
133
 
127
- <div className="space-y-1.5">
128
- <Label htmlFor="ai-budget">Max Budget (USD)</Label>
134
+ <div className={fieldGap}>
135
+ <Label htmlFor="ai-budget" className={compact ? labelSize : undefined}>Max Budget (USD)</Label>
129
136
  <Input
130
137
  key={`budget-${revision}`}
131
138
  id="ai-budget"
@@ -135,6 +142,7 @@ export function AISettingsSection() {
135
142
  max={50}
136
143
  defaultValue={config?.max_budget_usd ?? ""}
137
144
  placeholder="No limit"
145
+ className={compact ? "h-7 text-[11px]" : undefined}
138
146
  onBlur={(e) => {
139
147
  const val = parseFloat(e.target.value);
140
148
  handleSave("max_budget_usd", isNaN(val) ? undefined : val);
@@ -142,8 +150,8 @@ export function AISettingsSection() {
142
150
  />
143
151
  </div>
144
152
 
145
- <div className="space-y-1.5">
146
- <Label htmlFor="ai-thinking">Thinking Budget (tokens)</Label>
153
+ <div className={fieldGap}>
154
+ <Label htmlFor="ai-thinking" className={compact ? labelSize : undefined}>Thinking Budget (tokens)</Label>
147
155
  <Input
148
156
  key={`thinking-${revision}`}
149
157
  id="ai-thinking"
@@ -151,6 +159,7 @@ export function AISettingsSection() {
151
159
  min={0}
152
160
  defaultValue={config?.thinking_budget_tokens ?? ""}
153
161
  placeholder="Disabled"
162
+ className={compact ? "h-7 text-[11px]" : undefined}
154
163
  onBlur={(e) => {
155
164
  const val = parseInt(e.target.value);
156
165
  handleSave("thinking_budget_tokens", isNaN(val) ? undefined : val);
@@ -21,26 +21,27 @@ export function SettingsTab() {
21
21
  const { permission, isSubscribed, loading, subscribe, unsubscribe } = usePushNotification();
22
22
 
23
23
  return (
24
- <div className="h-full p-4 space-y-6 overflow-auto max-w-lg">
25
- <h2 className="text-lg font-semibold">Settings</h2>
24
+ <div className="h-full w-full overflow-auto">
25
+ <div className="p-3 space-y-4">
26
+ <h2 className="text-sm font-semibold">Settings</h2>
26
27
 
27
- <div className="space-y-3">
28
- <h3 className="text-sm font-medium text-text-secondary">Theme</h3>
29
- <div className="flex gap-2">
28
+ <div className="space-y-2">
29
+ <h3 className="text-xs font-medium text-text-secondary">Theme</h3>
30
+ <div className="flex gap-1.5">
30
31
  {THEME_OPTIONS.map((opt) => {
31
32
  const Icon = opt.icon;
32
33
  return (
33
34
  <Button
34
35
  key={opt.value}
35
36
  variant={theme === opt.value ? "default" : "outline"}
36
- size="lg"
37
+ size="sm"
37
38
  onClick={() => setTheme(opt.value)}
38
39
  className={cn(
39
- "flex-1 gap-2",
40
+ "flex-1 gap-1.5 text-xs h-8",
40
41
  theme === opt.value && "ring-2 ring-primary",
41
42
  )}
42
43
  >
43
- <Icon className="size-4" />
44
+ <Icon className="size-3.5" />
44
45
  {opt.label}
45
46
  </Button>
46
47
  );
@@ -54,22 +55,23 @@ export function SettingsTab() {
54
55
 
55
56
  <Separator />
56
57
 
57
- <div className="space-y-3">
58
- <h3 className="text-sm font-medium text-text-secondary">Notifications</h3>
58
+ <div className="space-y-2">
59
+ <h3 className="text-xs font-medium text-text-secondary">Notifications</h3>
59
60
  {!pushSupported ? (
60
- <p className="text-sm text-text-subtle">
61
- Push notifications are not supported in this browser.
61
+ <p className="text-xs text-text-subtle">
62
+ Push notifications not supported in this browser.
62
63
  </p>
63
64
  ) : (
64
65
  <>
65
66
  <div className="flex items-center justify-between">
66
- <div className="flex items-center gap-2">
67
- {isSubscribed ? <Bell className="size-4" /> : <BellOff className="size-4" />}
68
- <span className="text-sm">Push notifications</span>
67
+ <div className="flex items-center gap-1.5">
68
+ {isSubscribed ? <Bell className="size-3.5" /> : <BellOff className="size-3.5" />}
69
+ <span className="text-xs">Push notifications</span>
69
70
  </div>
70
71
  <Button
71
72
  variant={isSubscribed ? "default" : "outline"}
72
73
  size="sm"
74
+ className="h-7 text-xs"
73
75
  disabled={loading || permission === "denied"}
74
76
  onClick={() => (isSubscribed ? unsubscribe() : subscribe())}
75
77
  >
@@ -77,12 +79,12 @@ export function SettingsTab() {
77
79
  </Button>
78
80
  </div>
79
81
  {permission === "denied" && (
80
- <p className="text-xs text-destructive">
82
+ <p className="text-[11px] text-destructive">
81
83
  Notifications blocked. Enable in browser settings.
82
84
  </p>
83
85
  )}
84
86
  {isIosNonPwa && (
85
- <p className="text-xs text-text-subtle">
87
+ <p className="text-[11px] text-text-subtle">
86
88
  On iOS, install PPM to Home Screen for push notifications.
87
89
  </p>
88
90
  )}
@@ -92,15 +94,16 @@ export function SettingsTab() {
92
94
 
93
95
  <Separator />
94
96
 
95
- <div className="space-y-3">
96
- <h3 className="text-sm font-medium text-text-secondary">About</h3>
97
- <p className="text-sm text-text-secondary">
97
+ <div className="space-y-1.5">
98
+ <h3 className="text-xs font-medium text-text-secondary">About</h3>
99
+ <p className="text-xs text-text-secondary">
98
100
  PPM — Personal Project Manager
99
101
  </p>
100
- <p className="text-xs text-text-subtle">
102
+ <p className="text-[11px] text-text-subtle">
101
103
  A mobile-first web IDE for managing your projects.
102
104
  </p>
103
105
  </div>
104
106
  </div>
107
+ </div>
105
108
  );
106
109
  }
@@ -0,0 +1,223 @@
1
+ import { useMemo, useRef, useEffect } from "react";
2
+ import { marked } from "marked";
3
+ import { useTabStore } from "@/stores/tab-store";
4
+ import { useFileStore, type FileNode } from "@/stores/file-store";
5
+ import { openCommandPalette } from "@/hooks/use-global-keybindings";
6
+ import { api, projectUrl } from "@/lib/api-client";
7
+
8
+ // Configure marked globally
9
+ marked.use({ gfm: true, breaks: true });
10
+
11
+ /** Common text file extensions that PPM can open as editor tabs */
12
+ const FILE_EXTS = "ts|tsx|js|jsx|mjs|cjs|py|json|md|mdx|yaml|yml|toml|css|scss|less|html|htm|sh|bash|zsh|go|rs|sql|rb|java|kt|swift|c|cpp|h|hpp|cs|vue|svelte|txt|env|cfg|conf|ini|xml|csv|log|dockerfile|makefile|gradle";
13
+ const FILE_EXT_RE = new RegExp(`\\.(${FILE_EXTS})$`, "i");
14
+
15
+ interface MarkdownRendererProps {
16
+ content: string;
17
+ projectName?: string;
18
+ className?: string;
19
+ codeActions?: boolean;
20
+ }
21
+
22
+ /**
23
+ * Transform HTML string:
24
+ * - Wrap tables in scrollable container
25
+ * - Add target=_blank to external links
26
+ * - Mark <a> file paths with data-file-path
27
+ * - Make inline <code> with file names clickable (via HTML transform, not DOM)
28
+ */
29
+ function transformHtml(raw: string): string {
30
+ let html = raw;
31
+
32
+ // Wrap <table> in scroll container
33
+ html = html.replace(/<table/g, '<div class="table-scroll-wrapper overflow-x-auto"><table');
34
+ html = html.replace(/<\/table>/g, "</table></div>");
35
+
36
+ // External links → target=_blank
37
+ html = html.replace(
38
+ /<a\s+href="(https?:\/\/[^"]+)"/g,
39
+ '<a href="$1" target="_blank" rel="noopener noreferrer"',
40
+ );
41
+
42
+ // <a> with file paths → add data-file-path
43
+ html = html.replace(/<a\s+href="([^"]+)"/g, (match, href: string) => {
44
+ if (/^https?:\/\//.test(href)) return match; // already handled
45
+ const isFile = /^(\/|\.\/|\.\.\/)/.test(href) || FILE_EXT_RE.test(href);
46
+ return isFile ? `<a href="${href}" data-file-path="${href}"` : match;
47
+ });
48
+
49
+ // Inline <code> with file-like names → make clickable
50
+ // Split by <pre>...</pre> blocks to avoid transforming code inside them
51
+ const parts = html.split(/(<pre[\s\S]*?<\/pre>)/g);
52
+ html = parts.map((part) => {
53
+ // Skip <pre> blocks
54
+ if (part.startsWith("<pre")) return part;
55
+ // Transform inline <code> in non-pre content
56
+ return part.replace(
57
+ /<code>([^<]+)<\/code>/g,
58
+ (match, text: string) => {
59
+ const trimmed = text.trim();
60
+ if (!trimmed || trimmed.includes(" ")) return match;
61
+ if (!FILE_EXT_RE.test(trimmed) && !/^(\/|\.\/|\.\.\/)/.test(trimmed)) return match;
62
+ return `<code data-file-clickable="${trimmed}" style="cursor:pointer;text-decoration:underline;text-decoration-style:dotted">${text}</code>`;
63
+ },
64
+ );
65
+ }).join("");
66
+
67
+ return html;
68
+ }
69
+
70
+ export function MarkdownRenderer({ content, projectName, className = "", codeActions = false }: MarkdownRendererProps) {
71
+ const html = useMemo(() => {
72
+ try {
73
+ const raw = marked.parse(content) as string;
74
+ return transformHtml(raw);
75
+ } catch {
76
+ return content;
77
+ }
78
+ }, [content]);
79
+
80
+ const containerRef = useRef<HTMLDivElement>(null);
81
+ const openTab = useTabStore((s) => s.openTab);
82
+ const fileTree = useFileStore((s) => s.tree);
83
+
84
+ useEffect(() => {
85
+ const container = containerRef.current;
86
+ if (!container) return;
87
+
88
+ // --- Click handler for file links and clickable code ---
89
+ const handleClick = (e: MouseEvent) => {
90
+ const target = e.target as HTMLElement;
91
+
92
+ // Check <a data-file-path>
93
+ const link = target.closest("a[data-file-path]") as HTMLAnchorElement | null;
94
+ if (link && container.contains(link)) {
95
+ e.preventDefault();
96
+ openFileOrSearch(link.getAttribute("data-file-path") ?? "");
97
+ return;
98
+ }
99
+
100
+ // Check clickable <code>
101
+ const code = target.closest("code[data-file-clickable]") as HTMLElement | null;
102
+ if (code && container.contains(code)) {
103
+ openFileOrSearch(code.getAttribute("data-file-clickable") ?? "");
104
+ return;
105
+ }
106
+ };
107
+
108
+ /** Search file tree for matches by filename */
109
+ function findInTree(nodes: FileNode[], name: string): string[] {
110
+ const results: string[] = [];
111
+ for (const node of nodes) {
112
+ if (node.type === "file" && node.name === name) results.push(node.path);
113
+ if (node.children) results.push(...findInTree(node.children, name));
114
+ }
115
+ return results;
116
+ }
117
+
118
+ function openFileOrSearch(filePath: string) {
119
+ if (!filePath) return;
120
+ const isAbsolute = /^(\/|[A-Za-z]:[/\\])/.test(filePath);
121
+ const isRelative = /^(\.\/|\.\.\/)/.test(filePath);
122
+ const fileName = filePath.split("/").pop() ?? filePath;
123
+
124
+ // Absolute path → verify then open
125
+ if (isAbsolute) {
126
+ const meta: Record<string, unknown> = { filePath };
127
+ if (projectName) meta.projectName = projectName;
128
+ api.get(`/api/fs/read?path=${encodeURIComponent(filePath)}`).then(() => {
129
+ openTab({ type: "editor", title: fileName, metadata: meta, projectId: null, closable: true });
130
+ }).catch(() => openCommandPalette(filePath));
131
+ return;
132
+ }
133
+
134
+ // Relative path with ./ or ../ → try exact path in project
135
+ if (isRelative && projectName) {
136
+ const meta: Record<string, unknown> = { filePath, projectName };
137
+ api.get(`${projectUrl(projectName)}/files/read?path=${encodeURIComponent(filePath)}`)
138
+ .then(() => {
139
+ openTab({ type: "editor", title: fileName, metadata: meta, projectId: projectName, closable: true });
140
+ })
141
+ .catch(() => searchAndOpen(fileName));
142
+ return;
143
+ }
144
+
145
+ // Just a filename → search in project tree
146
+ searchAndOpen(fileName);
147
+ }
148
+
149
+ /** Search project file tree; if 1 match → open directly, else → command palette */
150
+ function searchAndOpen(fileName: string) {
151
+ const matches = findInTree(fileTree, fileName);
152
+ if (matches.length === 1) {
153
+ const match = matches[0]!;
154
+ openTab({
155
+ type: "editor",
156
+ title: fileName,
157
+ metadata: { filePath: match, projectName },
158
+ projectId: projectName ?? null,
159
+ closable: true,
160
+ });
161
+ } else {
162
+ openCommandPalette(fileName);
163
+ }
164
+ }
165
+
166
+ container.addEventListener("click", handleClick);
167
+
168
+ // --- Code block copy/run buttons ---
169
+ if (codeActions) {
170
+ container.querySelectorAll("pre").forEach((pre) => {
171
+ if (pre.querySelector(".code-actions")) return;
172
+ const code = pre.querySelector("code");
173
+ const text = code?.textContent ?? pre.textContent ?? "";
174
+ const langClass = code?.className ?? "";
175
+ const isBash = /language-(bash|sh|shell|zsh)/.test(langClass)
176
+ || (!langClass.includes("language-") && text.startsWith("$"));
177
+
178
+ pre.style.position = "relative";
179
+ pre.classList.add("group");
180
+
181
+ const actions = document.createElement("div");
182
+ actions.className = "code-actions absolute top-1 right-1 flex gap-1";
183
+
184
+ const copyBtn = document.createElement("button");
185
+ 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";
186
+ copyBtn.title = "Copy";
187
+ 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>`;
188
+ copyBtn.addEventListener("click", () => {
189
+ navigator.clipboard.writeText(text);
190
+ 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>`;
191
+ setTimeout(() => {
192
+ 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>`;
193
+ }, 2000);
194
+ });
195
+ actions.appendChild(copyBtn);
196
+
197
+ if (isBash && projectName) {
198
+ const runBtn = document.createElement("button");
199
+ 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";
200
+ runBtn.title = "Run in terminal";
201
+ 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>`;
202
+ runBtn.addEventListener("click", () => {
203
+ navigator.clipboard.writeText(text.replace(/^\$\s*/gm, ""));
204
+ openTab({ type: "terminal", title: "Terminal", metadata: { projectName }, projectId: projectName, closable: true });
205
+ });
206
+ actions.appendChild(runBtn);
207
+ }
208
+
209
+ pre.appendChild(actions);
210
+ });
211
+ }
212
+
213
+ return () => container.removeEventListener("click", handleClick);
214
+ }, [html, projectName, openTab, codeActions]);
215
+
216
+ return (
217
+ <div
218
+ ref={containerRef}
219
+ className={`markdown-content prose-sm ${className}`}
220
+ dangerouslySetInnerHTML={{ __html: html }}
221
+ />
222
+ );
223
+ }
@@ -38,9 +38,9 @@ function ScrollBar({
38
38
  className={cn(
39
39
  "flex touch-none p-px transition-colors select-none",
40
40
  orientation === "vertical" &&
41
- "h-full w-2.5 border-l border-l-transparent",
41
+ "h-full w-1.5 border-l border-l-transparent",
42
42
  orientation === "horizontal" &&
43
- "h-2.5 flex-col border-t border-t-transparent",
43
+ "h-1.5 flex-col border-t border-t-transparent",
44
44
  className
45
45
  )}
46
46
  {...props}
@@ -82,74 +82,76 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
82
82
  return;
83
83
  }
84
84
 
85
+ /**
86
+ * Route a child event to its parent Agent/Task tool_use's children array.
87
+ * Returns true if routed (caller should skip flat append), false if no parent found.
88
+ */
89
+ const routeToParent = (childEvent: ChatEvent, parentToolUseId: string): boolean => {
90
+ const parent = streamingEventsRef.current.find(
91
+ (e) => e.type === "tool_use"
92
+ && (e.tool === "Agent" || e.tool === "Task")
93
+ && (e as any).toolUseId === parentToolUseId,
94
+ );
95
+ if (parent && parent.type === "tool_use") {
96
+ if (!parent.children) parent.children = [];
97
+ parent.children.push(childEvent);
98
+ return true;
99
+ }
100
+ return false;
101
+ };
102
+
103
+ /** Trigger re-render with latest events snapshot */
104
+ const syncMessages = () => {
105
+ const content = streamingContentRef.current;
106
+ const events = [...streamingEventsRef.current];
107
+ setMessages((prev) => {
108
+ const last = prev[prev.length - 1];
109
+ if (last?.role === "assistant" && !last.id.startsWith("final-")) {
110
+ return [...prev.slice(0, -1), { ...last, content, events }];
111
+ }
112
+ return [...prev, {
113
+ id: `streaming-${Date.now()}`,
114
+ role: "assistant" as const,
115
+ content,
116
+ events,
117
+ timestamp: new Date().toISOString(),
118
+ }];
119
+ });
120
+ };
121
+
85
122
  switch (data.type) {
86
123
  case "text": {
124
+ const pid = (data as any).parentToolUseId as string | undefined;
125
+ if (pid && routeToParent(data, pid)) {
126
+ // Child text routed to parent — just re-render
127
+ syncMessages();
128
+ break;
129
+ }
87
130
  streamingContentRef.current += data.content;
88
131
  streamingEventsRef.current.push(data);
89
- // Snapshot BEFORE queueing setState — React 18 batching may delay updater execution
90
- const txtContent = streamingContentRef.current;
91
- const txtEvents = [...streamingEventsRef.current];
92
- setMessages((prev) => {
93
- const last = prev[prev.length - 1];
94
- if (last?.role === "assistant" && !last.id.startsWith("final-")) {
95
- return [
96
- ...prev.slice(0, -1),
97
- { ...last, content: txtContent, events: txtEvents },
98
- ];
99
- }
100
- return [
101
- ...prev,
102
- {
103
- id: `streaming-${Date.now()}`,
104
- role: "assistant" as const,
105
- content: txtContent,
106
- events: txtEvents,
107
- timestamp: new Date().toISOString(),
108
- },
109
- ];
110
- });
132
+ syncMessages();
111
133
  break;
112
134
  }
113
135
 
114
136
  case "tool_use": {
137
+ const pid = (data as any).parentToolUseId as string | undefined;
138
+ if (pid && routeToParent(data, pid)) {
139
+ syncMessages();
140
+ break;
141
+ }
115
142
  streamingEventsRef.current.push(data);
116
- const tuContent = streamingContentRef.current;
117
- const tuEvents = [...streamingEventsRef.current];
118
- setMessages((prev) => {
119
- const last = prev[prev.length - 1];
120
- if (last?.role === "assistant") {
121
- return [
122
- ...prev.slice(0, -1),
123
- { ...last, events: tuEvents },
124
- ];
125
- }
126
- return [
127
- ...prev,
128
- {
129
- id: `streaming-${Date.now()}`,
130
- role: "assistant" as const,
131
- content: tuContent,
132
- events: tuEvents,
133
- timestamp: new Date().toISOString(),
134
- },
135
- ];
136
- });
143
+ syncMessages();
137
144
  break;
138
145
  }
139
146
 
140
147
  case "tool_result": {
148
+ const pid = (data as any).parentToolUseId as string | undefined;
149
+ if (pid && routeToParent(data, pid)) {
150
+ syncMessages();
151
+ break;
152
+ }
141
153
  streamingEventsRef.current.push(data);
142
- const trEvents = [...streamingEventsRef.current];
143
- setMessages((prev) => {
144
- const last = prev[prev.length - 1];
145
- if (last?.role === "assistant") {
146
- return [
147
- ...prev.slice(0, -1),
148
- { ...last, events: trEvents },
149
- ];
150
- }
151
- return prev;
152
- });
154
+ syncMessages();
153
155
  break;
154
156
  }
155
157
 
@@ -217,30 +219,8 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
217
219
  });
218
220
  streamingContentRef.current = "";
219
221
  streamingEventsRef.current = [];
220
-
221
- // Flush queued message if user typed while streaming
222
- const queued = pendingMessageRef.current;
223
- if (queued) {
224
- pendingMessageRef.current = null;
225
- // Add user message to list
226
- setMessages((prev2) => [
227
- ...prev2,
228
- {
229
- id: `user-${Date.now()}`,
230
- role: "user" as const,
231
- content: queued,
232
- timestamp: new Date().toISOString(),
233
- },
234
- ]);
235
- streamingContentRef.current = "";
236
- streamingEventsRef.current = [];
237
- isStreamingRef.current = true;
238
- setIsStreaming(true);
239
- sendRef.current(JSON.stringify({ type: "message", content: queued }));
240
- } else {
241
- isStreamingRef.current = false;
242
- setIsStreaming(false);
243
- }
222
+ isStreamingRef.current = false;
223
+ setIsStreaming(false);
244
224
  break;
245
225
  }
246
226
  }
@@ -303,10 +283,23 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
303
283
  (content: string) => {
304
284
  if (!content.trim()) return;
305
285
 
306
- // If streaming, queue message to send after current stream finishes
307
- if (isStreaming) {
308
- pendingMessageRef.current = content;
309
- return;
286
+ // If streaming, cancel current stream first then send immediately
287
+ if (isStreamingRef.current) {
288
+ // Finalize current streaming message
289
+ const finalContent = streamingContentRef.current;
290
+ const finalEvents = [...streamingEventsRef.current];
291
+ setMessages((prev) => {
292
+ const last = prev[prev.length - 1];
293
+ if (last?.role === "assistant") {
294
+ return [
295
+ ...prev.slice(0, -1),
296
+ { ...last, id: `final-${Date.now()}`, content: finalContent || last.content, events: finalEvents.length > 0 ? finalEvents : last.events },
297
+ ];
298
+ }
299
+ return prev;
300
+ });
301
+ // Tell backend to abort current query
302
+ send(JSON.stringify({ type: "cancel" }));
310
303
  }
311
304
 
312
305
  // Add user message
@@ -323,12 +316,14 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
323
316
  // Reset streaming state
324
317
  streamingContentRef.current = "";
325
318
  streamingEventsRef.current = [];
319
+ pendingMessageRef.current = null;
326
320
  isStreamingRef.current = true;
327
321
  setIsStreaming(true);
322
+ setPendingApproval(null);
328
323
 
329
324
  send(JSON.stringify({ type: "message", content }));
330
325
  },
331
- [send, isStreaming],
326
+ [send],
332
327
  );
333
328
 
334
329
  const respondToApproval = useCallback(