@hienlh/ppm 0.6.7 → 0.7.1

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 (52) hide show
  1. package/CHANGELOG.md +24 -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/providers/claude-agent-sdk.ts +61 -3
  22. package/src/server/routes/chat.ts +5 -1
  23. package/src/server/routes/settings.ts +52 -0
  24. package/src/server/routes/tunnel.ts +1 -12
  25. package/src/server/ws/chat.ts +42 -12
  26. package/src/services/config.service.ts +1 -1
  27. package/src/services/notification.service.ts +42 -0
  28. package/src/services/telegram-notification.service.ts +106 -0
  29. package/src/types/config.ts +6 -0
  30. package/src/web/app.tsx +40 -1
  31. package/src/web/components/layout/draggable-tab.tsx +10 -2
  32. package/src/web/components/layout/mobile-nav.tsx +42 -3
  33. package/src/web/components/layout/project-bar.tsx +16 -8
  34. package/src/web/components/layout/tab-bar.tsx +55 -4
  35. package/src/web/components/settings/settings-tab.tsx +135 -94
  36. package/src/web/components/settings/telegram-settings-section.tsx +113 -0
  37. package/src/web/components/ui/accordion.tsx +64 -0
  38. package/src/web/hooks/use-chat.ts +29 -0
  39. package/src/web/hooks/use-notification-badge.ts +20 -0
  40. package/src/web/hooks/use-tab-overflow.ts +91 -0
  41. package/src/web/hooks/use-url-sync.ts +5 -2
  42. package/src/web/index.html +1 -0
  43. package/src/web/lib/favicon.ts +21 -0
  44. package/src/web/lib/notification-sounds.ts +61 -0
  45. package/src/web/stores/notification-store.ts +83 -0
  46. package/dist/web/assets/chat-tab-CWhxhPKH.js +0 -7
  47. package/dist/web/assets/git-graph-xD6TLRVv.js +0 -1
  48. package/dist/web/assets/index-CigdXBuQ.css +0 -2
  49. package/dist/web/assets/index-DBdw8tN_.js +0 -22
  50. package/dist/web/assets/keybindings-store-kHLASnRb.js +0 -1
  51. package/dist/web/assets/postgres-viewer-CaMySHpD.js +0 -1
  52. /package/dist/web/assets/{tab-store-DIyJSjtr.js → tab-store-Bm1Hw8OR.js} +0 -0
@@ -3,12 +3,15 @@ import { X } from "lucide-react";
3
3
  import type { Tab, TabType } from "@/stores/tab-store";
4
4
  import { cn } from "@/lib/utils";
5
5
  import { isDarkColor } from "@/lib/color-utils";
6
+ import { notificationColor } from "@/stores/notification-store";
6
7
 
