@hienlh/ppm 0.6.6 → 0.7.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 (67) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +86 -313
  3. package/dist/web/assets/chat-tab-CbNbBMGw.js +7 -0
  4. package/dist/web/assets/{code-editor-ZFl5kZ4-.js → code-editor-D6OuzcC-.js} +1 -1
  5. package/dist/web/assets/{database-viewer-DPpOsMqa.js → database-viewer-BxUpM_uA.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-CX74l6lV.js → diff-viewer-DAhrHpNM.js} +1 -1
  7. package/dist/web/assets/{dist-Jb3Tnkpc.js → dist-CNRrBoQi.js} +14 -14
  8. package/dist/web/assets/git-graph-BpTt5iOd.js +1 -0
  9. package/dist/web/assets/index-BU_07_oW.js +29 -0
  10. package/dist/web/assets/index-CBQhXXeV.css +2 -0
  11. package/dist/web/assets/keybindings-store-C0m8_V9X.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-Bke6DHFh.js → markdown-renderer-CvGYO9sH.js} +2 -2
  13. package/dist/web/assets/postgres-viewer-BL99auSm.js +1 -0
  14. package/dist/web/assets/{settings-tab-DD05d8rM.js → settings-tab-Bwsxb41F.js} +1 -1
  15. package/dist/web/assets/{sqlite-viewer-Cx7tLyT-.js → sqlite-viewer-DfgaCbWT.js} +1 -1
  16. package/dist/web/assets/terminal-tab-D27e4ZTD.js +36 -0
  17. package/dist/web/index.html +4 -3
  18. package/dist/web/sw.js +1 -1
  19. package/package.json +1 -1
  20. package/src/lib/network-utils.ts +12 -0
  21. package/src/server/index.ts +3 -79
  22. package/src/server/routes/database.ts +57 -0
  23. package/src/server/routes/fs-browse.ts +67 -0
  24. package/src/server/routes/settings.ts +52 -0
  25. package/src/server/routes/tunnel.ts +1 -12
  26. package/src/server/ws/chat.ts +30 -3
  27. package/src/services/config.service.ts +1 -1
  28. package/src/services/fs-browse.service.ts +216 -0
  29. package/src/services/notification.service.ts +42 -0
  30. package/src/services/telegram-notification.service.ts +106 -0
  31. package/src/types/config.ts +6 -0
  32. package/src/web/app.tsx +61 -18
  33. package/src/web/components/chat/message-list.tsx +8 -105
  34. package/src/web/components/chat/question-card.tsx +334 -0
  35. package/src/web/components/database/connection-form-dialog.tsx +15 -6
  36. package/src/web/components/database/connection-import-export.tsx +116 -0
  37. package/src/web/components/database/database-sidebar.tsx +12 -8
  38. package/src/web/components/database/use-connections.ts +13 -1
  39. package/src/web/components/layout/add-project-form.tsx +23 -12
  40. package/src/web/components/layout/command-palette.tsx +1 -1
  41. package/src/web/components/layout/draggable-tab.tsx +10 -2
  42. package/src/web/components/layout/mobile-nav.tsx +42 -3
  43. package/src/web/components/layout/project-bar.tsx +16 -8
  44. package/src/web/components/layout/tab-bar.tsx +55 -4
  45. package/src/web/components/projects/dir-suggest.tsx +22 -12
  46. package/src/web/components/settings/settings-tab.tsx +135 -94
  47. package/src/web/components/settings/telegram-settings-section.tsx +113 -0
  48. package/src/web/components/ui/accordion.tsx +64 -0
  49. package/src/web/components/ui/browse-button.tsx +42 -0
  50. package/src/web/components/ui/file-browser-picker.tsx +374 -0
  51. package/src/web/hooks/use-chat.ts +29 -0
  52. package/src/web/hooks/use-notification-badge.ts +20 -0
  53. package/src/web/hooks/use-tab-overflow.ts +91 -0
  54. package/src/web/hooks/use-url-sync.ts +5 -2
  55. package/src/web/index.html +1 -0
  56. package/src/web/lib/favicon.ts +21 -0
  57. package/src/web/lib/notification-sounds.ts +61 -0
  58. package/src/web/stores/notification-store.ts +83 -0
  59. package/src/web/stores/project-store.ts +0 -14
  60. package/dist/web/assets/chat-tab-dwpaSkQD.js +0 -7
  61. package/dist/web/assets/git-graph-Dju1rygf.js +0 -1
  62. package/dist/web/assets/index-DSg2VjxL.css +0 -2
  63. package/dist/web/assets/index-DXOEmhRm.js +0 -21
  64. package/dist/web/assets/keybindings-store-VhiJwp77.js +0 -1
  65. package/dist/web/assets/postgres-viewer-DaNYnInA.js +0 -1
  66. package/dist/web/assets/terminal-tab-_farMLMO.js +0 -36
  67. /package/dist/web/assets/{tab-store-DIyJSjtr.js → tab-store-Bm1Hw8OR.js} +0 -0
