@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/web/assets/api-client-B_eCZViO.js +1 -0
  3. package/dist/web/assets/chat-tab-FOn2nq1x.js +6 -0
  4. package/dist/web/assets/{code-editor-BgiyQO-M.js → code-editor-R0uEZQ-h.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-8_asmBRZ.js → diff-viewer-DDQ2Z0sz.js} +1 -1
  6. package/dist/web/assets/{git-graph-BiyTIbCz.js → git-graph-ugBsFNaz.js} +1 -1
  7. package/dist/web/assets/{git-status-panel-BifyO31N.js → git-status-panel-UMKtdAxp.js} +1 -1
  8. package/dist/web/assets/index-CGDMk8DE.css +2 -0
  9. package/dist/web/assets/index-Dmu22zQo.js +12 -0
  10. package/dist/web/assets/project-list-D38uQSpC.js +1 -0
  11. package/dist/web/assets/{settings-tab-Cn5Ja0_J.js → settings-tab-BpyCSbii.js} +1 -1
  12. package/dist/web/index.html +3 -3
  13. package/dist/web/sw.js +1 -1
  14. package/package.json +4 -4
  15. package/src/server/index.ts +32 -2
  16. package/src/server/routes/chat.ts +13 -18
  17. package/src/server/routes/projects.ts +12 -0
  18. package/src/server/routes/static.ts +2 -2
  19. package/src/services/claude-usage.service.ts +93 -74
  20. package/src/services/project.service.ts +43 -0
  21. package/src/types/chat.ts +0 -2
  22. package/src/web/components/chat/chat-tab.tsx +24 -7
  23. package/src/web/components/chat/usage-badge.tsx +23 -23
  24. package/src/web/components/layout/mobile-drawer.tsx +19 -1
  25. package/src/web/components/layout/sidebar.tsx +15 -4
  26. package/src/web/components/projects/project-list.tsx +153 -4
  27. package/src/web/hooks/use-chat.ts +30 -41
  28. package/src/web/hooks/use-usage.ts +65 -0
  29. package/src/web/lib/api-client.ts +9 -0
  30. package/src/web/lib/report-bug.ts +33 -0
  31. package/dist/web/assets/api-client-BgVufYKf.js +0 -1
  32. package/dist/web/assets/chat-tab-C4ovA2w4.js +0 -6
  33. package/dist/web/assets/index-DILaVO6p.css +0 -2
  34. package/dist/web/assets/index-DasstYgw.js +0 -11
  35. 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
- refreshUsage,
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
- {isConnected && (
254
- <span className="size-2 rounded-full bg-green-500" title="Connected" />
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
- <div className="flex items-center gap-2">
98
- {status && (
99
- <span className={`text-[10px] ${status.color}`}>{status.text}</span>
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
- export function UsageDetailPanel({ usage, visible, onClose, onReload, loading }: UsageDetailPanelProps) {
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
- <span className="text-xs font-semibold text-text-primary">Usage Limits</span>
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: (typeof projects)[number]) {
45
+ function handleClick(project: ProjectInfo) {
32
46
  setActiveProject(project);
33
47
  }
34
48
 
35
- function handleOpen(project: (typeof projects)[number]) {
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
- refreshUsage: () => void;
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 [usageInfo, setUsageInfo] = useState<UsageInfo>({});
34
- const [usageLoading, setUsageLoading] = useState(false);
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
- // Merge usage info accumulate totalCostUsd, track queryCostUsd
141
- setUsageInfo((prev) => {
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 refreshUsage = useCallback(() => {
395
- if (!projectName) return;
396
- setUsageLoading(true);
397
- fetch(`${projectUrl(projectName)}/chat/usage?providerId=${providerId}&_t=${Date.now()}`, {
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
- setUsageInfo((prev) => ({ ...prev, ...json.data }));
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(() => setUsageLoading(false));
408
- }, [projectName, providerId]);
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
- refreshUsage,
408
+ reconnect,
409
+ refetchMessages,
421
410
  isConnected,
422
411
  };
423
412
  }