@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
|
@@ -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=
|
|
60
|
-
<h3 className=
|
|
61
|
-
<p className=
|
|
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=
|
|
70
|
-
<h3 className=
|
|
75
|
+
<div className={gapSize}>
|
|
76
|
+
<h3 className={`${headingSize} font-medium text-text-secondary`}>AI Provider</h3>
|
|
71
77
|
|
|
72
|
-
<div className=
|
|
73
|
-
<div className=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
|
25
|
-
|
|
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-
|
|
28
|
-
<h3 className="text-
|
|
29
|
-
<div className="flex gap-
|
|
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="
|
|
37
|
+
size="sm"
|
|
37
38
|
onClick={() => setTheme(opt.value)}
|
|
38
39
|
className={cn(
|
|
39
|
-
"flex-1 gap-
|
|
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-
|
|
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-
|
|
58
|
-
<h3 className="text-
|
|
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-
|
|
61
|
-
Push notifications
|
|
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-
|
|
67
|
-
{isSubscribed ? <Bell className="size-
|
|
68
|
-
<span className="text-
|
|
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-
|
|
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-
|
|
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-
|
|
96
|
-
<h3 className="text-
|
|
97
|
-
<p className="text-
|
|
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-
|
|
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-
|
|
41
|
+
"h-full w-1.5 border-l border-l-transparent",
|
|
42
42
|
orientation === "horizontal" &&
|
|
43
|
-
"h-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
326
|
+
[send],
|
|
332
327
|
);
|
|
333
328
|
|
|
334
329
|
const respondToApproval = useCallback(
|