@hienlh/ppm 0.2.16 → 0.2.18

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 (53) hide show
  1. package/bun.lock +30 -3
  2. package/dist/ppm +0 -0
  3. package/dist/web/assets/{button-CQ5h5gxS.js → button-CvHWF07y.js} +1 -1
  4. package/dist/web/assets/{chat-tab-Cpj7_-mS.js → chat-tab-Cbc-uzKV.js} +4 -4
  5. package/dist/web/assets/{code-editor-BauDrXcB.js → code-editor-B9e5P-DN.js} +1 -1
  6. package/dist/web/assets/{dialog-CCBmXo6-.js → dialog-Cn5zGuid.js} +1 -1
  7. package/dist/web/assets/diff-viewer-B0iMQ1Qf.js +4 -0
  8. package/dist/web/assets/git-graph-D3ls9-HA.js +1 -0
  9. package/dist/web/assets/{git-status-panel-B_iCL1Ge.js → git-status-panel-UB0AyOdX.js} +1 -1
  10. package/dist/web/assets/index-BdUoflYx.css +2 -0
  11. package/dist/web/assets/index-DLIV9ojh.js +17 -0
  12. package/dist/web/assets/{project-list-7ReggIMy.js → project-list-D6oBUMd8.js} +1 -1
  13. package/dist/web/assets/settings-tab-DmTDAK9n.js +1 -0
  14. package/dist/web/assets/{terminal-tab-Cg4Pm_3X.js → terminal-tab-DlRo-KzS.js} +1 -1
  15. package/dist/web/index.html +7 -8
  16. package/dist/web/sw.js +1 -1
  17. package/package.json +3 -2
  18. package/src/providers/claude-agent-sdk.ts +1 -2
  19. package/src/server/index.ts +2 -0
  20. package/src/server/routes/push.ts +54 -0
  21. package/src/server/ws/chat.ts +211 -87
  22. package/src/services/push-notification.service.ts +118 -0
  23. package/src/types/config.ts +7 -0
  24. package/src/web/app.tsx +4 -11
  25. package/src/web/components/layout/draggable-tab.tsx +58 -0
  26. package/src/web/components/layout/editor-panel.tsx +64 -0
  27. package/src/web/components/layout/mobile-nav.tsx +99 -55
  28. package/src/web/components/layout/panel-layout.tsx +71 -0
  29. package/src/web/components/layout/sidebar.tsx +29 -6
  30. package/src/web/components/layout/split-drop-overlay.tsx +111 -0
  31. package/src/web/components/layout/tab-bar.tsx +60 -68
  32. package/src/web/components/settings/settings-tab.tsx +45 -1
  33. package/src/web/hooks/use-chat.ts +31 -2
  34. package/src/web/hooks/use-global-keybindings.ts +8 -0
  35. package/src/web/hooks/use-push-notification.ts +96 -0
  36. package/src/web/hooks/use-tab-drag.ts +109 -0
  37. package/src/web/stores/panel-store.ts +383 -0
  38. package/src/web/stores/panel-utils.ts +116 -0
  39. package/src/web/stores/settings-store.ts +25 -17
  40. package/src/web/stores/tab-store.ts +32 -152
  41. package/src/web/sw.ts +52 -0
  42. package/vite.config.ts +4 -11
  43. package/dist/web/assets/diff-viewer-CGIQRv_l.js +0 -4
  44. package/dist/web/assets/dist-CYANqO1g.js +0 -1
  45. package/dist/web/assets/git-graph-D4IUX9-7.js +0 -1
  46. package/dist/web/assets/index-DOHQ7GlD.js +0 -12
  47. package/dist/web/assets/index-Jhl6F2vS.css +0 -2
  48. package/dist/web/assets/settings-tab-Ceuow24i.js +0 -1
  49. package/dist/web/workbox-3e722498.js +0 -1
  50. /package/dist/web/assets/{api-client-tgjN9Mx8.js → api-client-B_eCZViO.js} +0 -0
  51. /package/dist/web/assets/{dist-0XHv8Vwc.js → dist-B6sG2GPc.js} +0 -0
  52. /package/dist/web/assets/{dist-BeHIxUn0.js → dist-CBiGQxfr.js} +0 -0
  53. /package/dist/web/assets/{utils-D6me7KDg.js → utils-61GRB9Cb.js} +0 -0
