@hienlh/ppm 0.11.17 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/web/assets/{ai-settings-section-L6XAmZEP.js → ai-settings-section-BHdBBJtS.js} +1 -1
  3. package/dist/web/assets/{audio-preview-VMboGrIH.js → audio-preview-D4AxF10w.js} +1 -1
  4. package/dist/web/assets/chat-tab-Bq2hmJ-B.js +12 -0
  5. package/dist/web/assets/code-editor-CMcDjype.js +8 -0
  6. package/dist/web/assets/{conflict-editor-943WUefe.js → conflict-editor-Br-ugFiK.js} +1 -1
  7. package/dist/web/assets/{csv-preview-BEBJD4a_.js → csv-preview-HMSavgBb.js} +1 -1
  8. package/dist/web/assets/{database-viewer-BV0Ebp0z.js → database-viewer-DxP0GmQK.js} +2 -2
  9. package/dist/web/assets/{diff-viewer-B3gAWXgA.js → diff-viewer-oEyE9UwV.js} +1 -1
  10. package/dist/web/assets/dist-D7KGU7Vl.js +1 -0
  11. package/dist/web/assets/extension-webview-CVqfQGjg.js +3 -0
  12. package/dist/web/assets/{image-preview-BEiYtg6_.js → image-preview-CY3sVd25.js} +1 -1
  13. package/dist/web/assets/index-BDRoldC9.js +23 -0
  14. package/dist/web/assets/index-CDSox8V2.css +2 -0
  15. package/dist/web/assets/{input-ClhO__YM.js → input-Dk49gO8E.js} +1 -1
  16. package/dist/web/assets/{markdown-renderer-t1ZBKbXZ.js → markdown-renderer-DwqWhkri.js} +1 -1
  17. package/dist/web/assets/{pdf-preview-CjfQxXE5.js → pdf-preview-Cl95qWE_.js} +1 -1
  18. package/dist/web/assets/{port-forwarding-tab-BZmfg410.js → port-forwarding-tab-iJ3MAjXa.js} +1 -1
  19. package/dist/web/assets/{postgres-viewer-CSTO0jc2.js → postgres-viewer-Do_w0Cji.js} +2 -2
  20. package/dist/web/assets/{scroll-area-DW7L4Gnc.js → scroll-area-BEllam7_.js} +1 -1
  21. package/dist/web/assets/settings-tab-DyBeLmUh.js +1 -0
  22. package/dist/web/assets/{sqlite-viewer-D0oWgepE.js → sqlite-viewer-oZkGJfW2.js} +1 -1
  23. package/dist/web/assets/{terminal-tab-WBPZXu12.js → terminal-tab-UoDiWvzG.js} +1 -1
  24. package/dist/web/assets/{vendor-ui-B-T_damt.js → vendor-ui-B-89Uj8i.js} +1 -1
  25. package/dist/web/assets/{video-preview-BcMa4tim.js → video-preview-3MbkDYcA.js} +1 -1
  26. package/dist/web/index.html +7 -7
  27. package/dist/web/sw.js +1 -1
  28. package/docs/project-changelog.md +56 -0
  29. package/docs/system-architecture.md +10 -1
  30. package/package.json +1 -1
  31. package/src/server/routes/chat.ts +57 -2
  32. package/src/server/routes/project-scoped.ts +2 -0
  33. package/src/server/routes/tag-routes.ts +93 -0
  34. package/src/services/db.service.ts +35 -1
  35. package/src/services/project.service.ts +2 -0
  36. package/src/services/supervisor.ts +7 -2
  37. package/src/services/tag.service.ts +114 -0
  38. package/src/types/chat.ts +9 -0
  39. package/src/web/components/chat/chat-history-bar.tsx +106 -7
  40. package/src/web/components/chat/chat-welcome.tsx +54 -27
  41. package/src/web/components/chat/session-context-menu.tsx +101 -0
  42. package/src/web/components/chat/session-picker.tsx +3 -0
  43. package/src/web/components/chat/tag-filter-chips.tsx +58 -0
  44. package/src/web/components/extensions/extension-webview.tsx +5 -33
  45. package/src/web/components/layout/editor-panel.tsx +53 -26
  46. package/src/web/components/layout/upgrade-banner.tsx +47 -37
  47. package/src/web/components/settings/tag-settings-section.tsx +167 -0
  48. package/src/web/hooks/use-extension-ws.ts +7 -2
  49. package/src/web/styles/globals.css +14 -0
  50. package/dist/web/assets/chat-tab-DfO2rHO8.js +0 -12
  51. package/dist/web/assets/code-editor-BU7NX_SZ.js +0 -8
  52. package/dist/web/assets/dist-C5IgeqrV.js +0 -1
  53. package/dist/web/assets/extension-webview-C8rdBYLl.js +0 -3
  54. package/dist/web/assets/index-B0V_IYbX.css +0 -2
  55. package/dist/web/assets/index-CBsOxcqb.js +0 -23
  56. package/dist/web/assets/settings-tab-b3AbZg6I.js +0 -1
