@hienlh/ppm 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/dist/web/assets/api-client-B_eCZViO.js +1 -0
- package/dist/web/assets/chat-tab-FOn2nq1x.js +6 -0
- package/dist/web/assets/{code-editor-BgiyQO-M.js → code-editor-R0uEZQ-h.js} +1 -1
- package/dist/web/assets/{diff-viewer-8_asmBRZ.js → diff-viewer-DDQ2Z0sz.js} +1 -1
- package/dist/web/assets/{git-graph-BiyTIbCz.js → git-graph-ugBsFNaz.js} +1 -1
- package/dist/web/assets/{git-status-panel-BifyO31N.js → git-status-panel-UMKtdAxp.js} +1 -1
- package/dist/web/assets/index-CGDMk8DE.css +2 -0
- package/dist/web/assets/index-Dmu22zQo.js +12 -0
- package/dist/web/assets/project-list-D38uQSpC.js +1 -0
- package/dist/web/assets/{settings-tab-Cn5Ja0_J.js → settings-tab-BpyCSbii.js} +1 -1
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +1 -1
- package/package.json +4 -4
- package/src/server/index.ts +32 -2
- package/src/server/routes/chat.ts +13 -18
- package/src/server/routes/projects.ts +12 -0
- package/src/server/routes/static.ts +2 -2
- package/src/services/claude-usage.service.ts +93 -74
- package/src/services/project.service.ts +43 -0
- package/src/types/chat.ts +0 -2
- package/src/web/components/chat/chat-tab.tsx +24 -7
- package/src/web/components/chat/usage-badge.tsx +23 -23
- package/src/web/components/layout/mobile-drawer.tsx +19 -1
- package/src/web/components/layout/sidebar.tsx +15 -4
- package/src/web/components/projects/project-list.tsx +153 -4
- package/src/web/hooks/use-chat.ts +30 -41
- package/src/web/hooks/use-usage.ts +65 -0
- package/src/web/lib/api-client.ts +9 -0
- package/src/web/lib/report-bug.ts +33 -0
- package/dist/web/assets/api-client-BgVufYKf.js +0 -1
- package/dist/web/assets/chat-tab-C4ovA2w4.js +0 -6
- package/dist/web/assets/index-DILaVO6p.css +0 -2
- package/dist/web/assets/index-DasstYgw.js +0 -11
- package/dist/web/assets/project-list-C7L3hZct.js +0 -1
|
@@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect, type DragEvent } from "react"
|
|
|
2
2
|
import { Upload } from "lucide-react";
|
|
3
3
|
import { api, projectUrl } from "@/lib/api-client";
|
|
4
4
|
import { useChat } from "@/hooks/use-chat";
|
|
5
|
+
import { useUsage } from "@/hooks/use-usage";
|
|
5
6
|
import { useTabStore } from "@/stores/tab-store";
|
|
6
7
|
import { useProjectStore } from "@/stores/project-store";
|
|
7
8
|
import { MessageList } from "./message-list";
|
|
@@ -49,6 +50,10 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
49
50
|
const activeProject = useProjectStore((s) => s.activeProject);
|
|
50
51
|
const updateTab = useTabStore((s) => s.updateTab);
|
|
51
52
|
|
|
53
|
+
// Usage runs independently — auto-refreshes on interval
|
|
54
|
+
const { usageInfo, usageLoading, lastUpdatedAt, refreshUsage, mergeUsage } =
|
|
55
|
+
useUsage(activeProject?.name ?? "", providerId);
|
|
56
|
+
|
|
52
57
|
// Persist sessionId and providerId to tab metadata so reload restores the session
|
|
53
58
|
useEffect(() => {
|
|
54
59
|
if (!tabId || !sessionId) return;
|
|
@@ -62,14 +67,13 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
62
67
|
messagesLoading,
|
|
63
68
|
isStreaming,
|
|
64
69
|
pendingApproval,
|
|
65
|
-
usageInfo,
|
|
66
|
-
usageLoading,
|
|
67
70
|
sendMessage,
|
|
68
71
|
respondToApproval,
|
|
69
72
|
cancelStreaming,
|
|
70
|
-
|
|
73
|
+
reconnect,
|
|
74
|
+
refetchMessages,
|
|
71
75
|
isConnected,
|
|
72
|
-
} = useChat(sessionId, providerId, activeProject?.name ?? "");
|
|
76
|
+
} = useChat(sessionId, providerId, activeProject?.name ?? "", { onUsageEvent: mergeUsage });
|
|
73
77
|
|
|
74
78
|
const handleNewSession = useCallback(() => {
|
|
75
79
|
const projectName = activeProject?.name ?? null;
|
|
@@ -248,11 +252,23 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
248
252
|
<div className="flex items-center gap-2">
|
|
249
253
|
<UsageBadge
|
|
250
254
|
usage={usageInfo}
|
|
255
|
+
loading={usageLoading}
|
|
251
256
|
onClick={() => setShowUsageDetail((v) => !v)}
|
|
252
257
|
/>
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
258
|
+
<button
|
|
259
|
+
onClick={() => {
|
|
260
|
+
if (!isConnected) reconnect();
|
|
261
|
+
refetchMessages();
|
|
262
|
+
}}
|
|
263
|
+
className="group relative size-4 flex items-center justify-center rounded-full hover:bg-surface-hover transition-colors"
|
|
264
|
+
title={isConnected ? "Connected — click to refetch messages" : "Disconnected — click to reconnect"}
|
|
265
|
+
>
|
|
266
|
+
<span
|
|
267
|
+
className={`size-2 rounded-full transition-colors ${
|
|
268
|
+
isConnected ? "bg-green-500" : "bg-red-500 animate-pulse"
|
|
269
|
+
}`}
|
|
270
|
+
/>
|
|
271
|
+
</button>
|
|
256
272
|
</div>
|
|
257
273
|
</div>
|
|
258
274
|
|
|
@@ -263,6 +279,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
263
279
|
onClose={() => setShowUsageDetail(false)}
|
|
264
280
|
onReload={refreshUsage}
|
|
265
281
|
loading={usageLoading}
|
|
282
|
+
lastUpdatedAt={lastUpdatedAt}
|
|
266
283
|
/>
|
|
267
284
|
|
|
268
285
|
{/* Pickers (in-flow, above input — only one visible at a time) */}
|
|
@@ -3,6 +3,7 @@ import type { UsageInfo, LimitBucket } from "../../../types/chat";
|
|
|
3
3
|
|
|
4
4
|
interface UsageBadgeProps {
|
|
5
5
|
usage: UsageInfo;
|
|
6
|
+
loading?: boolean;
|
|
6
7
|
onClick?: () => void;
|
|
7
8
|
}
|
|
8
9
|
|
|
@@ -18,7 +19,7 @@ function barColor(pct: number): string {
|
|
|
18
19
|
return "bg-green-500";
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
export function UsageBadge({ usage, onClick }: UsageBadgeProps) {
|
|
22
|
+
export function UsageBadge({ usage, loading, onClick }: UsageBadgeProps) {
|
|
22
23
|
const fiveHourPct = usage.fiveHour != null ? Math.round(usage.fiveHour * 100) : null;
|
|
23
24
|
const sevenDayPct = usage.sevenDay != null ? Math.round(usage.sevenDay * 100) : null;
|
|
24
25
|
|
|
@@ -34,7 +35,7 @@ export function UsageBadge({ usage, onClick }: UsageBadgeProps) {
|
|
|
34
35
|
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-medium tabular-nums transition-colors hover:bg-surface-hover ${colorClass}`}
|
|
35
36
|
title="Click for usage details"
|
|
36
37
|
>
|
|
37
|
-
<Activity className="size-3" />
|
|
38
|
+
{loading ? <RefreshCw className="size-3 animate-spin" /> : <Activity className="size-3" />}
|
|
38
39
|
<span>5h:{fiveHourLabel}</span>
|
|
39
40
|
<span className="text-text-subtle">·</span>
|
|
40
41
|
<span>Wk:{sevenDayLabel}</span>
|
|
@@ -50,6 +51,7 @@ interface UsageDetailPanelProps {
|
|
|
50
51
|
onClose: () => void;
|
|
51
52
|
onReload?: () => void;
|
|
52
53
|
loading?: boolean;
|
|
54
|
+
lastUpdatedAt?: number | null;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
function formatResetTime(bucket?: LimitBucket): string | null {
|
|
@@ -74,34 +76,18 @@ function formatResetTime(bucket?: LimitBucket): string | null {
|
|
|
74
76
|
return `${m}m`;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
function statusLabel(status?: string): { text: string; color: string } | null {
|
|
78
|
-
if (!status) return null;
|
|
79
|
-
switch (status) {
|
|
80
|
-
case "ahead_of_pace": return { text: "Ahead of pace", color: "text-green-500" };
|
|
81
|
-
case "behind_pace": return { text: "Behind pace", color: "text-amber-500" };
|
|
82
|
-
case "on_pace": return { text: "On pace", color: "text-text-subtle" };
|
|
83
|
-
default: return { text: status.replace(/_/g, " "), color: "text-text-subtle" };
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
79
|
function BucketRow({ label, bucket }: { label: string; bucket?: LimitBucket }) {
|
|
88
80
|
if (!bucket) return null;
|
|
89
81
|
const pct = Math.round(bucket.utilization * 100);
|
|
90
82
|
const reset = formatResetTime(bucket);
|
|
91
|
-
const status = statusLabel(bucket.status);
|
|
92
83
|
|
|
93
84
|
return (
|
|
94
85
|
<div className="space-y-1">
|
|
95
86
|
<div className="flex items-center justify-between">
|
|
96
87
|
<span className="text-xs font-medium text-text-primary">{label}</span>
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
)}
|
|
101
|
-
{reset && (
|
|
102
|
-
<span className="text-[10px] text-text-subtle">↻ {reset}</span>
|
|
103
|
-
)}
|
|
104
|
-
</div>
|
|
88
|
+
{reset && (
|
|
89
|
+
<span className="text-[10px] text-text-subtle">↻ {reset}</span>
|
|
90
|
+
)}
|
|
105
91
|
</div>
|
|
106
92
|
<div className="flex items-center gap-2">
|
|
107
93
|
<div className="flex-1 h-2 rounded-full bg-border overflow-hidden">
|
|
@@ -118,7 +104,16 @@ function BucketRow({ label, bucket }: { label: string; bucket?: LimitBucket }) {
|
|
|
118
104
|
);
|
|
119
105
|
}
|
|
120
106
|
|
|
121
|
-
|
|
107
|
+
function formatLastUpdated(ts: number | null | undefined): string | null {
|
|
108
|
+
if (!ts) return null;
|
|
109
|
+
const secs = Math.round((Date.now() - ts) / 1000);
|
|
110
|
+
if (secs < 5) return "just now";
|
|
111
|
+
if (secs < 60) return `${secs}s ago`;
|
|
112
|
+
const mins = Math.floor(secs / 60);
|
|
113
|
+
return `${mins}m ago`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, lastUpdatedAt }: UsageDetailPanelProps) {
|
|
122
117
|
if (!visible) return null;
|
|
123
118
|
|
|
124
119
|
const hasCost = usage.queryCostUsd != null || usage.totalCostUsd != null;
|
|
@@ -127,7 +122,12 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading }:
|
|
|
127
122
|
return (
|
|
128
123
|
<div className="border-b border-border bg-surface px-3 py-2.5 space-y-2.5">
|
|
129
124
|
<div className="flex items-center justify-between">
|
|
130
|
-
<
|
|
125
|
+
<div className="flex items-center gap-2">
|
|
126
|
+
<span className="text-xs font-semibold text-text-primary">Usage Limits</span>
|
|
127
|
+
{lastUpdatedAt && (
|
|
128
|
+
<span className="text-[10px] text-text-subtle">{formatLastUpdated(lastUpdatedAt)}</span>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
131
|
<div className="flex items-center gap-1">
|
|
132
132
|
{onReload && (
|
|
133
133
|
<button
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useMemo } from "react";
|
|
1
|
+
import { useState, useMemo, useCallback } from "react";
|
|
2
2
|
import {
|
|
3
3
|
FolderOpen,
|
|
4
4
|
Terminal,
|
|
@@ -13,12 +13,15 @@ import {
|
|
|
13
13
|
Check,
|
|
14
14
|
Plus,
|
|
15
15
|
Search,
|
|
16
|
+
Bug,
|
|
16
17
|
} from "lucide-react";
|
|
17
18
|
import { useProjectStore, sortByRecent } from "@/stores/project-store";
|
|
18
19
|
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
20
|
+
import { useSettingsStore } from "@/stores/settings-store";
|
|
19
21
|
import { cn } from "@/lib/utils";
|
|
20
22
|
import { Separator } from "@/components/ui/separator";
|
|
21
23
|
import { FileTree } from "@/components/explorer/file-tree";
|
|
24
|
+
import { openBugReport } from "@/lib/report-bug";
|
|
22
25
|
|
|
23
26
|
interface MobileDrawerProps {
|
|
24
27
|
isOpen: boolean;
|
|
@@ -50,6 +53,7 @@ const MAX_VISIBLE_MOBILE = 5;
|
|
|
50
53
|
export function MobileDrawer({ isOpen, onClose }: MobileDrawerProps) {
|
|
51
54
|
const { projects, activeProject, setActiveProject } = useProjectStore();
|
|
52
55
|
const openTab = useTabStore((s) => s.openTab);
|
|
56
|
+
const version = useSettingsStore((s) => s.version);
|
|
53
57
|
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
|
|
54
58
|
const [query, setQuery] = useState("");
|
|
55
59
|
|
|
@@ -87,6 +91,8 @@ export function MobileDrawer({ isOpen, onClose }: MobileDrawerProps) {
|
|
|
87
91
|
setQuery("");
|
|
88
92
|
}
|
|
89
93
|
|
|
94
|
+
const handleReportBug = useCallback(() => openBugReport(version), [version]);
|
|
95
|
+
|
|
90
96
|
return (
|
|
91
97
|
<div
|
|
92
98
|
className={cn(
|
|
@@ -231,6 +237,18 @@ export function MobileDrawer({ isOpen, onClose }: MobileDrawerProps) {
|
|
|
231
237
|
</div>
|
|
232
238
|
)}
|
|
233
239
|
</div>
|
|
240
|
+
|
|
241
|
+
{/* Report Bug + Version */}
|
|
242
|
+
<div className="flex items-center justify-between px-4 py-2 border-t border-border">
|
|
243
|
+
{version && <span className="text-[10px] text-text-subtle">v{version}</span>}
|
|
244
|
+
<button
|
|
245
|
+
onClick={handleReportBug}
|
|
246
|
+
className="flex items-center gap-1 text-[10px] text-text-subtle hover:text-text-secondary transition-colors"
|
|
247
|
+
>
|
|
248
|
+
<Bug className="size-3" />
|
|
249
|
+
<span>Report Bug</span>
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
234
252
|
</div>
|
|
235
253
|
</div>
|
|
236
254
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useState, useMemo } from "react";
|
|
2
|
-
import { FolderOpen, ChevronDown, Check, Plus, Search } from "lucide-react";
|
|
1
|
+
import { useState, useMemo, useCallback } from "react";
|
|
2
|
+
import { FolderOpen, ChevronDown, Check, Plus, Search, Bug } from "lucide-react";
|
|
3
3
|
import { useProjectStore, sortByRecent } from "@/stores/project-store";
|
|
4
4
|
import { useTabStore } from "@/stores/tab-store";
|
|
5
5
|
import { FileTree } from "@/components/explorer/file-tree";
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "@/components/ui/dropdown-menu";
|
|
12
12
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
13
13
|
import { cn } from "@/lib/utils";
|
|
14
|
+
import { openBugReport } from "@/lib/report-bug";
|
|
14
15
|
|
|
15
16
|
/** Max projects shown before needing to search (desktop) */
|
|
16
17
|
const MAX_VISIBLE = 8;
|
|
@@ -39,6 +40,8 @@ export function Sidebar() {
|
|
|
39
40
|
openTab({ type: "projects", title: "Projects", projectId: null, closable: true });
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
const handleReportBug = useCallback(() => openBugReport(version), [version]);
|
|
44
|
+
|
|
42
45
|
return (
|
|
43
46
|
<aside className="hidden md:flex flex-col w-[280px] min-w-[280px] bg-background border-r border-border overflow-hidden">
|
|
44
47
|
{/* Logo + project dropdown — same height as tab bar */}
|
|
@@ -130,10 +133,18 @@ export function Sidebar() {
|
|
|
130
133
|
</div>
|
|
131
134
|
)}
|
|
132
135
|
|
|
133
|
-
{/* Version footer */}
|
|
136
|
+
{/* Version footer + Report Bug */}
|
|
134
137
|
{version && (
|
|
135
|
-
<div className="px-3 py-1.5 border-t border-border shrink-0">
|
|
138
|
+
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border shrink-0">
|
|
136
139
|
<span className="text-[10px] text-text-subtle">v{version}</span>
|
|
140
|
+
<button
|
|
141
|
+
onClick={handleReportBug}
|
|
142
|
+
title="Report a bug"
|
|
143
|
+
className="flex items-center gap-1 text-[10px] text-text-subtle hover:text-text-secondary transition-colors"
|
|
144
|
+
>
|
|
145
|
+
<Bug className="size-3" />
|
|
146
|
+
<span>Report Bug</span>
|
|
147
|
+
</button>
|
|
137
148
|
</div>
|
|
138
149
|
)}
|
|
139
150
|
</aside>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState, useCallback } from "react";
|
|
2
|
-
import { FolderOpen, GitBranch, Circle, Plus } from "lucide-react";
|
|
2
|
+
import { FolderOpen, GitBranch, Circle, Plus, Pencil, Trash2 } from "lucide-react";
|
|
3
3
|
import { useProjectStore } from "@/stores/project-store";
|
|
4
4
|
import { useTabStore } from "@/stores/tab-store";
|
|
5
5
|
import { api } from "@/lib/api-client";
|
|
@@ -10,29 +10,43 @@ import {
|
|
|
10
10
|
DialogHeader,
|
|
11
11
|
DialogTitle,
|
|
12
12
|
DialogFooter,
|
|
13
|
+
DialogDescription,
|
|
13
14
|
} from "@/components/ui/dialog";
|
|
14
15
|
import { Input } from "@/components/ui/input";
|
|
15
16
|
import { Button } from "@/components/ui/button";
|
|
16
17
|
import { DirSuggest } from "./dir-suggest";
|
|
18
|
+
import type { ProjectInfo } from "@/stores/project-store";
|
|
17
19
|
|
|
18
20
|
export function ProjectList() {
|
|
19
21
|
const { projects, activeProject, setActiveProject, fetchProjects, loading, error } =
|
|
20
22
|
useProjectStore();
|
|
21
23
|
const openTab = useTabStore((s) => s.openTab);
|
|
24
|
+
|
|
25
|
+
// Add dialog state
|
|
22
26
|
const [showAdd, setShowAdd] = useState(false);
|
|
23
27
|
const [addPath, setAddPath] = useState("");
|
|
24
28
|
const [addName, setAddName] = useState("");
|
|
25
29
|
const [addError, setAddError] = useState("");
|
|
26
30
|
|
|
31
|
+
// Edit dialog state
|
|
32
|
+
const [editTarget, setEditTarget] = useState<ProjectInfo | null>(null);
|
|
33
|
+
const [editName, setEditName] = useState("");
|
|
34
|
+
const [editPath, setEditPath] = useState("");
|
|
35
|
+
const [editError, setEditError] = useState("");
|
|
36
|
+
|
|
37
|
+
// Delete dialog state
|
|
38
|
+
const [deleteTarget, setDeleteTarget] = useState<ProjectInfo | null>(null);
|
|
39
|
+
const [deleteError, setDeleteError] = useState("");
|
|
40
|
+
|
|
27
41
|
useEffect(() => {
|
|
28
42
|
fetchProjects();
|
|
29
43
|
}, [fetchProjects]);
|
|
30
44
|
|
|
31
|
-
function handleClick(project:
|
|
45
|
+
function handleClick(project: ProjectInfo) {
|
|
32
46
|
setActiveProject(project);
|
|
33
47
|
}
|
|
34
48
|
|
|
35
|
-
function handleOpen(project:
|
|
49
|
+
function handleOpen(project: ProjectInfo) {
|
|
36
50
|
setActiveProject(project);
|
|
37
51
|
openTab({
|
|
38
52
|
type: "terminal",
|
|
@@ -60,6 +74,60 @@ export function ProjectList() {
|
|
|
60
74
|
}
|
|
61
75
|
}, [addPath, addName, fetchProjects]);
|
|
62
76
|
|
|
77
|
+
function openEdit(project: ProjectInfo, e: React.MouseEvent) {
|
|
78
|
+
e.stopPropagation();
|
|
79
|
+
setEditTarget(project);
|
|
80
|
+
setEditName(project.name);
|
|
81
|
+
setEditPath(project.path);
|
|
82
|
+
setEditError("");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const handleEditProject = useCallback(async () => {
|
|
86
|
+
if (!editTarget || !editName.trim()) return;
|
|
87
|
+
setEditError("");
|
|
88
|
+
try {
|
|
89
|
+
await api.patch(`/api/projects/${encodeURIComponent(editTarget.name)}`, {
|
|
90
|
+
name: editName.trim(),
|
|
91
|
+
path: editPath.trim() || undefined,
|
|
92
|
+
});
|
|
93
|
+
await fetchProjects();
|
|
94
|
+
// Update active project if it was the one edited
|
|
95
|
+
if (activeProject?.name === editTarget.name) {
|
|
96
|
+
const updated = useProjectStore.getState().projects
|
|
97
|
+
.find((p) => p.name === editName.trim());
|
|
98
|
+
if (updated) setActiveProject(updated);
|
|
99
|
+
}
|
|
100
|
+
setEditTarget(null);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
setEditError(e instanceof Error ? e.message : "Failed to update project");
|
|
103
|
+
}
|
|
104
|
+
}, [editTarget, editName, editPath, fetchProjects, activeProject, setActiveProject]);
|
|
105
|
+
|
|
106
|
+
function openDelete(project: ProjectInfo, e: React.MouseEvent) {
|
|
107
|
+
e.stopPropagation();
|
|
108
|
+
setDeleteTarget(project);
|
|
109
|
+
setDeleteError("");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const handleDeleteProject = useCallback(async () => {
|
|
113
|
+
if (!deleteTarget) return;
|
|
114
|
+
setDeleteError("");
|
|
115
|
+
try {
|
|
116
|
+
await api.del(`/api/projects/${encodeURIComponent(deleteTarget.name)}`);
|
|
117
|
+
// Clear active project if it was deleted
|
|
118
|
+
if (activeProject?.name === deleteTarget.name) {
|
|
119
|
+
const remaining = projects.filter((p) => p.name !== deleteTarget.name);
|
|
120
|
+
if (remaining.length > 0) {
|
|
121
|
+
setActiveProject(remaining[0]!);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
await fetchProjects();
|
|
125
|
+
setDeleteTarget(null);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
setDeleteError(e instanceof Error ? e.message : "Failed to delete project");
|
|
128
|
+
}
|
|
129
|
+
}, [deleteTarget, activeProject, projects, fetchProjects, setActiveProject]);
|
|
130
|
+
|
|
63
131
|
if (error) {
|
|
64
132
|
return (
|
|
65
133
|
<div className="flex items-center justify-center h-full p-4">
|
|
@@ -104,13 +172,31 @@ export function ProjectList() {
|
|
|
104
172
|
onClick={() => handleClick(project)}
|
|
105
173
|
onDoubleClick={() => handleOpen(project)}
|
|
106
174
|
className={cn(
|
|
107
|
-
"text-left p-4 rounded-lg border transition-colors",
|
|
175
|
+
"group text-left p-4 rounded-lg border transition-colors relative",
|
|
108
176
|
"min-h-[44px]",
|
|
109
177
|
activeProject?.name === project.name
|
|
110
178
|
? "bg-surface border-primary"
|
|
111
179
|
: "bg-surface border-border hover:border-text-subtle",
|
|
112
180
|
)}
|
|
113
181
|
>
|
|
182
|
+
{/* Action buttons */}
|
|
183
|
+
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
184
|
+
<button
|
|
185
|
+
onClick={(e) => openEdit(project, e)}
|
|
186
|
+
className="p-1.5 rounded-md hover:bg-surface-elevated text-text-subtle hover:text-text-primary transition-colors"
|
|
187
|
+
title="Edit project"
|
|
188
|
+
>
|
|
189
|
+
<Pencil className="size-3.5" />
|
|
190
|
+
</button>
|
|
191
|
+
<button
|
|
192
|
+
onClick={(e) => openDelete(project, e)}
|
|
193
|
+
className="p-1.5 rounded-md hover:bg-error/10 text-text-subtle hover:text-error transition-colors"
|
|
194
|
+
title="Remove project"
|
|
195
|
+
>
|
|
196
|
+
<Trash2 className="size-3.5" />
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
114
200
|
<div className="flex items-start gap-3">
|
|
115
201
|
<FolderOpen className="size-5 text-primary shrink-0 mt-0.5" />
|
|
116
202
|
<div className="flex-1 min-w-0 space-y-1">
|
|
@@ -182,6 +268,69 @@ export function ProjectList() {
|
|
|
182
268
|
</DialogFooter>
|
|
183
269
|
</DialogContent>
|
|
184
270
|
</Dialog>
|
|
271
|
+
|
|
272
|
+
{/* Edit Project Dialog */}
|
|
273
|
+
<Dialog open={!!editTarget} onOpenChange={(open) => !open && setEditTarget(null)}>
|
|
274
|
+
<DialogContent>
|
|
275
|
+
<DialogHeader>
|
|
276
|
+
<DialogTitle>Edit Project</DialogTitle>
|
|
277
|
+
</DialogHeader>
|
|
278
|
+
<div className="space-y-3">
|
|
279
|
+
<div>
|
|
280
|
+
<label className="text-sm text-text-secondary">Name</label>
|
|
281
|
+
<Input
|
|
282
|
+
value={editName}
|
|
283
|
+
onChange={(e) => setEditName(e.target.value)}
|
|
284
|
+
onKeyDown={(e) => e.key === "Enter" && handleEditProject()}
|
|
285
|
+
autoFocus
|
|
286
|
+
/>
|
|
287
|
+
</div>
|
|
288
|
+
<div>
|
|
289
|
+
<label className="text-sm text-text-secondary">Path</label>
|
|
290
|
+
<DirSuggest
|
|
291
|
+
value={editPath}
|
|
292
|
+
onChange={setEditPath}
|
|
293
|
+
placeholder="/home/user/my-project"
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
296
|
+
{editError && (
|
|
297
|
+
<p className="text-sm text-error">{editError}</p>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
<DialogFooter>
|
|
301
|
+
<Button variant="outline" onClick={() => setEditTarget(null)}>
|
|
302
|
+
Cancel
|
|
303
|
+
</Button>
|
|
304
|
+
<Button onClick={handleEditProject} disabled={!editName.trim()}>
|
|
305
|
+
Save
|
|
306
|
+
</Button>
|
|
307
|
+
</DialogFooter>
|
|
308
|
+
</DialogContent>
|
|
309
|
+
</Dialog>
|
|
310
|
+
|
|
311
|
+
{/* Delete Confirmation Dialog */}
|
|
312
|
+
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
313
|
+
<DialogContent>
|
|
314
|
+
<DialogHeader>
|
|
315
|
+
<DialogTitle>Remove Project</DialogTitle>
|
|
316
|
+
<DialogDescription>
|
|
317
|
+
Remove <strong>{deleteTarget?.name}</strong> from PPM? This only unregisters
|
|
318
|
+
it — project files on disk are not affected.
|
|
319
|
+
</DialogDescription>
|
|
320
|
+
</DialogHeader>
|
|
321
|
+
{deleteError && (
|
|
322
|
+
<p className="text-sm text-error">{deleteError}</p>
|
|
323
|
+
)}
|
|
324
|
+
<DialogFooter>
|
|
325
|
+
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
|
326
|
+
Cancel
|
|
327
|
+
</Button>
|
|
328
|
+
<Button variant="destructive" onClick={handleDeleteProject}>
|
|
329
|
+
Remove
|
|
330
|
+
</Button>
|
|
331
|
+
</DialogFooter>
|
|
332
|
+
</DialogContent>
|
|
333
|
+
</Dialog>
|
|
185
334
|
</div>
|
|
186
335
|
);
|
|
187
336
|
}
|
|
@@ -4,34 +4,40 @@ import { getAuthToken, projectUrl } from "@/lib/api-client";
|
|
|
4
4
|
import type { ChatMessage, ChatEvent, UsageInfo } from "../../types/chat";
|
|
5
5
|
import type { ChatWsServerMessage } from "../../types/api";
|
|
6
6
|
|
|
7
|
+
/** Callback to forward WS usage events to the external useUsage hook */
|
|
8
|
+
export type UsageEventCallback = (usage: Partial<UsageInfo>) => void;
|
|
9
|
+
|
|
7
10
|
interface ApprovalRequest {
|
|
8
11
|
requestId: string;
|
|
9
12
|
tool: string;
|
|
10
13
|
input: unknown;
|
|
11
14
|
}
|
|
12
15
|
|
|
16
|
+
interface UseChatOptions {
|
|
17
|
+
onUsageEvent?: UsageEventCallback;
|
|
18
|
+
}
|
|
19
|
+
|
|
13
20
|
interface UseChatReturn {
|
|
14
21
|
messages: ChatMessage[];
|
|
15
22
|
messagesLoading: boolean;
|
|
16
23
|
isStreaming: boolean;
|
|
17
24
|
pendingApproval: ApprovalRequest | null;
|
|
18
|
-
usageInfo: UsageInfo;
|
|
19
|
-
usageLoading: boolean;
|
|
20
25
|
sendMessage: (content: string) => void;
|
|
21
26
|
respondToApproval: (requestId: string, approved: boolean, data?: unknown) => void;
|
|
22
27
|
cancelStreaming: () => void;
|
|
23
|
-
|
|
28
|
+
reconnect: () => void;
|
|
29
|
+
refetchMessages: () => void;
|
|
24
30
|
isConnected: boolean;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
|
-
export function useChat(sessionId: string | null, providerId = "claude-sdk", projectName = ""): UseChatReturn {
|
|
33
|
+
export function useChat(sessionId: string | null, providerId = "claude-sdk", projectName = "", options?: UseChatOptions): UseChatReturn {
|
|
28
34
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
29
35
|
const [messagesLoading, setMessagesLoading] = useState(false);
|
|
30
36
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
31
37
|
const [pendingApproval, setPendingApproval] = useState<ApprovalRequest | null>(null);
|
|
32
38
|
const [isConnected, setIsConnected] = useState(false);
|
|
33
|
-
const
|
|
34
|
-
|
|
39
|
+
const onUsageEventRef = useRef(options?.onUsageEvent);
|
|
40
|
+
onUsageEventRef.current = options?.onUsageEvent;
|
|
35
41
|
const streamingContentRef = useRef("");
|
|
36
42
|
const streamingEventsRef = useRef<ChatEvent[]>([]);
|
|
37
43
|
const isStreamingRef = useRef(false);
|
|
@@ -137,15 +143,8 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
|
|
|
137
143
|
}
|
|
138
144
|
|
|
139
145
|
case "usage": {
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
const next = { ...prev, ...data.usage };
|
|
143
|
-
if (data.usage.totalCostUsd != null) {
|
|
144
|
-
next.queryCostUsd = data.usage.totalCostUsd;
|
|
145
|
-
next.totalCostUsd = (prev.totalCostUsd ?? 0) + data.usage.totalCostUsd;
|
|
146
|
-
}
|
|
147
|
-
return next;
|
|
148
|
-
});
|
|
146
|
+
// Forward to external usage hook
|
|
147
|
+
onUsageEventRef.current?.(data.usage);
|
|
149
148
|
break;
|
|
150
149
|
}
|
|
151
150
|
|
|
@@ -230,7 +229,7 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
|
|
|
230
229
|
? `/ws/project/${encodeURIComponent(projectName)}/chat/${sessionId}`
|
|
231
230
|
: "";
|
|
232
231
|
|
|
233
|
-
const { send } = useWebSocket({
|
|
232
|
+
const { send, connect: wsReconnect } = useWebSocket({
|
|
234
233
|
url: wsUrl,
|
|
235
234
|
onMessage: handleMessage,
|
|
236
235
|
autoConnect: !!sessionId && !!projectName,
|
|
@@ -249,20 +248,6 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
|
|
|
249
248
|
streamingEventsRef.current = [];
|
|
250
249
|
setIsConnected(false);
|
|
251
250
|
|
|
252
|
-
if (projectName) {
|
|
253
|
-
// Load cached usage/rate-limit info immediately
|
|
254
|
-
fetch(`${projectUrl(projectName)}/chat/usage?providerId=${providerId}`, {
|
|
255
|
-
headers: { Authorization: `Bearer ${getAuthToken()}` },
|
|
256
|
-
})
|
|
257
|
-
.then((r) => r.json())
|
|
258
|
-
.then((json: any) => {
|
|
259
|
-
if (!cancelled && json.ok && json.data) {
|
|
260
|
-
setUsageInfo((prev) => ({ ...prev, ...json.data }));
|
|
261
|
-
}
|
|
262
|
-
})
|
|
263
|
-
.catch(() => {});
|
|
264
|
-
}
|
|
265
|
-
|
|
266
251
|
if (sessionId && projectName) {
|
|
267
252
|
// Load message history
|
|
268
253
|
setMessagesLoading(true);
|
|
@@ -391,33 +376,37 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
|
|
|
391
376
|
setPendingApproval(null);
|
|
392
377
|
}, [send]);
|
|
393
378
|
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
379
|
+
const reconnect = useCallback(() => {
|
|
380
|
+
setIsConnected(false);
|
|
381
|
+
wsReconnect();
|
|
382
|
+
}, [wsReconnect]);
|
|
383
|
+
|
|
384
|
+
const refetchMessages = useCallback(() => {
|
|
385
|
+
if (!sessionId || !projectName || isStreamingRef.current) return;
|
|
386
|
+
setMessagesLoading(true);
|
|
387
|
+
fetch(`${projectUrl(projectName)}/chat/sessions/${sessionId}/messages?providerId=${providerId}`, {
|
|
398
388
|
headers: { Authorization: `Bearer ${getAuthToken()}` },
|
|
399
389
|
})
|
|
400
390
|
.then((r) => r.json())
|
|
401
391
|
.then((json: any) => {
|
|
402
|
-
if (json.ok && json.data) {
|
|
403
|
-
|
|
392
|
+
if (json.ok && Array.isArray(json.data) && json.data.length > 0) {
|
|
393
|
+
setMessages(json.data);
|
|
404
394
|
}
|
|
405
395
|
})
|
|
406
396
|
.catch(() => {})
|
|
407
|
-
.finally(() =>
|
|
408
|
-
}, [
|
|
397
|
+
.finally(() => setMessagesLoading(false));
|
|
398
|
+
}, [sessionId, providerId, projectName]);
|
|
409
399
|
|
|
410
400
|
return {
|
|
411
401
|
messages,
|
|
412
402
|
messagesLoading,
|
|
413
403
|
isStreaming,
|
|
414
404
|
pendingApproval,
|
|
415
|
-
usageInfo,
|
|
416
|
-
usageLoading,
|
|
417
405
|
sendMessage,
|
|
418
406
|
respondToApproval,
|
|
419
407
|
cancelStreaming,
|
|
420
|
-
|
|
408
|
+
reconnect,
|
|
409
|
+
refetchMessages,
|
|
421
410
|
isConnected,
|
|
422
411
|
};
|
|
423
412
|
}
|