@@ -1,9 +1,10 @@
1
- import { Moon, Sun, Monitor } from "lucide-react";
1
+ import { Moon, Sun, Monitor, Bell, BellOff } from "lucide-react";
2
2
  import { Button } from "@/components/ui/button";
3
3
  import { Separator } from "@/components/ui/separator";
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
+ import { usePushNotification } from "@/hooks/use-push-notification";
7
8
 
8
9
  const THEME_OPTIONS: { value: Theme; label: string; icon: React.ElementType }[] = [
9
10
  { value: "light", label: "Light", icon: Sun },
@@ -11,8 +12,13 @@ const THEME_OPTIONS: { value: Theme; label: string; icon: React.ElementType }[]
11
12
  { value: "system", label: "System", icon: Monitor },
12
13
  ];
13
14
 
15
+ const pushSupported = "PushManager" in window && "serviceWorker" in navigator;
16
+ const isIosNonPwa = /iPhone|iPad/.test(navigator.userAgent) &&
17
+ !window.matchMedia("(display-mode: standalone)").matches;
18
+
14
19
  export function SettingsTab() {
15
20
  const { theme, setTheme } = useSettingsStore();
21
+ const { permission, isSubscribed, loading, subscribe, unsubscribe } = usePushNotification();
16
22
 
17
23
  return (
18
24
  <div className="h-full p-4 space-y-6 overflow-auto max-w-lg">
@@ -48,6 +54,44 @@ export function SettingsTab() {
48
54
 
49
55
  <Separator />
50
56
 
57
+ <div className="space-y-3">
58
+ <h3 className="text-sm font-medium text-text-secondary">Notifications</h3>
59
+ {!pushSupported ? (
60
+ <p className="text-sm text-text-subtle">
61
+ Push notifications are not supported in this browser.
62
+ </p>
63
+ ) : (
64
+ <>
65
+ <div className="flex items-center justify-between">
66
+ <div className="flex items-center gap-2">
67
+ {isSubscribed ? <Bell className="size-4" /> : <BellOff className="size-4" />}
68
+ <span className="text-sm">Push notifications</span>
69
+ </div>
70
+ <Button
71
+ variant={isSubscribed ? "default" : "outline"}
72
+ size="sm"
73
+ disabled={loading || permission === "denied"}
74
+ onClick={() => (isSubscribed ? unsubscribe() : subscribe())}
75
+ >
76
+ {loading ? "..." : isSubscribed ? "On" : "Off"}
77
+ </Button>
78
+ </div>
79
+ {permission === "denied" && (
80
+ <p className="text-xs text-destructive">
81
+ Notifications blocked. Enable in browser settings.
82
+ </p>
83
+ )}
84
+ {isIosNonPwa && (
85
+ <p className="text-xs text-text-subtle">
86
+ On iOS, install PPM to Home Screen for push notifications.
87
+ </p>
88
+ )}
89
+ </>
90
+ )}
91
+ </div>
92
+
93
+ <Separator />
94
+
51
95
  <div className="space-y-3">
52
96
  <h3 className="text-sm font-medium text-text-secondary">About</h3>
53
97
  <p className="text-sm text-text-secondary">
@@ -43,6 +43,7 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
43
43
  const isStreamingRef = useRef(false);
44
44
  const pendingMessageRef = useRef<string | null>(null);
45
45
  const sendRef = useRef<(data: string) => void>(() => {});
46
+ const refetchRef = useRef<(() => void) | null>(null);
46
47
 
47
48
  const handleMessage = useCallback((event: MessageEvent) => {
48
49
  let data: ChatWsServerMessage;
@@ -55,12 +56,32 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
55
56
  // Ignore keepalive pings
56
57
  if ((data as any).type === "ping") return;
57
58
 
58
- // Handle connected event (custom, not in type)
59
+ // Handle connected event (new session)
59
60
  if ((data as any).type === "connected") {
60
61
  setIsConnected(true);
61
62
  return;
62
63
  }
63
64
 
65
+ // Handle status event (FE reconnected to existing session)
66
+ if ((data as any).type === "status") {
67
+ setIsConnected(true);
68
+ const status = data as any;
69
+ if (status.isStreaming) {
70
+ isStreamingRef.current = true;
71
+ setIsStreaming(true);
72
+ }
73
+ if (status.pendingApproval) {
74
+ setPendingApproval({
75
+ requestId: status.pendingApproval.requestId,
76
+ tool: status.pendingApproval.tool,
77
+ input: status.pendingApproval.input,
78
+ });
79
+ }
80
+ // Refetch history to catch up on events missed during disconnect
81
+ refetchRef.current?.();
82
+ return;
83
+ }
84
+
64
85
  switch (data.type) {
65
86
  case "text": {
66
87
  streamingContentRef.current += data.content;
@@ -379,10 +400,12 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
379
400
  const reconnect = useCallback(() => {
380
401
  setIsConnected(false);
381
402
  wsReconnect();
403
+ // Refetch history on manual reconnect to catch up on missed events
404
+ refetchRef.current?.();
382
405
  }, [wsReconnect]);
383
406
 
384
407
  const refetchMessages = useCallback(() => {
385
- if (!sessionId || !projectName || isStreamingRef.current) return;
408
+ if (!sessionId || !projectName) return;
386
409
  setMessagesLoading(true);
387
410
  fetch(`${projectUrl(projectName)}/chat/sessions/${sessionId}/messages?providerId=${providerId}`, {
388
411
  headers: { Authorization: `Bearer ${getAuthToken()}` },
@@ -391,12 +414,18 @@ export function useChat(sessionId: string | null, providerId = "claude-sdk", pro
391
414
  .then((json: any) => {
392
415
  if (json.ok && Array.isArray(json.data) && json.data.length > 0) {
393
416
  setMessages(json.data);
417
+ // Reset streaming content refs so live tokens append cleanly after history
418
+ streamingContentRef.current = "";
419
+ streamingEventsRef.current = [];
394
420
  }
395
421
  })
396
422
  .catch(() => {})
397
423
  .finally(() => setMessagesLoading(false));
398
424
  }, [sessionId, providerId, projectName]);
399
425
 
426
+ // Keep refetchRef in sync so handleMessage (status event) can trigger refetch
427
+ refetchRef.current = refetchMessages;
428
+
400
429
  return {
401
430
  messages,
402
431
  messagesLoading,
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState, useCallback } from "react";
2
2
  import { useTabStore } from "@/stores/tab-store";
3
+ import { useSettingsStore } from "@/stores/settings-store";
3
4
 
4
5
  /**
5
6
  * Global keyboard shortcuts.
@@ -29,6 +30,13 @@ export function useGlobalKeybindings() {
29
30
  // Keydown shortcuts
30
31
  if (e.type !== "keydown") return;
31
32
 
33
+ // Cmd/Ctrl+B → Toggle sidebar
34
+ if (e.key === "b" && (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey) {
35
+ e.preventDefault();
36
+ useSettingsStore.getState().toggleSidebar();
37
+ return;
38
+ }
39
+
32
40
  // Alt+] / Alt+[ → Cycle tabs
33
41
  if (e.altKey && !e.ctrlKey && !e.metaKey && (e.key === "]" || e.key === "[")) {
34
42
  e.preventDefault();
@@ -0,0 +1,96 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { getAuthToken } from "@/lib/api-client";
3
+
4
+ /** Convert VAPID public key from base64url to Uint8Array for PushManager */
5
+ function urlBase64ToUint8Array(base64String: string): Uint8Array {
6
+ const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
7
+ const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
8
+ const rawData = atob(base64);
9
+ return Uint8Array.from(rawData, (char) => char.charCodeAt(0));
10
+ }
11
+
12
+ export function usePushNotification() {
13
+ const [permission, setPermission] = useState<NotificationPermission>("default");
14
+ const [isSubscribed, setIsSubscribed] = useState(false);
15
+ const [loading, setLoading] = useState(false);
16
+
17
+ // Check current permission and subscription state on mount
18
+ useEffect(() => {
19
+ if ("Notification" in window) {
20
+ setPermission(Notification.permission);
21
+ }
22
+ setIsSubscribed(localStorage.getItem("ppm-push-subscribed") === "true");
23
+ }, []);
24
+
25
+ const subscribe = useCallback(async () => {
26
+ setLoading(true);
27
+ try {
28
+ // 1. Request notification permission
29
+ const perm = await Notification.requestPermission();
30
+ setPermission(perm);
31
+ if (perm !== "granted") return;
32
+
33
+ // 2. Get VAPID public key from server
34
+ const headers: Record<string, string> = {};
35
+ const token = getAuthToken();
36
+ if (token) headers.Authorization = `Bearer ${token}`;
37
+
38
+ const res = await fetch("/api/push/vapid-key", { headers });
39
+ const json = await res.json();
40
+ if (!json.ok) throw new Error(json.error || "Failed to get VAPID key");
41
+
42
+ // 3. Subscribe via PushManager
43
+ const reg = await navigator.serviceWorker.ready;
44
+ const sub = await reg.pushManager.subscribe({
45
+ userVisibleOnly: true,
46
+ applicationServerKey: urlBase64ToUint8Array(json.data.publicKey).buffer as ArrayBuffer,
47
+ });
48
+
49
+ // 4. Send subscription to server
50
+ await fetch("/api/push/subscribe", {
51
+ method: "POST",
52
+ headers: { ...headers, "Content-Type": "application/json" },
53
+ body: JSON.stringify(sub.toJSON()),
54
+ });
55
+
56
+ setIsSubscribed(true);
57
+ localStorage.setItem("ppm-push-subscribed", "true");
58
+ } catch (err) {
59
+ console.error("[push] Subscribe failed:", err);
60
+ } finally {
61
+ setLoading(false);
62
+ }
63
+ }, []);
64
+
65
+ const unsubscribe = useCallback(async () => {
66
+ setLoading(true);
67
+ try {
68
+ const reg = await navigator.serviceWorker.ready;
69
+ const sub = await reg.pushManager.getSubscription();
70
+ if (sub) {
71
+ // Remove from server
72
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
73
+ const token = getAuthToken();
74
+ if (token) headers.Authorization = `Bearer ${token}`;
75
+
76
+ await fetch("/api/push/subscribe", {
77
+ method: "DELETE",
78
+ headers,
79
+ body: JSON.stringify({ endpoint: sub.endpoint }),
80
+ });
81
+
82
+ // Unsubscribe locally
83
+ await sub.unsubscribe();
84
+ }
85
+
86
+ setIsSubscribed(false);
87
+ localStorage.removeItem("ppm-push-subscribed");
88
+ } catch (err) {
89
+ console.error("[push] Unsubscribe failed:", err);
90
+ } finally {
91
+ setLoading(false);
92
+ }
93
+ }, []);
94
+
95
+ return { permission, isSubscribed, loading, subscribe, unsubscribe };
96
+ }
@@ -0,0 +1,109 @@
1
+ import { useRef, useState, useCallback, useSyncExternalStore } from "react";
2
+ import { usePanelStore } from "@/stores/panel-store";
3
+
4
+ export const TAB_DRAG_TYPE = "application/ppm-tab";
5
+
6
+ export interface DragPayload {
7
+ tabId: string;
8
+ panelId: string;
9
+ }
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Global drag state — lets overlays know a tab drag is in progress
13
+ // ---------------------------------------------------------------------------
14
+ let _dragging = false;
15
+ const _listeners = new Set<() => void>();
16
+
17
+ function setDragging(v: boolean) {
18
+ if (_dragging === v) return;
19
+ _dragging = v;
20
+ _listeners.forEach((fn) => fn());
21
+ }
22
+
23
+ export function useIsDraggingTab(): boolean {
24
+ return useSyncExternalStore(
25
+ (cb) => { _listeners.add(cb); return () => _listeners.delete(cb); },
26
+ () => _dragging,
27
+ );
28
+ }
29
+
30
+ /** Call from any drop handler to clear global drag state */
31
+ export function clearDragging() {
32
+ setDragging(false);
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Hook for tab bar DnD
37
+ // ---------------------------------------------------------------------------
38
+ export function useTabDrag(panelId: string) {
39
+ const [dropIndex, setDropIndex] = useState<number | null>(null);
40
+ const dragOverRef = useRef<string | null>(null);
41
+
42
+ const handleDragStart = useCallback(
43
+ (e: React.DragEvent, tabId: string) => {
44
+ const payload: DragPayload = { tabId, panelId };
45
+ e.dataTransfer.setData(TAB_DRAG_TYPE, JSON.stringify(payload));
46
+ e.dataTransfer.effectAllowed = "move";
47
+ // Delay so browser captures the drag image first
48
+ requestAnimationFrame(() => setDragging(true));
49
+ },
50
+ [panelId],
51
+ );
52
+
53
+ const handleDragOver = useCallback(
54
+ (e: React.DragEvent, tabId: string, tabIndex: number) => {
55
+ if (!e.dataTransfer.types.includes(TAB_DRAG_TYPE)) return;
56
+ e.preventDefault();
57
+ e.dataTransfer.dropEffect = "move";
58
+
59
+ if (dragOverRef.current === tabId) return;
60
+ dragOverRef.current = tabId;
61
+
62
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
63
+ const midX = rect.left + rect.width / 2;
64
+ setDropIndex(e.clientX < midX ? tabIndex : tabIndex + 1);
65
+ },
66
+ [],
67
+ );
68
+
69
+ const handleDragOverBar = useCallback(
70
+ (e: React.DragEvent) => {
71
+ if (!e.dataTransfer.types.includes(TAB_DRAG_TYPE)) return;
72
+ e.preventDefault();
73
+ e.dataTransfer.dropEffect = "move";
74
+ },
75
+ [],
76
+ );
77
+
78
+ const handleDrop = useCallback(
79
+ (e: React.DragEvent) => {
80
+ e.preventDefault();
81
+ setDragging(false);
82
+ const raw = e.dataTransfer.getData(TAB_DRAG_TYPE);
83
+ if (!raw) return;
84
+
85
+ try {
86
+ const payload = JSON.parse(raw) as DragPayload;
87
+ const store = usePanelStore.getState();
88
+
89
+ if (payload.panelId === panelId) {
90
+ if (dropIndex !== null) store.reorderTab(payload.tabId, panelId, dropIndex);
91
+ } else {
92
+ store.moveTab(payload.tabId, payload.panelId, panelId, dropIndex ?? undefined);
93
+ }
94
+ } catch { /* ignore */ }
95
+
96
+ setDropIndex(null);
97
+ dragOverRef.current = null;
98
+ },
99
+ [panelId, dropIndex],
100
+ );
101
+
102
+ const handleDragEnd = useCallback(() => {
103
+ setDragging(false);
104
+ setDropIndex(null);
105
+ dragOverRef.current = null;
106
+ }, []);
107
+
108
+ return { dropIndex, handleDragStart, handleDragOver, handleDragOverBar, handleDrop, handleDragEnd };
109
+ }