7
8
  interface DraggableTabProps {
8
9
  tab: Tab;
9
10
  isActive: boolean;
10
11
  icon: React.ElementType;
11
12
  showDropBefore: boolean;
13
+ /** Notification type if unread (null = no unread). Controls badge color. */
14
+ notificationType?: string | null;
12
15
  onSelect: () => void;
13
16
  onClose: () => void;
14
17
  onDragStart: (e: React.DragEvent) => void;
@@ -20,7 +23,7 @@ interface DraggableTabProps {
20
23
  }
21
24
 
22
25
  export function DraggableTab({
23
- tab, isActive, icon: Icon, showDropBefore, onSelect, onClose,
26
+ tab, isActive, icon: Icon, showDropBefore, notificationType, onSelect, onClose,
24
27
  onDragStart, onDragOver, onDragEnd, tabRef, onRename,
25
28
  }: DraggableTabProps) {
26
29
  const [editing, setEditing] = useState(false);
@@ -74,7 +77,12 @@ export function DraggableTab({
74
77
  colorStyle && "border-transparent",
75
78
  )}
76
79
  >
77
- <Icon className="size-4" />
80
+ <span className="relative">
81
+ <Icon className="size-4" />
82
+ {notificationType && !isActive && (
83
+ <span className={cn("absolute -top-1 -right-1 size-2 rounded-full", notificationColor(notificationType))} />
84
+ )}
85
+ </span>
78
86
  {editing ? (
79
87
  <input
80
88
  ref={inputRef}
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
2
2
  import {
3
3
  Terminal, MessageSquare, GitBranch, Database,
4
4
  FileDiff, FileCode, Settings, Menu, X, ArrowLeft, ArrowRight, SplitSquareVertical, MoveVertical, Layers, Plus,
5
+ ChevronLeft, ChevronRight,
5
6
  } from "lucide-react";
6
7
  import { usePanelStore } from "@/stores/panel-store";
7
8
  import { useProjectStore, resolveOrder } from "@/stores/project-store";
@@ -11,6 +12,8 @@ import { getProjectInitials } from "@/lib/project-avatar";
11
12
  import type { TabType } from "@/stores/tab-store";
12
13
  import { cn } from "@/lib/utils";
13
14
  import { openCommandPalette } from "@/hooks/use-global-keybindings";
15
+ import { useNotificationStore, notificationColor } from "@/stores/notification-store";
16
+ import { useTabOverflow, getHiddenUnreadDirection } from "@/hooks/use-tab-overflow";
14
17
 
15
18
  const NEW_TAB_OPTIONS: { type: TabType; label: string }[] = [
16
19
  { type: "terminal", label: "Terminal" },
@@ -35,7 +38,12 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
35
38
  const tabs = panel?.tabs ?? [];
36
39
  const activeTabId = panel?.activeTabId ?? null;
37
40
  const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
41
+ const mobileScrollRef = useRef<HTMLDivElement>(null);
38
42
  const prevTabCount = useRef(tabs.length);
43
+ const notifications = useNotificationStore((s) => s.notifications);
44
+ const { canScrollLeft, canScrollRight, scrollLeft: doScrollLeft, scrollRight: doScrollRight } =
45
+ useTabOverflow(mobileScrollRef);
46
+ const hiddenUnread = getHiddenUnreadDirection(mobileScrollRef.current, tabRefs.current as Map<string, HTMLElement>, tabs, notifications);
39
47
 
40
48
  const [menuTabId, setMenuTabId] = useState<string | null>(null);
41
49
  const [newTabSheetOpen, setNewTabSheetOpen] = useState(false);
@@ -109,15 +117,31 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
109
117
  <Menu className="size-5" />
110
118
  </button>
111
119
 
112
- <div className="flex-1 flex items-center h-12 overflow-x-auto">
120
+ <div className="flex-1 relative flex items-center h-12">
121
+ {/* Left scroll arrow */}
122
+ {canScrollLeft && (
123
+ <button onClick={doScrollLeft} className="absolute left-0 z-10 flex items-center justify-center size-8 bg-gradient-to-r from-background via-background to-transparent">
124
+ <span className="relative">
125
+ <ChevronLeft className="size-3.5 text-text-secondary" />
126
+ {hiddenUnread.left && <span className={cn("absolute -top-1 -right-0.5 size-1.5 rounded-full", notificationColor(hiddenUnread.left))} />}
127
+ </span>
128
+ </button>
129
+ )}
130
+ <div ref={mobileScrollRef} className="flex-1 flex items-center h-12 overflow-x-auto scrollbar-none">
113
131
  {tabs.map((tab) => {
114
132
  const Icon = TAB_ICONS[tab.type];
115
133
  const isActive = tab.id === activeTabId;
134
+ const sessionId = tab.type === "chat" ? (tab.metadata?.sessionId as string) : undefined;
135
+ const entry = sessionId ? notifications.get(sessionId) : undefined;
136
+ const notiType = entry && entry.count > 0 ? entry.type : null;
116
137
  return (
117
138
  <button
118
139
  key={tab.id}
119
140
  ref={(el) => { if (el) tabRefs.current.set(tab.id, el); else tabRefs.current.delete(tab.id); }}
120
- onClick={() => usePanelStore.getState().setActiveTab(tab.id)}
141
+ onClick={() => {
142
+ usePanelStore.getState().setActiveTab(tab.id);
143
+ if (sessionId) useNotificationStore.getState().clearForSession(sessionId);
144
+ }}
121
145
  onTouchStart={() => startLongPress(tab.id)}
122
146
  onTouchEnd={cancelLongPress}
123
147
  onTouchMove={cancelLongPress}
@@ -127,7 +151,12 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
127
151
  isActive ? "border-primary bg-surface text-primary" : "border-transparent text-text-secondary",
128
152
  )}
129
153
  >
130
- <Icon className="size-4" />
154
+ <span className="relative">
155
+ <Icon className="size-4" />
156
+ {notiType && !isActive && (
157
+ <span className={cn("absolute -top-1 -right-1 size-2 rounded-full", notificationColor(notiType))} />
158
+ )}
159
+ </span>
131
160
  <span className="max-w-[80px] truncate">{tab.title}</span>
132
161
  {tab.closable && (
133
162
  <span role="button" tabIndex={0} onClick={(e) => { e.stopPropagation(); usePanelStore.getState().closeTab(tab.id); }}
@@ -138,6 +167,16 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
138
167
  </button>
139
168
  );
140
169
  })}
170
+ </div>
171
+ {/* Right scroll arrow */}
172
+ {canScrollRight && (
173
+ <button onClick={doScrollRight} className="absolute right-0 z-10 flex items-center justify-center size-8 bg-gradient-to-l from-background via-background to-transparent">
174
+ <span className="relative">
175
+ <ChevronRight className="size-3.5 text-text-secondary" />
176
+ {hiddenUnread.right && <span className={cn("absolute -top-1 -left-0.5 size-1.5 rounded-full", notificationColor(hiddenUnread.right))} />}
177
+ </span>
178
+ </button>
179
+ )}
141
180
  </div>
142
181
 
143
182
  {/* Add tab — opens command palette */}
@@ -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
+ }