@hienlh/ppm 0.9.9 → 0.9.11

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 +15 -0
  2. package/dist/web/assets/{browser-tab-CpltAQ9R.js → browser-tab-CWkYQN8G.js} +1 -1
  3. package/dist/web/assets/chat-tab-BVf4q-TX.js +8 -0
  4. package/dist/web/assets/code-editor-r5T7wq0I.js +2 -0
  5. package/dist/web/assets/{database-viewer-DUDwfC9o.js → database-viewer-DIKCOXIJ.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-C8wC1442.js → diff-viewer-Cs2knua9.js} +1 -1
  7. package/dist/web/assets/{extension-webview-jl1C2HGm.js → extension-webview-BbDesnaR.js} +1 -1
  8. package/dist/web/assets/{git-graph-BgpRKeIW.js → git-graph-BWQm8I8V.js} +1 -1
  9. package/dist/web/assets/index-D-NC2Rnz.css +2 -0
  10. package/dist/web/assets/index-E0N-qark.js +37 -0
  11. package/dist/web/assets/keybindings-store-DXucgQSx.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-fLOZi3vK.js → markdown-renderer-DgeFkhU6.js} +1 -1
  13. package/dist/web/assets/{postgres-viewer-DhEmO5Pd.js → postgres-viewer-hQ47YDO5.js} +1 -1
  14. package/dist/web/assets/{settings-tab-B0JPmP_y.js → settings-tab-BZ_CLZDj.js} +1 -1
  15. package/dist/web/assets/{sqlite-viewer-4YwpO6X6.js → sqlite-viewer-B8vs7svK.js} +1 -1
  16. package/dist/web/assets/{terminal-tab-Dc1b9QaT.js → terminal-tab-DjE8lCXj.js} +1 -1
  17. package/dist/web/assets/{use-monaco-theme-CN0etsaO.js → use-monaco-theme-BNtfLGmz.js} +1 -1
  18. package/dist/web/index.html +2 -2
  19. package/dist/web/sw.js +1 -1
  20. package/docs/project-changelog.md +8 -0
  21. package/docs/project-roadmap.md +2 -1
  22. package/package.json +1 -1
  23. package/src/index.ts +1 -0
  24. package/src/server/index.ts +4 -0
  25. package/src/server/routes/git.ts +56 -0
  26. package/src/server/routes/teams.ts +40 -0
  27. package/src/server/ws/chat.ts +42 -0
  28. package/src/server/ws/team-inbox-watcher.ts +181 -0
  29. package/src/services/git.service.ts +99 -0
  30. package/src/types/chat.ts +4 -1
  31. package/src/types/git.ts +21 -0
  32. package/src/types/team.ts +33 -0
  33. package/src/web/components/chat/chat-tab.tsx +6 -0
  34. package/src/web/components/chat/message-input.tsx +70 -1
  35. package/src/web/components/chat/team-activity-popover.tsx +202 -0
  36. package/src/web/components/git/create-worktree-dialog.tsx +232 -0
  37. package/src/web/components/git/git-status-panel.tsx +13 -0
  38. package/src/web/components/git/git-worktree-panel.tsx +306 -0
  39. package/src/web/components/layout/draggable-tab.tsx +7 -1
  40. package/src/web/components/layout/editor-panel.tsx +1 -1
  41. package/src/web/components/layout/split-drop-overlay.tsx +10 -5
  42. package/src/web/components/layout/tab-bar.tsx +6 -0
  43. package/src/web/components/settings/ai-settings-section.tsx +83 -1
  44. package/src/web/hooks/use-chat.ts +96 -1
  45. package/src/web/hooks/use-media-query.ts +18 -0
  46. package/src/web/hooks/use-tab-drag.ts +1 -1
  47. package/src/web/hooks/use-touch-tab-drag.ts +190 -0
  48. package/dist/web/assets/chat-tab-BDvg70wQ.js +0 -8
  49. package/dist/web/assets/code-editor-CQXdf1v_.js +0 -2
  50. package/dist/web/assets/index-BPTNjFbl.js +0 -37
  51. package/dist/web/assets/index-D3XyoeN3.css +0 -2
  52. package/dist/web/assets/keybindings-store-BTCdVItE.js +0 -1
