@hienlh/ppm 0.6.7 → 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 (50) hide show
  1. package/CHANGELOG.md +17 -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-BUg7alP6.js → code-editor-D6OuzcC-.js} +1 -1
  5. package/dist/web/assets/{database-viewer-CAgZOkZc.js → database-viewer-BxUpM_uA.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-DVvY1aFb.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-z99RjIxZ.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-BnDkeQWk.js → settings-tab-Bwsxb41F.js} +1 -1
  15. package/dist/web/assets/{sqlite-viewer-EwHWc37J.js → sqlite-viewer-DfgaCbWT.js} +1 -1
  16. package/dist/web/assets/{terminal-tab-CTN18lb6.js → terminal-tab-D27e4ZTD.js} +2 -2
  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/routes/settings.ts +52 -0
  22. package/src/server/routes/tunnel.ts +1 -12
  23. package/src/server/ws/chat.ts +30 -3
  24. package/src/services/config.service.ts +1 -1
  25. package/src/services/notification.service.ts +42 -0
  26. package/src/services/telegram-notification.service.ts +106 -0
  27. package/src/types/config.ts +6 -0
  28. package/src/web/app.tsx +40 -1
  29. package/src/web/components/layout/draggable-tab.tsx +10 -2
  30. package/src/web/components/layout/mobile-nav.tsx +42 -3
  31. package/src/web/components/layout/project-bar.tsx +16 -8
  32. package/src/web/components/layout/tab-bar.tsx +55 -4
  33. package/src/web/components/settings/settings-tab.tsx +135 -94
  34. package/src/web/components/settings/telegram-settings-section.tsx +113 -0
  35. package/src/web/components/ui/accordion.tsx +64 -0
  36. package/src/web/hooks/use-chat.ts +29 -0
  37. package/src/web/hooks/use-notification-badge.ts +20 -0
  38. package/src/web/hooks/use-tab-overflow.ts +91 -0
  39. package/src/web/hooks/use-url-sync.ts +5 -2
  40. package/src/web/index.html +1 -0
  41. package/src/web/lib/favicon.ts +21 -0
  42. package/src/web/lib/notification-sounds.ts +61 -0
  43. package/src/web/stores/notification-store.ts +83 -0
  44. package/dist/web/assets/chat-tab-CWhxhPKH.js +0 -7
  45. package/dist/web/assets/git-graph-xD6TLRVv.js +0 -1
  46. package/dist/web/assets/index-CigdXBuQ.css +0 -2
  47. package/dist/web/assets/index-DBdw8tN_.js +0 -22
  48. package/dist/web/assets/keybindings-store-kHLASnRb.js +0 -1
  49. package/dist/web/assets/postgres-viewer-CaMySHpD.js +0 -1
  50. /package/dist/web/assets/{tab-store-DIyJSjtr.js → tab-store-Bm1Hw8OR.js} +0 -0
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useRef, useEffect } from "react";
1
+ import { useState, useCallback, useMemo, useRef, useEffect } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { Plus, Settings, Pencil, Trash2, Palette, Bug, Share2, Loader2, Copy, Check, X } from "lucide-react";
4
4
  import { QRCodeSVG } from "qrcode.react";
@@ -25,6 +25,7 @@ import {
25
25
  } from "@/components/ui/dialog";
26
26
  import { AddProjectForm } from "@/components/layout/add-project-form";
27
27
  import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
28
+ import { useNotificationStore, selectProjectUrgentType, notificationColor } from "@/stores/notification-store";
28
29
  import { cn } from "@/lib/utils";
29
30
 
30
31
  // ---------------------------------------------------------------------------