@@ -0,0 +1,113 @@
1
+ import { useState, useEffect } from "react";
2
+ import { Send } from "lucide-react";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Input } from "@/components/ui/input";
5
+ import { api } from "@/lib/api-client";
6
+
7
+ interface TelegramConfig {
8
+ bot_token: string;
9
+ chat_id: string;
10
+ }
11
+
12
+ export function TelegramSettingsSection() {
13
+ const [config, setConfig] = useState<TelegramConfig>({ bot_token: "", chat_id: "" });
14
+ const [tokenInput, setTokenInput] = useState("");
15
+ const [chatIdInput, setChatIdInput] = useState("");
16
+ const [saving, setSaving] = useState(false);
17
+ const [testing, setTesting] = useState(false);
18
+ const [status, setStatus] = useState<{ type: "ok" | "err"; msg: string } | null>(null);
19
+
20
+ useEffect(() => {
21
+ api.get<TelegramConfig>("/api/settings/telegram").then((data) => {
22
+ setConfig(data);
23
+ setChatIdInput(data.chat_id);
24
+ }).catch(() => {});
25
+ }, []);
26
+
27
+ const save = async () => {
28
+ setSaving(true);
29
+ setStatus(null);
30
+ try {
31
+ const body: Record<string, string> = { chat_id: chatIdInput };
32
+ if (tokenInput) body.bot_token = tokenInput;
33
+ const data = await api.put<TelegramConfig>("/api/settings/telegram", body);
34
+ setConfig(data);
35
+ setTokenInput("");
36
+ setStatus({ type: "ok", msg: "Saved" });
37
+ } catch (e) {
38
+ setStatus({ type: "err", msg: (e as Error).message });
39
+ } finally {
40
+ setSaving(false);
41
+ }
42
+ };
43
+
44
+ const test = async () => {
45
+ setTesting(true);
46
+ setStatus(null);
47
+ try {
48
+ // Send current input values; backend falls back to saved config for empty fields
49
+ await api.post("/api/settings/telegram/test", {
50
+ ...(tokenInput ? { bot_token: tokenInput } : {}),
51
+ ...(chatIdInput ? { chat_id: chatIdInput } : {}),
52
+ });
53
+ setStatus({ type: "ok", msg: "Test message sent!" });
54
+ } catch (e) {
55
+ setStatus({ type: "err", msg: (e as Error).message });
56
+ } finally {
57
+ setTesting(false);
58
+ }
59
+ };
60
+
61
+ const isConfigured = !!config.bot_token;
62
+
63
+ return (
64
+ <div className="space-y-2">
65
+ <div className="space-y-1.5">
66
+ <label className="text-[11px] text-text-subtle">Bot Token</label>
67
+ <Input
68
+ type="password"
69
+ placeholder={isConfigured ? "•••••• (saved)" : "123456:ABC-DEF..."}
70
+ value={tokenInput}
71
+ onChange={(e) => setTokenInput(e.target.value)}
72
+ className="h-7 text-xs"
73
+ />
74
+ </div>
75
+ <div className="space-y-1.5">
76
+ <label className="text-[11px] text-text-subtle">Chat ID</label>
77
+ <Input
78
+ placeholder="-1001234567890"
79
+ value={chatIdInput}
80
+ onChange={(e) => setChatIdInput(e.target.value)}
81
+ className="h-7 text-xs"
82
+ />
83
+ <p className="text-[10px] text-text-subtle">Personal or group chat ID</p>
84
+ </div>
85
+ <div className="flex gap-1.5">
86
+ <Button
87
+ variant="default"
88
+ size="sm"
89
+ className="h-7 text-xs flex-1"
90
+ disabled={saving || (!tokenInput && !chatIdInput)}
91
+ onClick={save}
92
+ >
93
+ {saving ? "..." : "Save"}
94
+ </Button>
95
+ <Button
96
+ variant="outline"
97
+ size="sm"
98
+ className="h-7 text-xs gap-1"
99
+ disabled={testing || !isConfigured}
100
+ onClick={test}
101
+ >
102
+ <Send className="size-3" />
103
+ {testing ? "..." : "Test"}
104
+ </Button>
105
+ </div>
106
+ {status && (
107
+ <p className={`text-[11px] ${status.type === "ok" ? "text-green-600 dark:text-green-400" : "text-destructive"}`}>
108
+ {status.msg}
109
+ </p>
110
+ )}
111
+ </div>
112
+ );
113
+ }
@@ -0,0 +1,64 @@
1
+ import * as React from "react"
2
+ import { ChevronDownIcon } from "lucide-react"
3
+ import { Accordion as AccordionPrimitive } from "radix-ui"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ function Accordion({
8
+ ...props
9
+ }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
10
+ return <AccordionPrimitive.Root data-slot="accordion" {...props} />
11
+ }
12
+
13
+ function AccordionItem({
14
+ className,
15
+ ...props
16
+ }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
17
+ return (
18
+ <AccordionPrimitive.Item
19
+ data-slot="accordion-item"
20
+ className={cn("border-b last:border-b-0", className)}
21
+ {...props}
22
+ />
23
+ )
24
+ }
25
+
26
+ function AccordionTrigger({
27
+ className,
28
+ children,
29
+ ...props
30
+ }: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
31
+ return (
32
+ <AccordionPrimitive.Header className="flex">
33
+ <AccordionPrimitive.Trigger
34
+ data-slot="accordion-trigger"
35
+ className={cn(
36
+ "flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
37
+ className
38
+ )}
39
+ {...props}
40
+ >
41
+ {children}
42
+ <ChevronDownIcon className="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" />
43
+ </AccordionPrimitive.Trigger>
44
+ </AccordionPrimitive.Header>
45
+ )
46
+ }
47
+
48
+ function AccordionContent({
49
+ className,
50
+ children,
51
+ ...props
52
+ }: React.ComponentProps<typeof AccordionPrimitive.Content>) {
53
+ return (
54
+ <AccordionPrimitive.Content
55
+ data-slot="accordion-content"
56
+ className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
57
+ {...props}
58
+ >
59
+ <div className={cn("pt-0 pb-4", className)}>{children}</div>
60
+ </AccordionPrimitive.Content>
61
+ )
62
+ }
63
+
64
+ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
@@ -0,0 +1,42 @@
1
+ import { useState } from "react";
2
+ import { FolderOpen } from "lucide-react";
3
+ import { Button } from "@/components/ui/button";
4
+ import { FileBrowserPicker, type FileBrowserPickerProps } from "./file-browser-picker";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ interface BrowseButtonProps {
8
+ mode: FileBrowserPickerProps["mode"];
9
+ accept?: string[];
10
+ root?: string;
11
+ title?: string;
12
+ onSelect: (path: string) => void;
13
+ className?: string;
14
+ }
15
+
16
+ export function BrowseButton({ mode, accept, root, title, onSelect, className }: BrowseButtonProps) {
17
+ const [open, setOpen] = useState(false);
18
+
19
+ return (
20
+ <>
21
+ <Button
22
+ type="button"
23
+ variant="ghost"
24
+ size="icon"
25
+ className={cn("size-8 shrink-0", className)}
26
+ onClick={() => setOpen(true)}
27
+ title={title ?? "Browse..."}
28
+ >
29
+ <FolderOpen className="size-4" />
30
+ </Button>
31
+ <FileBrowserPicker
32
+ open={open}
33
+ mode={mode}
34
+ accept={accept}
35
+ root={root}
36
+ title={title}
37
+ onSelect={(path) => { onSelect(path); setOpen(false); }}
38
+ onCancel={() => setOpen(false)}
39
+ />
40
+ </>
41
+ );
42
+ }
@@ -0,0 +1,374 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import {
3
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
4
+ } from "@/components/ui/dialog";
5
+ import { Button } from "@/components/ui/button";
6
+ import { ScrollArea } from "@/components/ui/scroll-area";
7
+ import { Input } from "@/components/ui/input";
8
+ import { api } from "@/lib/api-client";
9
+ import {
10
+ Folder, File, Database, Home, Monitor, FileText,
11
+ Download, ChevronRight, ArrowLeft, Search, Loader2, Clock, Eye, EyeOff,
12
+ } from "lucide-react";
13
+ import { cn } from "@/lib/utils";
14
+
15
+ // ── Types ──────────────────────────────────────────────────────────
16
+
17
+ interface BrowseEntry {
18
+ name: string;
19
+ path: string;
20
+ type: "file" | "directory";
21
+ size?: number;
22
+ modified: string;
23
+ }
24
+
25
+ interface BrowseResult {
26
+ entries: BrowseEntry[];
27
+ current: string;
28
+ parent: string | null;
29
+ breadcrumbs: { name: string; path: string }[];
30
+ }
31
+
32
+ export interface FileBrowserPickerProps {
33
+ open: boolean;
34
+ mode: "file" | "folder" | "both";
35
+ accept?: string[];
36
+ root?: string;
37
+ title?: string;
38
+ onSelect: (path: string) => void;
39
+ onCancel: () => void;
40
+ }
41
+
42
+ // ── Helpers ────────────────────────────────────────────────────────
43
+
44
+ const RECENT_KEY = "ppm-recent-paths";
45
+ const MAX_RECENT = 5;
46
+
47
+ const QUICK_ACCESS = [
48
+ { name: "Home", path: "~", icon: Home },
49
+ { name: "Desktop", path: "~/Desktop", icon: Monitor },
50
+ { name: "Documents", path: "~/Documents", icon: FileText },
51
+ { name: "Downloads", path: "~/Downloads", icon: Download },
52
+ ];
53
+
54
+ function getRecent(): string[] {
55
+ try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]"); } catch { return []; }
56
+ }
57
+
58
+ function saveRecent(dirPath: string): void {
59
+ const updated = [dirPath, ...getRecent().filter((p) => p !== dirPath)].slice(0, MAX_RECENT);
60
+ localStorage.setItem(RECENT_KEY, JSON.stringify(updated));
61
+ }
62
+
63
+ function formatSize(bytes?: number): string {
64
+ if (bytes == null) return "";
65
+ if (bytes < 1024) return `${bytes} B`;
66
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
67
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
68
+ }
69
+
70
+ function formatRelativeTime(iso: string): string {
71
+ const diff = Date.now() - new Date(iso).getTime();
72
+ const mins = Math.floor(diff / 60000);
73
+ if (mins < 1) return "now";
74
+ if (mins < 60) return `${mins}m ago`;
75
+ const hrs = Math.floor(mins / 60);
76
+ if (hrs < 24) return `${hrs}h ago`;
77
+ const days = Math.floor(hrs / 24);
78
+ return `${days}d ago`;
79
+ }
80
+
81
+ function fileIcon(entry: BrowseEntry): React.ReactNode {
82
+ if (entry.type === "directory") return <Folder className="size-4 text-blue-500" />;
83
+ const ext = entry.name.split(".").pop()?.toLowerCase();
84
+ if (ext && ["db", "sqlite", "sqlite3"].includes(ext)) return <Database className="size-4 text-amber-500" />;
85
+ return <File className="size-4 text-text-subtle" />;
86
+ }
87
+
88
+ function matchesAccept(name: string, accept?: string[]): boolean {
89
+ if (!accept?.length) return true;
90
+ const ext = "." + name.split(".").pop()?.toLowerCase();
91
+ return accept.includes(ext);
92
+ }
93
+
94
+ // ── Main Component ─────────────────────────────────────────────────
95
+
96
+ export function FileBrowserPicker({
97
+ open, mode, accept, root, title, onSelect, onCancel,
98
+ }: FileBrowserPickerProps) {
99
+ const [entries, setEntries] = useState<BrowseEntry[]>([]);
100
+ const [current, setCurrent] = useState("");
101
+ const [parent, setParent] = useState<string | null>(null);
102
+ const [breadcrumbs, setBreadcrumbs] = useState<{ name: string; path: string }[]>([]);
103
+ const [selected, setSelected] = useState<string | null>(null);
104
+ const [loading, setLoading] = useState(false);
105
+ const [error, setError] = useState<string | null>(null);
106
+ const [search, setSearch] = useState("");
107
+ const [pathInput, setPathInput] = useState("");
108
+ const [showHidden, setShowHidden] = useState(false);
109
+ const [recentPaths, setRecentPaths] = useState<string[]>([]);
110
+ const listRef = useRef<HTMLDivElement>(null);
111
+ const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
112
+
113
+ const defaultTitle = mode === "folder" ? "Select Folder" : mode === "file" ? "Select File" : "Select File or Folder";
114
+
115
+ const fetchDir = useCallback(async (dirPath?: string, hidden?: boolean) => {
116
+ setLoading(true);
117
+ setError(null);
118
+ setSelected(null);
119
+ setSearch("");
120
+ try {
121
+ const params = new URLSearchParams();
122
+ if (dirPath) params.set("path", dirPath);
123
+ if (hidden) params.set("showHidden", "true");
124
+ const result = await api.get<BrowseResult>(`/api/fs/browse?${params}`);
125
+ setEntries(result.entries);
126
+ setCurrent(result.current);
127
+ setParent(result.parent);
128
+ setBreadcrumbs(result.breadcrumbs);
129
+ setPathInput(result.current);
130
+ } catch (e) {
131
+ setError((e as Error).message || "Failed to browse directory");
132
+ } finally {
133
+ setLoading(false);
134
+ }
135
+ }, []);
136
+
137
+ // Fetch on open
138
+ useEffect(() => {
139
+ if (open) {
140
+ fetchDir(root ?? "~", showHidden);
141
+ setRecentPaths(getRecent());
142
+ }
143
+ }, [open, root, fetchDir, showHidden]);
144
+
145
+ const handleNavigate = (path: string) => fetchDir(path, showHidden);
146
+
147
+ const toggleHidden = () => {
148
+ const next = !showHidden;
149
+ setShowHidden(next);
150
+ fetchDir(current || (root ?? "~"), next);
151
+ };
152
+
153
+ const handlePathInputSubmit = (e: React.KeyboardEvent) => {
154
+ if (e.key === "Enter" && pathInput.trim()) {
155
+ fetchDir(pathInput.trim());
156
+ }
157
+ };
158
+
159
+ const handleEntryClick = (entry: BrowseEntry) => {
160
+ if (entry.type === "directory") {
161
+ if (mode === "file") {
162
+ // In file mode, clicking a dir navigates into it
163
+ handleNavigate(entry.path);
164
+ } else {
165
+ // In folder/both mode, clicking selects it
166
+ setSelected(entry.path);
167
+ }
168
+ } else {
169
+ // File: select if mode allows
170
+ if (mode !== "folder") {
171
+ setSelected(entry.path);
172
+ }
173
+ }
174
+ };
175
+
176
+ const handleEntryDoubleClick = (entry: BrowseEntry) => {
177
+ if (entry.type === "directory") {
178
+ handleNavigate(entry.path);
179
+ }
180
+ };
181
+
182
+ const handleConfirm = () => {
183
+ if (!selected) return;
184
+ saveRecent(current);
185
+ onSelect(selected);
186
+ };
187
+
188
+ // Filter entries by search + accept
189
+ const visible = entries.filter((e) => {
190
+ if (search && !e.name.toLowerCase().includes(search.toLowerCase())) return false;
191
+ if (e.type === "file" && accept?.length && !matchesAccept(e.name, accept)) return false;
192
+ return true;
193
+ });
194
+
195
+ const isSelectable = (entry: BrowseEntry): boolean => {
196
+ if (entry.type === "directory") return mode !== "file";
197
+ return mode !== "folder";
198
+ };
199
+
200
+ const content = (
201
+ <div className="flex flex-col flex-1 min-h-0">
202
+ {/* Path input bar */}
203
+ <div className="flex items-center gap-1.5 px-3 py-2 border-b border-border">
204
+ {parent && (
205
+ <Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={() => handleNavigate(parent)}>
206
+ <ArrowLeft className="size-4" />
207
+ </Button>
208
+ )}
209
+ <Input
210
+ value={pathInput}
211
+ onChange={(e) => setPathInput(e.target.value)}
212
+ onKeyDown={handlePathInputSubmit}
213
+ placeholder="Type path and press Enter"
214
+ className="h-7 text-xs font-mono flex-1"
215
+ />
216
+ </div>
217
+
218
+ {/* Breadcrumbs */}
219
+ <div className="flex items-center gap-0.5 px-3 py-1.5 border-b border-border overflow-x-auto text-xs">
220
+ {breadcrumbs.map((crumb, i) => (
221
+ <span key={crumb.path} className="flex items-center shrink-0">
222
+ {i > 0 && <ChevronRight className="size-3 text-text-subtle mx-0.5" />}
223
+ <button
224
+ type="button"
225
+ onClick={() => handleNavigate(crumb.path)}
226
+ className="hover:text-primary hover:underline text-text-secondary"
227
+ >
228
+ {crumb.name}
229
+ </button>
230
+ </span>
231
+ ))}
232
+ </div>
233
+
234
+ {/* Main area */}
235
+ <div className="flex flex-1 min-h-0 overflow-hidden">
236
+ {/* Quick access sidebar (desktop only) */}
237
+ {!isMobile && (
238
+ <div className="w-36 border-r border-border py-2 px-1 shrink-0 overflow-y-auto">
239
+ {QUICK_ACCESS.map((qa) => (
240
+ <button
241
+ key={qa.path}
242
+ type="button"
243
+ onClick={() => handleNavigate(qa.path)}
244
+ className={cn(
245
+ "flex items-center gap-2 w-full px-2 py-1 text-xs rounded-md hover:bg-surface-hover text-left",
246
+ current.endsWith(qa.name) && "bg-primary/10 text-primary",
247
+ )}
248
+ >
249
+ <qa.icon className="size-3.5" />
250
+ {qa.name}
251
+ </button>
252
+ ))}
253
+ {recentPaths.length > 0 && (
254
+ <>
255
+ <div className="text-[10px] text-text-subtle px-2 mt-3 mb-1 font-medium">Recent</div>
256
+ {recentPaths.map((rp) => (
257
+ <button
258
+ key={rp}
259
+ type="button"
260
+ onClick={() => handleNavigate(rp)}
261
+ className="flex items-center gap-2 w-full px-2 py-1 text-xs rounded-md hover:bg-surface-hover text-left truncate"
262
+ >
263
+ <Clock className="size-3 shrink-0" />
264
+ <span className="truncate">{rp.split("/").pop()}</span>
265
+ </button>
266
+ ))}
267
+ </>
268
+ )}
269
+ </div>
270
+ )}
271
+
272
+ {/* Entry list */}
273
+ <ScrollArea className="flex-1 min-h-0">
274
+ {loading ? (
275
+ <div className="flex items-center justify-center py-12">
276
+ <Loader2 className="size-5 animate-spin text-text-subtle" />
277
+ </div>
278
+ ) : error ? (
279
+ <div className="text-center py-8 text-xs text-red-500">{error}</div>
280
+ ) : visible.length === 0 ? (
281
+ <div className="text-center py-8 text-xs text-text-subtle">
282
+ {search ? "No matching entries" : "Empty directory"}
283
+ </div>
284
+ ) : (
285
+ <div ref={listRef} className="py-1">
286
+ {visible.map((entry) => {
287
+ const selectable = isSelectable(entry);
288
+ return (
289
+ <button
290
+ key={entry.path}
291
+ type="button"
292
+ onClick={() => handleEntryClick(entry)}
293
+ onDoubleClick={() => handleEntryDoubleClick(entry)}
294
+ className={cn(
295
+ "flex items-center gap-2 w-full px-3 py-1.5 text-left text-xs transition-colors",
296
+ selected === entry.path
297
+ ? "bg-primary/10 text-primary"
298
+ : selectable
299
+ ? "hover:bg-surface-hover text-text-primary"
300
+ : "opacity-40 cursor-default",
301
+ )}
302
+ disabled={!selectable && entry.type === "file"}
303
+ >
304
+ {fileIcon(entry)}
305
+ <span className={cn("flex-1 truncate", entry.type === "directory" && "font-medium")}>
306
+ {entry.name}
307
+ </span>
308
+ <span className="text-text-subtle text-[10px] shrink-0 w-14 text-right">
309
+ {formatSize(entry.size)}
310
+ </span>
311
+ <span className="text-text-subtle text-[10px] shrink-0 w-14 text-right">
312
+ {formatRelativeTime(entry.modified)}
313
+ </span>
314
+ </button>
315
+ );
316
+ })}
317
+ </div>
318
+ )}
319
+ </ScrollArea>
320
+ </div>
321
+
322
+ {/* Footer */}
323
+ <div className="flex items-center gap-2 px-3 py-2 border-t border-border shrink-0">
324
+ <Button
325
+ variant="ghost"
326
+ size="icon"
327
+ className={cn("size-7 shrink-0", showHidden && "text-primary")}
328
+ onClick={toggleHidden}
329
+ title={showHidden ? "Hide hidden files" : "Show hidden files"}
330
+ >
331
+ {showHidden ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
332
+ </Button>
333
+ {accept?.length ? (
334
+ <span className="text-[10px] text-text-subtle bg-surface-hover px-1.5 py-0.5 rounded">
335
+ {accept.join(", ")}
336
+ </span>
337
+ ) : null}
338
+ <div className="flex-1 max-w-48">
339
+ <div className="relative">
340
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3 text-text-subtle" />
341
+ <Input
342
+ value={search}
343
+ onChange={(e) => setSearch(e.target.value)}
344
+ placeholder="Filter..."
345
+ className="h-6 text-[11px] pl-6"
346
+ />
347
+ </div>
348
+ </div>
349
+ <div className="flex-1" />
350
+ <Button variant="outline" size="sm" onClick={onCancel} className="h-7 text-xs">
351
+ Cancel
352
+ </Button>
353
+ <Button size="sm" onClick={handleConfirm} disabled={!selected} className="h-7 text-xs">
354
+ Select
355
+ </Button>
356
+ </div>
357
+ </div>
358
+ );
359
+
360
+ // Responsive: Dialog for desktop, simplified dialog with taller content for mobile
361
+ return (
362
+ <Dialog open={open} onOpenChange={(v) => { if (!v) onCancel(); }}>
363
+ <DialogContent className={cn(
364
+ "p-0 gap-0 overflow-hidden flex flex-col",
365
+ isMobile ? "max-w-[95vw] h-[85vh]" : "max-w-2xl h-[70vh]",
366
+ )}>
367
+ <DialogHeader className="px-3 py-2 border-b border-border">
368
+ <DialogTitle className="text-sm">{title ?? defaultTitle}</DialogTitle>
369
+ </DialogHeader>
370
+ {content}
371
+ </DialogContent>
372
+ </Dialog>
373
+ );
374
+ }
@@ -1,6 +1,9 @@
1
1
  import { useState, useCallback, useRef, useEffect } from "react";
2
2
  import { useWebSocket } from "./use-websocket";
3
3
  import { getAuthToken, projectUrl } from "@/lib/api-client";
4
+ import { useNotificationStore } from "@/stores/notification-store";
5
+ import { usePanelStore } from "@/stores/panel-store";
6
+ import { playNotificationSound } from "@/lib/notification-sounds";
4
7
  import type { ChatMessage, ChatEvent } from "../../types/chat";
5
8
  import type { ChatWsServerMessage } from "../../types/api";
6
9
 
@@ -33,6 +36,16 @@ interface UseChatReturn {
33
36
  isConnected: boolean;
34
37
  }
35
38
 
39
+ /** Check if the chat tab for this session is the active foreground tab */
40
+ function isSessionTabActive(sid: string): boolean {
41
+ if (document.hidden) return false;
42
+ const { panels, focusedPanelId } = usePanelStore.getState();
43
+ const panel = panels[focusedPanelId];
44
+ if (!panel) return false;
45
+ const activeTab = panel.tabs.find((t) => t.id === panel.activeTabId);
46
+ return activeTab?.type === "chat" && activeTab.metadata?.sessionId === sid;
47
+ }
48
+
36
49
  export function useChat(sessionId: string | null, providerId = "claude", projectName = ""): UseChatReturn {
37
50
  const [messages, setMessages] = useState<ChatMessage[]>([]);
38
51
  const [messagesLoading, setMessagesLoading] = useState(false);
@@ -52,6 +65,11 @@ export function useChat(sessionId: string | null, providerId = "claude", project
52
65
  const pendingMessageRef = useRef<string | null>(null);
53
66
  const sendRef = useRef<(data: string) => void>(() => {});
54
67
  const refetchRef = useRef<(() => void) | null>(null);
68
+ // Refs for notification dispatch inside handleMessage (which has [] deps)
69
+ const sessionIdRef = useRef(sessionId);
70
+ sessionIdRef.current = sessionId;
71
+ const projectNameRef = useRef(projectName);
72
+ projectNameRef.current = projectName;
55
73
 
56
74
  const handleMessage = useCallback((event: MessageEvent) => {
57
75
  let data: ChatWsServerMessage;
@@ -215,6 +233,12 @@ export function useChat(sessionId: string | null, providerId = "claude", project
215
233
  tool: data.tool,
216
234
  input: data.input,
217
235
  });
236
+ // Local notification badge — only if this tab is NOT active
237
+ if (sessionIdRef.current && !isSessionTabActive(sessionIdRef.current)) {
238
+ const nType = data.tool === "AskUserQuestion" ? "question" : "approval_request";
239
+ useNotificationStore.getState().addNotification(sessionIdRef.current, nType, projectNameRef.current);
240
+ playNotificationSound(nType);
241
+ }
218
242
  break;
219
243
  }
220
244
 
@@ -253,6 +277,11 @@ export function useChat(sessionId: string | null, providerId = "claude", project
253
277
  if (data.contextWindowPct != null) {
254
278
  setContextWindowPct(data.contextWindowPct);
255
279
  }
280
+ // Local notification badge — only if this tab is NOT active
281
+ if (sessionIdRef.current && !isSessionTabActive(sessionIdRef.current)) {
282
+ useNotificationStore.getState().addNotification(sessionIdRef.current, "done", projectNameRef.current);
283
+ playNotificationSound("done");
284
+ }
256
285
  // Finalize the streaming message — capture refs before clearing
257
286
  const finalContent = streamingContentRef.current;
258
287
  const finalEvents = [...streamingEventsRef.current];
@@ -0,0 +1,20 @@
1
+ import { useEffect } from "react";
2
+ import { useNotificationStore, selectTotalUnread } from "@/stores/notification-store";
3
+ import { setFavicon } from "@/lib/favicon";
4
+
5
+ const DEFAULT_TITLE = "PPM — Personal Project Manager";
6
+
7
+ /** Syncs document.title and favicon with unread notification count */
8
+ export function useNotificationBadge(): void {
9
+ const totalUnread = useNotificationStore(selectTotalUnread);
10
+
11
+ useEffect(() => {
12
+ if (totalUnread > 0) {
13
+ document.title = `(${totalUnread}) PPM`;
14
+ setFavicon(true);
15
+ } else {
16
+ document.title = DEFAULT_TITLE;
17
+ setFavicon(false);
18
+ }
19
+ }, [totalUnread]);
20
+ }