@@ -0,0 +1,202 @@
1
+ import { useState, useRef, useEffect, useCallback } from "react";
2
+ import { RefreshCw, X } from "lucide-react";
3
+ import { cn } from "@/lib/utils";
4
+ import { api } from "@/lib/api-client";
5
+ import type { TeamMessageItem } from "@/hooks/use-chat";
6
+
7
+ interface TeamActivityPopoverProps {
8
+ teamNames: string[];
9
+ messages: TeamMessageItem[];
10
+ open: boolean;
11
+ onOpenChange: (open: boolean) => void;
12
+ }
13
+
14
+ const STATUS_COLORS: Record<string, string> = {
15
+ active: "bg-green-500",
16
+ idle: "bg-yellow-500",
17
+ shutdown: "bg-zinc-400",
18
+ };
19
+
20
+ const TYPE_BADGES: Record<string, { label: string; className: string }> = {
21
+ task_assignment: { label: "task", className: "bg-blue-500/20 text-blue-400" },
22
+ idle_notification: { label: "idle", className: "bg-yellow-500/20 text-yellow-400" },
23
+ completion: { label: "done", className: "bg-green-500/20 text-green-400" },
24
+ shutdown_request: { label: "shutdown", className: "bg-red-500/20 text-red-400" },
25
+ shutdown_approved: { label: "shutdown ✓", className: "bg-zinc-500/20 text-zinc-400" },
26
+ };
27
+
28
+ export function TeamActivityPopover({ teamNames, messages, open, onOpenChange }: TeamActivityPopoverProps) {
29
+ const [selectedTeam, setSelectedTeam] = useState(teamNames[0] ?? "");
30
+ const [members, setMembers] = useState<any[]>([]);
31
+ const [loading, setLoading] = useState(false);
32
+ const messagesEndRef = useRef<HTMLDivElement>(null);
33
+ const panelRef = useRef<HTMLDivElement>(null);
34
+
35
+ // Sync selected team when teamNames changes
36
+ useEffect(() => {
37
+ if (teamNames.length > 0 && !teamNames.includes(selectedTeam)) {
38
+ setSelectedTeam(teamNames[0]!);
39
+ }
40
+ }, [teamNames, selectedTeam]);
41
+
42
+ const fetchTeamDetail = useCallback(async (name: string) => {
43
+ setLoading(true);
44
+ try {
45
+ const detail = await api.get<any>(`/api/teams/${encodeURIComponent(name)}`);
46
+ setMembers(detail?.members ?? []);
47
+ } catch { setMembers([]); }
48
+ setLoading(false);
49
+ }, []);
50
+
51
+ // Fetch members on mount and tab switch
52
+ useEffect(() => {
53
+ if (open && selectedTeam) fetchTeamDetail(selectedTeam);
54
+ }, [open, selectedTeam, fetchTeamDetail]);
55
+
56
+ // Auto-scroll messages
57
+ useEffect(() => {
58
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
59
+ }, [messages.length]);
60
+
61
+ // Close on outside click
62
+ useEffect(() => {
63
+ if (!open) return;
64
+ const handler = (e: MouseEvent) => {
65
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
66
+ onOpenChange(false);
67
+ }
68
+ };
69
+ document.addEventListener("mousedown", handler);
70
+ return () => document.removeEventListener("mousedown", handler);
71
+ }, [open, onOpenChange]);
72
+
73
+ if (!open) return null;
74
+
75
+ // Filter messages relevant to selected team (all for now — teams share the same messages array)
76
+ const teamMessages = messages;
77
+ const displayMessages = teamMessages.slice(-200);
78
+
79
+ return (
80
+ <div
81
+ ref={panelRef}
82
+ className="absolute bottom-full left-0 mb-2 w-80 md:w-96 bg-surface border border-border rounded-lg shadow-lg z-50 overflow-hidden"
83
+ >
84
+ {/* Header with tabs */}
85
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface-elevated">
86
+ <div className="flex items-center gap-1 overflow-x-auto min-w-0">
87
+ {teamNames.map((name) => (
88
+ <button
89
+ key={name}
90
+ onClick={() => setSelectedTeam(name)}
91
+ className={cn(
92
+ "px-2 py-0.5 text-[11px] rounded-md whitespace-nowrap transition-colors",
93
+ selectedTeam === name
94
+ ? "bg-primary/10 text-primary font-medium"
95
+ : "text-text-subtle hover:text-text-primary"
96
+ )}
97
+ >
98
+ {name}
99
+ </button>
100
+ ))}
101
+ </div>
102
+ <div className="flex items-center gap-1 shrink-0">
103
+ <button
104
+ onClick={() => selectedTeam && fetchTeamDetail(selectedTeam)}
105
+ className="text-text-subtle hover:text-foreground p-1"
106
+ aria-label="Refresh"
107
+ >
108
+ <RefreshCw className={cn("size-3", loading && "animate-spin")} />
109
+ </button>
110
+ <button
111
+ onClick={() => onOpenChange(false)}
112
+ className="text-text-subtle hover:text-foreground p-1"
113
+ aria-label="Close"
114
+ >
115
+ <X className="size-3" />
116
+ </button>
117
+ </div>
118
+ </div>
119
+
120
+ {/* Members */}
121
+ {members.length > 0 && (
122
+ <div className="px-3 py-2 border-b border-border">
123
+ <div className="text-[10px] text-text-subtle uppercase tracking-wider mb-1">Members</div>
124
+ <div className="space-y-1">
125
+ {members.map((m: any) => (
126
+ <div key={m.name} className="flex items-center gap-2 text-xs">
127
+ <span className={cn("size-1.5 rounded-full shrink-0", STATUS_COLORS[m.status] ?? "bg-zinc-400")} />
128
+ <span className="font-medium truncate">{m.name}</span>
129
+ {m.model && m.model !== "unknown" && (
130
+ <span className="text-text-subtle text-[10px]">({m.model})</span>
131
+ )}
132
+ <span className="ml-auto text-text-subtle text-[10px]">{m.status}</span>
133
+ </div>
134
+ ))}
135
+ </div>
136
+ </div>
137
+ )}
138
+
139
+ {/* Messages */}
140
+ <div className="max-h-64 overflow-y-auto px-3 py-2">
141
+ {displayMessages.length === 0 ? (
142
+ <p className="text-xs text-text-subtle text-center py-4">No messages yet</p>
143
+ ) : (
144
+ <div className="space-y-2">
145
+ {displayMessages.map((msg, i) => {
146
+ const badge = msg.parsedType ? TYPE_BADGES[msg.parsedType] : null;
147
+ const time = formatTime(msg.timestamp);
148
+ return (
149
+ <div key={`${msg.timestamp}-${i}`} className="text-xs">
150
+ <div className="flex items-center gap-1 text-text-subtle">
151
+ <span className="font-medium" style={safeColor(msg.color)}>
152
+ {msg.from}
153
+ </span>
154
+ <span>→</span>
155
+ <span>{msg.to}</span>
156
+ <span className="ml-auto text-[10px]">{time}</span>
157
+ </div>
158
+ <div className="mt-0.5 text-foreground/90 break-words">
159
+ {badge && (
160
+ <span className={cn("inline-block px-1 py-0 rounded text-[9px] mr-1", badge.className)}>
161
+ {badge.label}
162
+ </span>
163
+ )}
164
+ {msg.summary ?? truncateText(msg.text)}
165
+ </div>
166
+ </div>
167
+ );
168
+ })}
169
+ <div ref={messagesEndRef} />
170
+ </div>
171
+ )}
172
+ </div>
173
+ </div>
174
+ );
175
+ }
176
+
177
+ function formatTime(timestamp: string): string {
178
+ try {
179
+ const d = new Date(timestamp);
180
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
181
+ } catch { return ""; }
182
+ }
183
+
184
+ /** Sanitize color value to prevent CSS injection */
185
+ function safeColor(color?: string): React.CSSProperties | undefined {
186
+ if (!color) return undefined;
187
+ // Only allow hex colors and named CSS colors (no url(), expression(), etc.)
188
+ if (/^#[0-9a-fA-F]{3,8}$/.test(color) || /^[a-zA-Z]{3,20}$/.test(color)) {
189
+ return { color };
190
+ }
191
+ return undefined;
192
+ }
193
+
194
+ function truncateText(text: string, max = 120): string {
195
+ if (!text) return "";
196
+ // Try to parse JSON for structured messages
197
+ try {
198
+ const parsed = JSON.parse(text);
199
+ return parsed.summary ?? parsed.text ?? text.slice(0, max);
200
+ } catch {}
201
+ return text.length > max ? text.slice(0, max) + "..." : text;
202
+ }
@@ -0,0 +1,232 @@
1
+ import { useState, useEffect } from "react";
2
+ import { Loader2, X } from "lucide-react";
3
+ import { api, projectUrl } from "@/lib/api-client";
4
+ import { Button } from "@/components/ui/button";
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from "@/components/ui/dialog";
12
+ import { Input } from "@/components/ui/input";
13
+ import { Label } from "@/components/ui/label";
14
+ import type { GitBranch } from "../../../types/git";
15
+ import { cn } from "@/lib/utils";
16
+
17
+ interface CreateWorktreeDialogProps {
18
+ open: boolean;
19
+ onOpenChange: (open: boolean) => void;
20
+ projectName: string;
21
+ onCreated: () => void;
22
+ }
23
+
24
+ type BranchMode = "existing" | "new";
25
+
26
+ /** Mobile bottom sheet + desktop dialog for creating a git worktree. */
27
+ export function CreateWorktreeDialog({
28
+ open,
29
+ onOpenChange,
30
+ projectName,
31
+ onCreated,
32
+ }: CreateWorktreeDialogProps) {
33
+ const [worktreePath, setWorktreePath] = useState("");
34
+ const [branchMode, setBranchMode] = useState<BranchMode>("new");
35
+ const [newBranch, setNewBranch] = useState("");
36
+ const [existingBranch, setExistingBranch] = useState("");
37
+ const [branches, setBranches] = useState<string[]>([]);
38
+ const [loading, setLoading] = useState(false);
39
+ const [error, setError] = useState<string | null>(null);
40
+
41
+ // Fetch available branches when dialog opens
42
+ useEffect(() => {
43
+ if (!open || !projectName) return;
44
+ api
45
+ .get<GitBranch[]>(`${projectUrl(projectName)}/git/branches`)
46
+ .then((data) => {
47
+ const local = data.filter((b) => !b.remote).map((b) => b.name);
48
+ setBranches(local);
49
+ if (local.length > 0 && !existingBranch) {
50
+ setExistingBranch(local[0]!);
51
+ }
52
+ })
53
+ .catch(() => setBranches([]));
54
+ }, [open, projectName]);
55
+
56
+ function reset() {
57
+ setWorktreePath("");
58
+ setBranchMode("new");
59
+ setNewBranch("");
60
+ setError(null);
61
+ }
62
+
63
+ function handleClose() {
64
+ reset();
65
+ onOpenChange(false);
66
+ }
67
+
68
+ async function handleSubmit() {
69
+ if (!worktreePath.trim()) {
70
+ setError("Worktree path is required");
71
+ return;
72
+ }
73
+ const selectedBranch = branchMode === "existing" ? existingBranch : undefined;
74
+ const selectedNewBranch = branchMode === "new" && newBranch.trim() ? newBranch.trim() : undefined;
75
+
76
+ setLoading(true);
77
+ setError(null);
78
+ try {
79
+ await api.post(`${projectUrl(projectName)}/git/worktree/add`, {
80
+ path: worktreePath.trim(),
81
+ branch: selectedBranch,
82
+ newBranch: selectedNewBranch,
83
+ });
84
+ onCreated();
85
+ handleClose();
86
+ } catch (e) {
87
+ setError(e instanceof Error ? e.message : "Failed to create worktree");
88
+ } finally {
89
+ setLoading(false);
90
+ }
91
+ }
92
+
93
+ const formContent = (
94
+ <div className="space-y-4">
95
+ {error && (
96
+ <p className="text-xs text-destructive bg-destructive/10 px-3 py-2 rounded-md">{error}</p>
97
+ )}
98
+
99
+ <div className="space-y-1.5">
100
+ <Label htmlFor="wt-path">Worktree Path</Label>
101
+ <Input
102
+ id="wt-path"
103
+ placeholder="../my-feature"
104
+ value={worktreePath}
105
+ onChange={(e) => setWorktreePath(e.target.value)}
106
+ autoFocus
107
+ className="w-full"
108
+ />
109
+ <p className="text-xs text-muted-foreground">
110
+ Relative or absolute path for the new worktree directory
111
+ </p>
112
+ </div>
113
+
114
+ <div className="space-y-2">
115
+ <Label>Branch</Label>
116
+ <div className="flex gap-3">
117
+ <label className="flex items-center gap-2 cursor-pointer">
118
+ <input
119
+ type="radio"
120
+ checked={branchMode === "new"}
121
+ onChange={() => setBranchMode("new")}
122
+ className="accent-primary"
123
+ />
124
+ <span className="text-sm">Create new branch</span>
125
+ </label>
126
+ <label className="flex items-center gap-2 cursor-pointer">
127
+ <input
128
+ type="radio"
129
+ checked={branchMode === "existing"}
130
+ onChange={() => setBranchMode("existing")}
131
+ className="accent-primary"
132
+ />
133
+ <span className="text-sm">Use existing branch</span>
134
+ </label>
135
+ </div>
136
+
137
+ {branchMode === "new" ? (
138
+ <Input
139
+ placeholder="feature/my-branch"
140
+ value={newBranch}
141
+ onChange={(e) => setNewBranch(e.target.value)}
142
+ className="w-full"
143
+ />
144
+ ) : (
145
+ <select
146
+ value={existingBranch}
147
+ onChange={(e) => setExistingBranch(e.target.value)}
148
+ className="w-full h-9 px-3 rounded-md border border-input bg-transparent text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
149
+ >
150
+ {branches.length === 0 && (
151
+ <option value="">No branches available</option>
152
+ )}
153
+ {branches.map((b) => (
154
+ <option key={b} value={b}>
155
+ {b}
156
+ </option>
157
+ ))}
158
+ </select>
159
+ )}
160
+ </div>
161
+ </div>
162
+ );
163
+
164
+ const footer = (
165
+ <div className="flex gap-2 justify-end pt-2">
166
+ <Button variant="outline" size="sm" onClick={handleClose} disabled={loading}>
167
+ Cancel
168
+ </Button>
169
+ <Button
170
+ size="sm"
171
+ onClick={handleSubmit}
172
+ disabled={loading || !worktreePath.trim()}
173
+ >
174
+ {loading ? <Loader2 className="size-3 animate-spin mr-1" /> : null}
175
+ Create Worktree
176
+ </Button>
177
+ </div>
178
+ );
179
+
180
+ return (
181
+ <>
182
+ {/* Desktop: centered dialog */}
183
+ <Dialog open={open} onOpenChange={(o) => { if (!o) handleClose(); }}>
184
+ <DialogContent className="hidden md:grid sm:max-w-md">
185
+ <DialogHeader>
186
+ <DialogTitle>Add Worktree</DialogTitle>
187
+ </DialogHeader>
188
+ {formContent}
189
+ <DialogFooter>{footer}</DialogFooter>
190
+ </DialogContent>
191
+ </Dialog>
192
+
193
+ {/* Mobile: bottom sheet */}
194
+ {open && (
195
+ <div className="md:hidden">
196
+ {/* Backdrop */}
197
+ <div
198
+ className="fixed inset-0 z-50 bg-black/50"
199
+ onClick={handleClose}
200
+ />
201
+ {/* Sheet */}
202
+ <div
203
+ className={cn(
204
+ "fixed bottom-0 left-0 right-0 z-50 bg-background rounded-t-2xl border-t border-border shadow-2xl",
205
+ "animate-in slide-in-from-bottom-2 duration-200",
206
+ )}
207
+ >
208
+ {/* Drag handle */}
209
+ <div className="flex justify-center pt-3 pb-1">
210
+ <div className="w-10 h-1 rounded-full bg-border" />
211
+ </div>
212
+ {/* Header */}
213
+ <div className="flex items-center justify-between px-4 py-2 border-b border-border">
214
+ <span className="text-sm font-semibold">Add Worktree</span>
215
+ <button
216
+ onClick={handleClose}
217
+ className="flex items-center justify-center size-7 rounded-md hover:bg-surface-elevated transition-colors"
218
+ >
219
+ <X className="size-4" />
220
+ </button>
221
+ </div>
222
+ {/* Body */}
223
+ <div className="px-4 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
224
+ {formContent}
225
+ {footer}
226
+ </div>
227
+ </div>
228
+ </div>
229
+ )}
230
+ </>
231
+ );
232
+ }
@@ -17,6 +17,8 @@ import { api, projectUrl } from "@/lib/api-client";
17
17
  import { basename } from "@/lib/utils";