@@ -34,15 +35,22 @@ function ProjectAvatar({ name, color, active, allNames }: {
34
35
  name: string; color: string; active: boolean; allNames: string[];
35
36
  }) {
36
37
  const initials = getProjectInitials(name, allNames);
38
+ const selector = useMemo(() => selectProjectUrgentType(name), [name]);
39
+ const urgentType = useNotificationStore(selector);
37
40
  return (
38
- <div
39
- className={cn(
40
- "size-10 rounded-full flex items-center justify-center text-xs font-bold text-white select-none shrink-0",
41
- active && "ring-2 ring-primary ring-offset-2 ring-offset-background",
41
+ <div className="relative">
42
+ <div
43
+ className={cn(
44
+ "size-10 rounded-full flex items-center justify-center text-xs font-bold text-white select-none shrink-0",
45
+ active && "ring-2 ring-primary ring-offset-2 ring-offset-background",
46
+ )}
47
+ style={{ background: color }}
48
+ >
49
+ {initials}
50
+ </div>
51
+ {urgentType && (
52
+ <div className={cn("absolute -top-0.5 -right-0.5 size-2.5 rounded-full border-2 border-background", notificationColor(urgentType))} />
42
53
  )}
43
- style={{ background: color }}
44
- >
45
- {initials}
46
54
  </div>
47
55
  );
48
56
  }
@@ -8,6 +8,8 @@ import {
8
8
  Settings,
9
9
  FileCode,
10
10
  Database,
11
+ ChevronLeft,
12
+ ChevronRight,
11
13
  } from "lucide-react";
12
14
  import { useTabStore, type TabType } from "@/stores/tab-store";
13
15
  import { usePanelStore } from "@/stores/panel-store";
@@ -15,7 +17,10 @@ import { useProjectStore } from "@/stores/project-store";
15
17
  import { useTabDrag } from "@/hooks/use-tab-drag";
16
18
  import { openCommandPalette } from "@/hooks/use-global-keybindings";
17
19
  import { api, projectUrl } from "@/lib/api-client";
20
+ import { useNotificationStore, notificationColor } from "@/stores/notification-store";
21
+ import { useTabOverflow, getHiddenUnreadDirection } from "@/hooks/use-tab-overflow";
18
22
  import { DraggableTab } from "./draggable-tab";
23
+ import { cn } from "@/lib/utils";
19
24
  import type { Tab } from "@/stores/tab-store";
20
25
 
21
26
  const TAB_ICONS: Record<TabType, React.ElementType> = {
@@ -49,6 +54,13 @@ export function TabBar({ panelId }: TabBarProps) {
49
54
  const { dropIndex, handleDragStart, handleDragOver, handleDragOverBar, handleDrop, handleDragEnd } =
50
55
  useTabDrag(effectivePanelId);
51
56
 
57
+ const notifications = useNotificationStore((s) => s.notifications);
58
+ const { canScrollLeft, canScrollRight, scrollLeft: doScrollLeft, scrollRight: doScrollRight } =
59
+ useTabOverflow(scrollRef);
60
+
61
+ // Hidden unread direction — recomputed when notifications or scroll changes
62
+ const hiddenUnread = getHiddenUnreadDirection(scrollRef.current, tabRefs.current as Map<string, HTMLElement>, tabs, notifications);
63
+
52
64
  // Auto-scroll to new tab
53
65
  useEffect(() => {
54
66
  if (tabs.length > prevTabCount.current && activeTabId) {
@@ -86,26 +98,49 @@ export function TabBar({ panelId }: TabBarProps) {
86
98
 
87
99
  return (
88
100
  <div
89
- className="hidden md:flex items-center h-10 border-b border-border bg-background"
101
+ className="hidden md:flex items-center h-10 border-b border-border bg-background relative"
90
102
  onDragOver={handleDragOverBar}
91
103
  onDrop={handleDrop}
92
104
  onDoubleClick={handleBarDoubleClick}
93
105
  onContextMenu={handleBarContextMenu}
94
106
  >
107
+ {/* Left scroll arrow */}
108
+ {canScrollLeft && (
109
+ <button
110
+ onClick={doScrollLeft}
111
+ className="absolute left-0 z-10 flex items-center justify-center size-8 bg-gradient-to-r from-background via-background to-transparent"
112
+ >
113
+ <span className="relative">
114
+ <ChevronLeft className="size-4 text-text-secondary" />
115
+ {hiddenUnread.left && (
116
+ <span className={cn("absolute -top-1 -right-0.5 size-2 rounded-full", notificationColor(hiddenUnread.left))} />
117
+ )}
118
+ </span>
119
+ </button>
120
+ )}
121
+
95
122
  {/* Scrollable tabs + sticky + button */}
96
123
  <div
97
124
  ref={scrollRef}
98
125
  className="flex-1 overflow-x-auto overflow-y-hidden min-w-0 scrollbar-none"
99
126
  >
100
127
  <div className="flex items-center h-10">
101
- {tabs.map((tab, i) => (
128
+ {tabs.map((tab, i) => {
129
+ const sessionId = tab.type === "chat" ? (tab.metadata?.sessionId as string) : undefined;
130
+ const entry = sessionId ? notifications.get(sessionId) : undefined;
131
+ const notiType = entry && entry.count > 0 ? entry.type : null;
132
+ return (
102
133
  <DraggableTab
103
134
  key={tab.id}
104
135
  tab={tab}
105
136
  isActive={tab.id === activeTabId}
106
137
  icon={TAB_ICONS[tab.type]}
107
138
  showDropBefore={dropIndex === i}
108
- onSelect={() => usePanelStore.getState().setActiveTab(tab.id, effectivePanelId)}
139
+ notificationType={notiType}
140
+ onSelect={() => {
141
+ usePanelStore.getState().setActiveTab(tab.id, effectivePanelId);
142
+ if (sessionId) useNotificationStore.getState().clearForSession(sessionId);
143
+ }}
109
144
  onClose={() => usePanelStore.getState().closeTab(tab.id, effectivePanelId)}
110
145
  onDragStart={(e) => handleDragStart(e, tab.id)}
111
146
  onDragOver={(e) => handleDragOver(e, tab.id, i)}
@@ -116,7 +151,8 @@ export function TabBar({ panelId }: TabBarProps) {
116
151
  }}
117
152
  onRename={tab.type === "chat" ? (title) => handleRenameTab(tab, title) : undefined}
118
153
  />
119
- ))}
154
+ );
155
+ })}
120
156
  {/* Show drop indicator at the end */}
121
157
  {dropIndex !== null && dropIndex >= tabs.length && (
122
158
  <div className="w-0.5 h-6 bg-primary rounded-full" />
@@ -132,6 +168,21 @@ export function TabBar({ panelId }: TabBarProps) {
132
168
  </button>
133
169
  </div>
134
170
  </div>
171
+
172
+ {/* Right scroll arrow */}
173
+ {canScrollRight && (
174
+ <button
175
+ onClick={doScrollRight}
176
+ className="absolute right-10 z-10 flex items-center justify-center size-8 bg-gradient-to-l from-background via-background to-transparent"
177
+ >
178
+ <span className="relative">
179
+ <ChevronRight className="size-4 text-text-secondary" />
180
+ {hiddenUnread.right && (
181
+ <span className={cn("absolute -top-1 -left-0.5 size-2 rounded-full", notificationColor(hiddenUnread.right))} />
182
+ )}
183
+ </span>
184
+ </button>
185
+ )}
135
186
  </div>
136
187
  );
137
188
  }
@@ -1,10 +1,11 @@
1
1
  import { Moon, Sun, Monitor, Bell, BellOff } from "lucide-react";
2
2
  import { Button } from "@/components/ui/button";
3
- import { Separator } from "@/components/ui/separator";
3
+ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
4
4
  import { useSettingsStore, type Theme } from "@/stores/settings-store";
5
5
  import { cn } from "@/lib/utils";
6
6
  import { AISettingsSection } from "./ai-settings-section";
7
7
  import { KeyboardShortcutsSection } from "./keyboard-shortcuts-section";
8
+ import { TelegramSettingsSection } from "./telegram-settings-section";
8
9
  import { usePushNotification } from "@/hooks/use-push-notification";
9
10
 
10
11
  const THEME_OPTIONS: { value: Theme; label: string; icon: React.ElementType }[] = [
@@ -23,107 +24,147 @@ export function SettingsTab() {
23
24
 
24
25
  return (
25
26
  <div className="h-full w-full overflow-auto">
26
- <div className="p-3 space-y-4">
27
- <h2 className="text-sm font-semibold">Settings</h2>
27
+ <div className="p-3 space-y-2">
28
+ <h2 className="text-sm font-semibold">Settings</h2>
28
29
 
29
- <div className="space-y-2">
30
- <h3 className="text-xs font-medium text-text-secondary">Theme</h3>
31
- <div className="flex gap-1.5">
32
- {THEME_OPTIONS.map((opt) => {
33
- const Icon = opt.icon;
34
- return (
35
- <Button
36
- key={opt.value}
37
- variant={theme === opt.value ? "default" : "outline"}
38
- size="sm"
39
- onClick={() => setTheme(opt.value)}
40
- className={cn(
41
- "flex-1 gap-1.5 text-xs h-8",
42
- theme === opt.value && "ring-2 ring-primary",
43
- )}
44
- >
45
- <Icon className="size-3.5" />
46
- {opt.label}
47
- </Button>
48
- );
49
- })}
50
- </div>
51
- </div>
52
-
53
- <Separator />
54
-
55
- <AISettingsSection />
30
+ <Accordion type="multiple" defaultValue={["notifications"]}>
31
+ {/* Theme */}
32
+ <AccordionItem value="theme">
33
+ <AccordionTrigger className="py-2 text-xs font-medium text-text-secondary hover:no-underline">
34
+ Theme
35
+ </AccordionTrigger>
36
+ <AccordionContent className="pb-2">
37
+ <div className="flex gap-1.5">
38
+ {THEME_OPTIONS.map((opt) => {
39
+ const Icon = opt.icon;
40
+ return (
41
+ <Button
42
+ key={opt.value}
43
+ variant={theme === opt.value ? "default" : "outline"}
44
+ size="sm"
45
+ onClick={() => setTheme(opt.value)}
46
+ className={cn(
47
+ "flex-1 gap-1.5 text-xs h-8",
48
+ theme === opt.value && "ring-2 ring-primary",
49
+ )}
50
+ >
51
+ <Icon className="size-3.5" />
52
+ {opt.label}
53
+ </Button>
54
+ );
55
+ })}
56
+ </div>
57
+ </AccordionContent>
58
+ </AccordionItem>
56
59
 
57
- <Separator />
60
+ {/* AI Settings */}
61
+ <AccordionItem value="ai">
62
+ <AccordionTrigger className="py-2 text-xs font-medium text-text-secondary hover:no-underline">
63
+ AI Settings
64
+ </AccordionTrigger>
65
+ <AccordionContent className="pb-2">
66
+ <AISettingsSection />
67
+ </AccordionContent>
68
+ </AccordionItem>
58
69
 
59
- <div className="space-y-2">
60
- <h3 className="text-xs font-medium text-text-secondary">Notifications</h3>
61
- {!pushSupported ? (
62
- <p className="text-xs text-text-subtle">
63
- Push notifications not supported in this browser.
64
- </p>
65
- ) : (
66
- <>
67
- <div className="flex items-center justify-between">
68
- <div className="flex items-center gap-1.5">
69
- {isSubscribed ? <Bell className="size-3.5" /> : <BellOff className="size-3.5" />}
70
- <span className="text-xs">Push notifications</span>
70
+ {/* Notifications */}
71
+ <AccordionItem value="notifications">
72
+ <AccordionTrigger className="py-2 text-xs font-medium text-text-secondary hover:no-underline">
73
+ Notifications
74
+ </AccordionTrigger>
75
+ <AccordionContent className="pb-2">
76
+ <div className="space-y-2">
77
+ {/* Push notifications */}
78
+ {!pushSupported ? (
79
+ <p className="text-xs text-text-subtle">
80
+ Push notifications not supported in this browser.
81
+ </p>
82
+ ) : (
83
+ <>
84
+ <div className="flex items-center justify-between">
85
+ <div className="flex items-center gap-1.5">
86
+ {isSubscribed ? <Bell className="size-3.5" /> : <BellOff className="size-3.5" />}
87
+ <span className="text-xs">Push notifications</span>
88
+ </div>
89
+ <Button
90
+ variant={isSubscribed ? "default" : "outline"}
91
+ size="sm"
92
+ className="h-7 text-xs"
93
+ disabled={loading || permission === "denied"}
94
+ onClick={() => (isSubscribed ? unsubscribe() : subscribe())}
95
+ >
96
+ {loading ? "..." : isSubscribed ? "On" : "Off"}
97
+ </Button>
98
+ </div>
99
+ {isSubscribed && (
100
+ <Button
101
+ variant="outline"
102
+ size="sm"
103
+ className="h-7 text-xs w-full"
104
+ onClick={() => {
105
+ new Notification("PPM Test", { body: "Push notifications are working!" });
106
+ }}
107
+ >
108
+ Test notification
109
+ </Button>
110
+ )}
111
+ {pushError && (
112
+ <p className="text-[11px] text-destructive">{pushError}</p>
113
+ )}
114
+ {permission === "denied" && (
115
+ <p className="text-[11px] text-destructive">
116
+ Notifications blocked. Enable in browser settings.
117
+ </p>
118
+ )}
119
+ {isIosNonPwa && (
120
+ <p className="text-[11px] text-text-subtle">
121
+ On iOS, install PPM to Home Screen for push notifications.
122
+ </p>
123
+ )}
124
+ </>
125
+ )}
71
126
  </div>
72
- <Button
73
- variant={isSubscribed ? "default" : "outline"}
74
- size="sm"
75
- className="h-7 text-xs"
76
- disabled={loading || permission === "denied"}
77
- onClick={() => (isSubscribed ? unsubscribe() : subscribe())}
78
- >
79
- {loading ? "..." : isSubscribed ? "On" : "Off"}
80
- </Button>
81
- </div>
82
- {isSubscribed && (
83
- <Button
84
- variant="outline"
85
- size="sm"
86
- className="h-7 text-xs w-full"
87
- onClick={() => {
88
- new Notification("PPM Test", { body: "Push notifications are working!" });
89
- }}
90
- >
91
- Test notification
92
- </Button>
93
- )}
94
- {pushError && (
95
- <p className="text-[11px] text-destructive">{pushError}</p>
96
- )}
97
- {permission === "denied" && (
98
- <p className="text-[11px] text-destructive">
99
- Notifications blocked. Enable in browser settings.
100
- </p>
101
- )}
102
- {isIosNonPwa && (
103
- <p className="text-[11px] text-text-subtle">
104
- On iOS, install PPM to Home Screen for push notifications.
105
- </p>
106
- )}
107
- </>
108
- )}
109
- </div>
110
-
111
- <Separator />
127
+ </AccordionContent>
128
+ </AccordionItem>
112
129
 
113
- <KeyboardShortcutsSection />
130
+ {/* Telegram */}
131
+ <AccordionItem value="telegram">
132
+ <AccordionTrigger className="py-2 text-xs font-medium text-text-secondary hover:no-underline">
133
+ Telegram
134
+ </AccordionTrigger>
135
+ <AccordionContent className="pb-2">
136
+ <TelegramSettingsSection />
137
+ </AccordionContent>
138
+ </AccordionItem>
114
139
 
115
- <Separator />
140
+ {/* Keyboard Shortcuts */}
141
+ <AccordionItem value="shortcuts">
142
+ <AccordionTrigger className="py-2 text-xs font-medium text-text-secondary hover:no-underline">
143
+ Keyboard Shortcuts
144
+ </AccordionTrigger>
145
+ <AccordionContent className="pb-2">
146
+ <KeyboardShortcutsSection />
147
+ </AccordionContent>
148
+ </AccordionItem>
116
149
 
117
- <div className="space-y-1.5">
118
- <h3 className="text-xs font-medium text-text-secondary">About</h3>
119
- <p className="text-xs text-text-secondary">
120
- PPM — Personal Project Manager
121
- </p>
122
- <p className="text-[11px] text-text-subtle">
123
- A mobile-first web IDE for managing your projects.
124
- </p>
150
+ {/* About */}
151
+ <AccordionItem value="about">
152
+ <AccordionTrigger className="py-2 text-xs font-medium text-text-secondary hover:no-underline">
153
+ About
154
+ </AccordionTrigger>
155
+ <AccordionContent className="pb-2">
156
+ <div className="space-y-1.5">
157
+ <p className="text-xs text-text-secondary">
158
+ PPM — Personal Project Manager
159
+ </p>
160
+ <p className="text-[11px] text-text-subtle">
161
+ A mobile-first web IDE for managing your projects.
162
+ </p>
163
+ </div>
164
+ </AccordionContent>
165
+ </AccordionItem>
166
+ </Accordion>
125
167
  </div>
126
168
  </div>
127
- </div>
128
169
  );
129
170
  }
@@ -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 }
@@ -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];