@@ -1,12 +1,10 @@
1
1
  import { useState, useEffect, useCallback } from "react";
2
2
  import { api } from "@/lib/api-client";
3
3
  import { toast } from "sonner";
4
- import { Loader2, ArrowUpCircle, X } from "lucide-react";
4
+ import { Loader2, ArrowUpCircle, X, RefreshCw, CheckCircle2 } from "lucide-react";
5
5
 
6
6
  const POLL_INTERVAL_MS = 60_000;
7
7
  const DISMISS_KEY_PREFIX = "ppm-upgrade-dismissed-";
8
- const RESTART_POLL_MS = 1_500;
9
- const RESTART_TIMEOUT_MS = 60_000;
10
8
 
11
9
  interface UpgradeStatus {
12
10
  currentVersion: string;
@@ -21,28 +19,13 @@ interface UpgradeResult {
21
19
  message?: string;
22
20
  }
23
21
 
24
- /** Poll /api/health aggressively until server goes down then back up, then reload. */
25
- async function waitForServerRestart(): Promise<boolean> {
26
- let serverWentDown = false;
27
- const start = Date.now();
28
-
29
- while (Date.now() - start < RESTART_TIMEOUT_MS) {
30
- await new Promise((r) => setTimeout(r, RESTART_POLL_MS));
31
- try {
32
- const res = await fetch("/api/health", { cache: "no-store" });
33
- if (res.ok && serverWentDown) {
34
- if ("caches" in window) {
35
- const keys = await caches.keys();
36
- await Promise.all(keys.map((k) => caches.delete(k)));
37
- }
38
- window.location.reload();
39
- return true;
40
- }
41
- } catch {
42
- serverWentDown = true;
43
- }
22
+ /** Clear browser/SW caches and reload the page */
23
+ async function clearCachesAndReload() {
24
+ if ("caches" in window) {
25
+ const keys = await caches.keys();
26
+ await Promise.all(keys.map((k) => caches.delete(k)));
44
27
  }
45
- return false;
28
+ window.location.reload();
46
29
  }
47
30
 
48
31
  interface UpgradeBannerProps {
@@ -52,6 +35,7 @@ interface UpgradeBannerProps {
52
35
  export function UpgradeBanner({ onVisibilityChange }: UpgradeBannerProps) {
53
36
  const [availableVersion, setAvailableVersion] = useState<string | null>(null);
54
37
  const [upgrading, setUpgrading] = useState(false);
38
+ const [upgradeComplete, setUpgradeComplete] = useState(false);
55
39
  const [dismissed, setDismissed] = useState(false);
56
40
 
57
41
  // Poll for upgrade status
@@ -86,12 +70,9 @@ export function UpgradeBanner({ onVisibilityChange }: UpgradeBannerProps) {
86
70
  const data = await api.post<UpgradeResult>("/api/upgrade/apply");
87
71
 
88
72
  if (data.restart) {
89
- // Server will restart — poll aggressively until it comes back
90
- const restarted = await waitForServerRestart();
91
- if (!restarted) {
92
- toast.warning("Upgrade installed but server hasn't restarted. Try refreshing manually.");
93
- setUpgrading(false);
94
- }
73
+ // Upgrade installed, server will restart — ask user to reload
74
+ setUpgrading(false);
75
+ setUpgradeComplete(true);
95
76
  } else {
96
77
  // No supervisor — manual restart needed
97
78
  toast.info(data.message || "Upgrade installed. Restart PPM manually.");
@@ -102,10 +83,21 @@ export function UpgradeBanner({ onVisibilityChange }: UpgradeBannerProps) {
102
83
  setDismissed(true);
103
84
  }
104
85
  } catch (e) {
105
- toast.error(`Upgrade failed: ${(e as Error).message}`);
106
- setUpgrading(false);
86
+ // If fetch failed with a network error, the server likely died mid-response
87
+ // after the install succeeded (supervisor killed the server before response flushed).
88
+ // Show reload prompt instead of a confusing error.
89
+ const isNetworkError = e instanceof TypeError
90
+ || (e as Error).message?.includes("fetch")
91
+ || (e as Error).message?.includes("network");
92
+ if (isNetworkError) {
93
+ setUpgrading(false);
94
+ setUpgradeComplete(true);
95
+ } else {
96
+ toast.error(`Upgrade failed: ${(e as Error).message}`);
97
+ setUpgrading(false);
98
+ }
107
99
  }
108
- }, []);
100
+ }, [availableVersion]);
109
101
 
110
102
  const handleDismiss = useCallback(() => {
111
103
  if (availableVersion) {
@@ -114,7 +106,7 @@ export function UpgradeBanner({ onVisibilityChange }: UpgradeBannerProps) {
114
106
  setDismissed(true);
115
107
  }, [availableVersion]);
116
108
 
117
- const visible = !!availableVersion && !dismissed;
109
+ const visible = (!!availableVersion && !dismissed) || upgradeComplete;
118
110
 
119
111
  useEffect(() => {
120
112
  onVisibilityChange?.(visible);
@@ -123,12 +115,30 @@ export function UpgradeBanner({ onVisibilityChange }: UpgradeBannerProps) {
123
115
  if (!visible) return null;
124
116
 
125
117
  return (
126
- <div className="w-full bg-blue-600 dark:bg-blue-700 text-white px-3 py-1 flex items-center justify-between gap-2 z-50 text-sm shrink-0">
127
- {upgrading ? (
118
+ <div className={`w-full text-white px-3 py-1 flex items-center justify-between gap-2 z-50 text-sm shrink-0 ${
119
+ upgradeComplete ? "bg-green-600 dark:bg-green-700" : "bg-blue-600 dark:bg-blue-700"
120
+ }`}>
121
+ {upgradeComplete ? (
122
+ <>
123
+ <div className="flex items-center gap-2 flex-1 min-w-0">
124
+ <CheckCircle2 className="size-4 shrink-0" />
125
+ <span className="truncate">
126
+ Upgrade to v{availableVersion} installed! Reload to apply.
127
+ </span>
128
+ </div>
129
+ <button
130
+ onClick={clearCachesAndReload}
131
+ className="bg-white text-green-600 font-medium rounded-full px-3 py-0.5 text-xs min-h-[28px] min-w-[28px] flex items-center gap-1.5 justify-center hover:bg-green-50 active:bg-green-100 transition-colors shrink-0"
132
+ >
133
+ <RefreshCw className="size-3" />
134
+ Reload
135
+ </button>
136
+ </>
137
+ ) : upgrading ? (
128
138
  <div className="flex items-center gap-2 flex-1 min-w-0">
129
139
  <Loader2 className="size-4 animate-spin shrink-0" />
130
140
  <span className="truncate">
131
- Upgrading to v{availableVersion}... PPM will restart shortly
141
+ Upgrading to v{availableVersion}...
132
142
  </span>
133
143
  </div>
134
144
  ) : (
@@ -0,0 +1,167 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { Plus, Trash2, Pencil, Check, X, RotateCcw } from "lucide-react";
3
+ import { api, projectUrl } from "@/lib/api-client";
4
+ import type { ProjectTag } from "../../../types/chat";
5
+
6
+ interface TagSettingsSectionProps {
7
+ projectName: string;
8
+ onTagsChanged?: () => void;
9
+ }
10
+
11
+ export function TagSettingsSection({ projectName, onTagsChanged }: TagSettingsSectionProps) {
12
+ const [tags, setTags] = useState<ProjectTag[]>([]);
13
+ const [defaultTagId, setDefaultTagId] = useState<number | null>(null);
14
+ const [loading, setLoading] = useState(true);
15
+ const [editingId, setEditingId] = useState<number | null>(null);
16
+ const [editName, setEditName] = useState("");
17
+ const [editColor, setEditColor] = useState("");
18
+ const [newName, setNewName] = useState("");
19
+ const [newColor, setNewColor] = useState("#22c55e");
20
+ const [showAdd, setShowAdd] = useState(false);
21
+
22
+ const baseUrl = `${projectUrl(projectName)}/tags`;
23
+
24
+ const loadTags = useCallback(async () => {
25
+ try {
26
+ const data = await api.get<{ tags: ProjectTag[]; defaultTagId: number | null }>(baseUrl);
27
+ setTags(data.tags);
28
+ setDefaultTagId(data.defaultTagId);
29
+ } catch { /* silent */ }
30
+ setLoading(false);
31
+ }, [baseUrl]);
32
+
33
+ useEffect(() => { loadTags(); }, [loadTags]);
34
+
35
+ const handleCreate = async () => {
36
+ if (!newName.trim()) return;
37
+ try {
38
+ await api.post(baseUrl, { name: newName.trim(), color: newColor });
39
+ setNewName("");
40
+ setShowAdd(false);
41
+ loadTags();
42
+ onTagsChanged?.();
43
+ } catch { /* silent */ }
44
+ };
45
+
46
+ const handleUpdate = async (id: number) => {
47
+ try {
48
+ await api.patch(`${baseUrl}/${id}`, { name: editName.trim() || undefined, color: editColor || undefined });
49
+ setEditingId(null);
50
+ loadTags();
51
+ onTagsChanged?.();
52
+ } catch { /* silent */ }
53
+ };
54
+
55
+ const handleDelete = async (id: number, name: string) => {
56
+ if (!window.confirm(`Delete tag "${name}"? Sessions with this tag will become untagged.`)) return;
57
+ try {
58
+ await api.del(`${baseUrl}/${id}`);
59
+ loadTags();
60
+ onTagsChanged?.();
61
+ } catch { /* silent */ }
62
+ };
63
+
64
+ const handleSetDefault = async (tagId: number) => {
65
+ const newId = tagId === defaultTagId ? null : tagId;
66
+ try {
67
+ await api.patch(`${baseUrl}/default-tag`, { tagId: newId });
68
+ setDefaultTagId(newId);
69
+ } catch { /* silent */ }
70
+ };
71
+
72
+ const handleReset = async () => {
73
+ try {
74
+ await api.post(`${baseUrl}/reset`, {});
75
+ loadTags();
76
+ onTagsChanged?.();
77
+ } catch { /* silent */ }
78
+ };
79
+
80
+ if (loading) return <p className="text-[11px] text-muted-foreground animate-pulse">Loading tags...</p>;
81
+
82
+ return (
83
+ <div className="space-y-2">
84
+ <div className="flex items-center justify-between">
85
+ <h3 className="text-xs font-medium text-muted-foreground">Session Tags</h3>
86
+ <div className="flex items-center gap-1">
87
+ <button onClick={handleReset} className="p-1 rounded text-text-subtle hover:text-text-secondary" title="Reset to defaults">
88
+ <RotateCcw className="size-3" />
89
+ </button>
90
+ <button onClick={() => setShowAdd(!showAdd)} className="p-1 rounded text-primary hover:bg-primary/10" title="Add tag">
91
+ <Plus className="size-3.5" />
92
+ </button>
93
+ </div>
94
+ </div>
95
+
96
+ {/* Add form */}
97
+ {showAdd && (
98
+ <div className="flex items-center gap-1.5 px-1">
99
+ <input type="color" value={newColor} onChange={(e) => setNewColor(e.target.value)} className="size-6 rounded cursor-pointer border-0 p-0" />
100
+ <input
101
+ value={newName}
102
+ onChange={(e) => setNewName(e.target.value)}
103
+ onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); if (e.key === "Escape") setShowAdd(false); }}
104
+ placeholder="Tag name"
105
+ className="flex-1 min-w-0 bg-surface-elevated text-[11px] text-text-primary px-2 py-1 rounded border border-border outline-none focus:border-primary"
106
+ autoFocus
107
+ />
108
+ <button onClick={handleCreate} className="p-1 text-green-500 hover:text-green-400"><Check className="size-3.5" /></button>
109
+ <button onClick={() => setShowAdd(false)} className="p-1 text-text-subtle hover:text-text-secondary"><X className="size-3.5" /></button>
110
+ </div>
111
+ )}
112
+
113
+ {/* Tag list */}
114
+ <div className="space-y-0.5">
115
+ {tags.map((tag) => (
116
+ <div key={tag.id} className="flex items-center gap-1.5 px-1 py-1 rounded hover:bg-surface-elevated group">
117
+ {editingId === tag.id ? (
118
+ <>
119
+ <input type="color" value={editColor} onChange={(e) => setEditColor(e.target.value)} className="size-5 rounded cursor-pointer border-0 p-0" />
120
+ <input
121
+ value={editName}
122
+ onChange={(e) => setEditName(e.target.value)}
123
+ onKeyDown={(e) => { if (e.key === "Enter") handleUpdate(tag.id); if (e.key === "Escape") setEditingId(null); }}
124
+ className="flex-1 min-w-0 bg-surface-elevated text-[11px] px-1.5 py-0.5 rounded border border-border outline-none focus:border-primary"
125
+ autoFocus
126
+ />
127
+ <button onClick={() => handleUpdate(tag.id)} className="p-0.5 text-green-500"><Check className="size-3" /></button>
128
+ <button onClick={() => setEditingId(null)} className="p-0.5 text-text-subtle"><X className="size-3" /></button>
129
+ </>
130
+ ) : (
131
+ <>
132
+ <span className="size-3 rounded-full shrink-0" style={{ backgroundColor: tag.color }} />
133
+ <span className="flex-1 text-[11px] text-text-primary truncate">{tag.name}</span>
134
+ <button
135
+ onClick={() => handleSetDefault(tag.id)}
136
+ className={`px-1.5 py-0.5 rounded text-[9px] font-medium transition-colors ${
137
+ tag.id === defaultTagId
138
+ ? "bg-primary/15 text-primary border border-primary/30"
139
+ : "text-text-subtle border border-transparent can-hover:opacity-0 can-hover:group-hover:opacity-100 hover:bg-surface-elevated hover:border-border"
140
+ }`}
141
+ title={tag.id === defaultTagId ? "Default tag (click to unset)" : "Set as default for new sessions"}
142
+ >
143
+ {tag.id === defaultTagId ? "Default" : "Set default"}
144
+ </button>
145
+ <button
146
+ onClick={() => { setEditingId(tag.id); setEditName(tag.name); setEditColor(tag.color); }}
147
+ className="p-0.5 rounded text-text-subtle hover:text-text-secondary can-hover:opacity-0 can-hover:group-hover:opacity-100"
148
+ >
149
+ <Pencil className="size-3" />
150
+ </button>
151
+ <button
152
+ onClick={() => handleDelete(tag.id, tag.name)}
153
+ className="p-0.5 rounded text-text-subtle hover:text-red-400 can-hover:opacity-0 can-hover:group-hover:opacity-100"
154
+ >
155
+ <Trash2 className="size-3" />
156
+ </button>
157
+ </>
158
+ )}
159
+ </div>
160
+ ))}
161
+ {tags.length === 0 && (
162
+ <p className="text-[11px] text-muted-foreground py-2 text-center">No tags. Click + to create one.</p>
163
+ )}
164
+ </div>
165
+ </div>
166
+ );
167
+ }
@@ -151,10 +151,15 @@ export function useExtensionWs(enabled = true) {
151
151
  if (t) { existingTabId = t.id; break; }
152
152
  }
153
153
  if (existingTabId) {
154
- // Tab already exists — update metadata with new panelId (panel was recreated)
154
+ // Tab already exists — update metadata with new panelId (panel was recreated).
155
+ // Preserve existing metadata (e.g. projectName) since updateTab replaces metadata entirely.
156
+ const existingTab = ps.grid.flat().reduce<Record<string, unknown> | undefined>((acc, pid) => {
157
+ if (acc) return acc;
158
+ return ps.panels[pid]?.tabs.find(tab => tab.id === existingTabId)?.metadata;
159
+ }, undefined);
155
160
  useTabStore.getState().updateTab(existingTabId, {
156
161
  title: msg.title,
157
- metadata: { viewType: viewTypeSlug, panelId: msg.panelId, extensionId: msg.extensionId },
162
+ metadata: { ...existingTab, viewType: viewTypeSlug, panelId: msg.panelId, extensionId: msg.extensionId },
158
163
  });
159
164
  // Focus the existing tab so Cmd+G / command palette switches to it
160
165
  useTabStore.getState().setActiveTab(existingTabId);
@@ -172,6 +172,7 @@ html, body {
172
172
 
173
173
  .markdown-content pre {
174
174
  overflow-x: auto;
175
+ overflow-y: hidden;
175
176
  border-radius: 6px;
176
177
  background: var(--color-background);
177
178
  padding: 0.5rem;
@@ -179,6 +180,19 @@ html, body {
179
180
  font-family: var(--font-mono);
180
181
  border: 1px solid var(--color-border);
181
182
  margin: 0.5rem 0;
183
+ scrollbar-width: thin;
184
+ scrollbar-color: var(--color-border) transparent;
185
+ }
186
+
187
+ .markdown-content pre::-webkit-scrollbar {
188
+ height: 4px;
189
+ }
190
+ .markdown-content pre::-webkit-scrollbar-track {
191
+ background: transparent;
192
+ }
193
+ .markdown-content pre::-webkit-scrollbar-thumb {
194
+ background: var(--color-border);
195
+ border-radius: 9999px;
182
196
  }
183
197
 
184
198
  .markdown-content code {