@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.
- package/CHANGELOG.md +17 -0
- package/README.md +86 -313
- package/dist/web/assets/chat-tab-CbNbBMGw.js +7 -0
- package/dist/web/assets/{code-editor-BUg7alP6.js → code-editor-D6OuzcC-.js} +1 -1
- package/dist/web/assets/{database-viewer-CAgZOkZc.js → database-viewer-BxUpM_uA.js} +1 -1
- package/dist/web/assets/{diff-viewer-DVvY1aFb.js → diff-viewer-DAhrHpNM.js} +1 -1
- package/dist/web/assets/{dist-Jb3Tnkpc.js → dist-CNRrBoQi.js} +14 -14
- package/dist/web/assets/git-graph-BpTt5iOd.js +1 -0
- package/dist/web/assets/index-BU_07_oW.js +29 -0
- package/dist/web/assets/index-CBQhXXeV.css +2 -0
- package/dist/web/assets/keybindings-store-C0m8_V9X.js +1 -0
- package/dist/web/assets/{markdown-renderer-z99RjIxZ.js → markdown-renderer-CvGYO9sH.js} +2 -2
- package/dist/web/assets/postgres-viewer-BL99auSm.js +1 -0
- package/dist/web/assets/{settings-tab-BnDkeQWk.js → settings-tab-Bwsxb41F.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-EwHWc37J.js → sqlite-viewer-DfgaCbWT.js} +1 -1
- package/dist/web/assets/{terminal-tab-CTN18lb6.js → terminal-tab-D27e4ZTD.js} +2 -2
- package/dist/web/index.html +4 -3
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/lib/network-utils.ts +12 -0
- package/src/server/routes/settings.ts +52 -0
- package/src/server/routes/tunnel.ts +1 -12
- package/src/server/ws/chat.ts +30 -3
- package/src/services/config.service.ts +1 -1
- package/src/services/notification.service.ts +42 -0
- package/src/services/telegram-notification.service.ts +106 -0
- package/src/types/config.ts +6 -0
- package/src/web/app.tsx +40 -1
- package/src/web/components/layout/draggable-tab.tsx +10 -2
- package/src/web/components/layout/mobile-nav.tsx +42 -3
- package/src/web/components/layout/project-bar.tsx +16 -8
- package/src/web/components/layout/tab-bar.tsx +55 -4
- package/src/web/components/settings/settings-tab.tsx +135 -94
- package/src/web/components/settings/telegram-settings-section.tsx +113 -0
- package/src/web/components/ui/accordion.tsx +64 -0
- package/src/web/hooks/use-chat.ts +29 -0
- package/src/web/hooks/use-notification-badge.ts +20 -0
- package/src/web/hooks/use-tab-overflow.ts +91 -0
- package/src/web/hooks/use-url-sync.ts +5 -2
- package/src/web/index.html +1 -0
- package/src/web/lib/favicon.ts +21 -0
- package/src/web/lib/notification-sounds.ts +61 -0
- package/src/web/stores/notification-store.ts +83 -0
- package/dist/web/assets/chat-tab-CWhxhPKH.js +0 -7
- package/dist/web/assets/git-graph-xD6TLRVv.js +0 -1
- package/dist/web/assets/index-CigdXBuQ.css +0 -2
- package/dist/web/assets/index-DBdw8tN_.js +0 -22
- package/dist/web/assets/keybindings-store-kHLASnRb.js +0 -1
- package/dist/web/assets/postgres-viewer-CaMySHpD.js +0 -1
- /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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
27
|
-
|
|
27
|
+
<div className="p-3 space-y-2">
|
|
28
|
+
<h2 className="text-sm font-semibold">Settings</h2>
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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];
|