18
18
  import { useTabStore } from "@/stores/tab-store";
19
19
  import { useSettingsStore } from "@/stores/settings-store";
20
+ import { useProjectStore } from "@/stores/project-store";
21
+ import { GitWorktreePanel } from "./git-worktree-panel";
20
22
  import { Button } from "@/components/ui/button";
21
23
  import { ScrollArea } from "@/components/ui/scroll-area";
22
24
  import {
@@ -117,6 +119,9 @@ export function GitStatusPanel({ metadata, tabId, onNavigate }: GitStatusPanelPr
117
119
  const { openTab } = useTabStore();
118
120
  const viewMode = useSettingsStore((s) => s.gitStatusViewMode);
119
121
  const setViewMode = useSettingsStore((s) => s.setGitStatusViewMode);
122
+ const activeProjectPath = useProjectStore((s) =>
123
+ s.projects.find((p) => p.name === projectName)?.path,
124
+ );
120
125
 
121
126
  const fetchStatus = useCallback(async () => {
122
127
  if (!projectName) return;
@@ -335,6 +340,14 @@ export function GitStatusPanel({ metadata, tabId, onNavigate }: GitStatusPanelPr
335
340
  </div>
336
341
  )}
337
342
 
343
+ {/* Worktrees collapsible section */}
344
+ {projectName && (
345
+ <GitWorktreePanel
346
+ projectName={projectName}
347
+ projectPath={activeProjectPath}
348
+ />
349
+ )}
350
+
338
351
  <ScrollArea className="flex-1 overflow-hidden">
339
352
  <div className="p-1.5 space-y-2 overflow-hidden">
340
353
  {/* Staged Changes */}