@hienlh/ppm 0.11.18 → 0.12.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 (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-CZ7uVdJ1.js → audio-preview-DBOZOGw7.js} +1 -1
  4. package/dist/web/assets/chat-tab-BrdZcg-D.js +12 -0
  5. package/dist/web/assets/code-editor-BjB4rgPf.js +8 -0
  6. package/dist/web/assets/{conflict-editor-BsNC4CIp.js → conflict-editor-BnXrp55a.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-dYaf9tXG.js → database-viewer-D39etQBx.js} +2 -2
  9. package/dist/web/assets/{diff-viewer-BlQsizFF.js → diff-viewer-NVkEMuQW.js} +1 -1
  10. package/dist/web/assets/dist-D7KGU7Vl.js +1 -0
  11. package/dist/web/assets/extension-webview-AjxJYBif.js +3 -0
  12. package/dist/web/assets/{image-preview-eH8PNO1D.js → image-preview-BDhESiBw.js} +1 -1
  13. package/dist/web/assets/index-BeD-Flot.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-CyDOVFYo.js → markdown-renderer-CRA2pyBy.js} +1 -1
  17. package/dist/web/assets/{pdf-preview-BOJHsWt2.js → pdf-preview-BbA8cq4C.js} +1 -1
  18. package/dist/web/assets/{port-forwarding-tab-BxuO01Zb.js → port-forwarding-tab-D-Ka0Xhw.js} +1 -1
  19. package/dist/web/assets/{postgres-viewer-cLp_WazA.js → postgres-viewer-B4YwHwTt.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-ycdscbD8.js +1 -0
  22. package/dist/web/assets/{sqlite-viewer-BgEY7Fml.js → sqlite-viewer-CBR5cebG.js} +1 -1
  23. package/dist/web/assets/{terminal-tab-B6meTG0M.js → terminal-tab-DATStOUT.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-D2gRujKO.js → video-preview-B-DptaDM.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/tag.service.ts +114 -0
  37. package/src/types/chat.ts +9 -0
  38. package/src/web/components/chat/chat-history-bar.tsx +106 -7
  39. package/src/web/components/chat/chat-welcome.tsx +54 -27
  40. package/src/web/components/chat/session-context-menu.tsx +101 -0
  41. package/src/web/components/chat/session-picker.tsx +3 -0
  42. package/src/web/components/chat/tag-filter-chips.tsx +58 -0
  43. package/src/web/components/extensions/extension-webview.tsx +5 -33
  44. package/src/web/components/layout/draggable-tab.tsx +12 -0
  45. package/src/web/components/layout/editor-panel.tsx +53 -26
  46. package/src/web/components/layout/tab-bar.tsx +66 -0
  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-D0edjxcO.js +0 -12
  51. package/dist/web/assets/code-editor-CHWEBvuT.js +0 -8
  52. package/dist/web/assets/dist-C5IgeqrV.js +0 -1
  53. package/dist/web/assets/extension-webview-DXjdd0U6.js +0 -3
  54. package/dist/web/assets/index-BVQikaUv.js +0 -23
  55. package/dist/web/assets/index-C99i-AFP.css +0 -2
  56. package/dist/web/assets/settings-tab-BQpEkd-V.js +0 -1
@@ -4,6 +4,8 @@ import { usePanelStore } from "@/stores/panel-store";
4
4
  import { useProjectStore } from "@/stores/project-store";
5
5
  import { useTabStore, type TabType } from "@/stores/tab-store";
6
6
  import { api, projectUrl } from "@/lib/api-client";
7
+ import { useProjectTags, TagChipBar } from "@/components/chat/tag-filter-chips";
8
+ import { SessionContextMenu } from "@/components/chat/session-context-menu";
7
9
  import type { SessionInfo } from "../../../types/chat";
8
10
  import { TabBar } from "./tab-bar";
9
11
  import { SplitDropOverlay } from "./split-drop-overlay";
@@ -111,6 +113,8 @@ function EmptyPanel({ panelId }: { panelId: string }) {
111
113
  const [sessions, setSessions] = useState<SessionInfo[]>([]);
112
114
  const [loadingSessions, setLoadingSessions] = useState(false);
113
115
  const [showAll, setShowAll] = useState(false);
116
+ const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
117
+ const { projectTags, tagCounts, loadTags } = useProjectTags(activeProject?.name);
114
118
 
115
119
  const loadSessions = useCallback(async () => {
116
120
  if (!activeProject?.name) return;
@@ -176,41 +180,58 @@ function EmptyPanel({ panelId }: { panelId: string }) {
176
180
  );
177
181
  }
178
182
 
179
- const pinnedSessions = sessions.filter((s) => s.pinned);
180
- const allRecentSessions = sessions.filter((s) => !s.pinned);
183
+ const handleTagChanged = useCallback((sid: string, tag: { id: number; name: string; color: string } | null) => {
184
+ setSessions((prev) => prev.map((s) => s.id === sid ? { ...s, tag } : s));
185
+ loadTags();
186
+ }, [loadTags]);
187
+
188
+ const filtered = selectedTagId !== null ? sessions.filter((s) => s.tag?.id === selectedTagId) : sessions;
189
+ const pinnedSessions = filtered.filter((s) => s.pinned);
190
+ const allRecentSessions = filtered.filter((s) => !s.pinned);
181
191
  const recentSessions = showAll ? allRecentSessions : allRecentSessions.slice(0, MAX_RECENT_SESSIONS);
182
192
  const hasMore = allRecentSessions.length > MAX_RECENT_SESSIONS;
183
193
 
184
194
  function renderSessionRow(session: SessionInfo) {
185
195
  return (
186
- <button
196
+ <SessionContextMenu
187
197
  key={session.id}
188
- onClick={() => openSession(session)}
189
- className="group flex items-center gap-2.5 w-full px-3 py-2.5 text-left hover:bg-surface-elevated active:bg-surface-elevated transition-colors border-b border-border/50 last:border-0"
198
+ session={session}
199
+ projectName={activeProject!.name}
200
+ projectTags={projectTags}
201
+ onTogglePin={togglePin}
202
+ onTagChanged={handleTagChanged}
190
203
  >
191
- <MessageSquare className="size-3.5 shrink-0 text-text-subtle" />
192
- <span className="flex-1 min-w-0 text-xs font-medium truncate text-text-primary">
193
- {session.title || "Untitled"}
194
- </span>
195
- {session.updatedAt && (
196
- <span className="text-[10px] text-text-subtle shrink-0">
197
- {formatRelativeDate(session.updatedAt)}
198
- </span>
199
- )}
200
- <span
201
- role="button"
202
- tabIndex={0}
203
- onClick={(e) => togglePin(e, session)}
204
- className={`p-1 rounded transition-colors shrink-0 ${
205
- session.pinned
206
- ? "text-primary hover:text-primary/70"
207
- : "text-text-subtle can-hover:opacity-0 can-hover:group-hover:opacity-100 hover:text-text-primary"
208
- }`}
209
- aria-label={session.pinned ? "Unpin session" : "Pin session"}
204
+ <button
205
+ onClick={() => openSession(session)}
206
+ className="group flex items-center gap-2.5 w-full px-3 py-2.5 text-left hover:bg-surface-elevated active:bg-surface-elevated transition-colors border-b border-border/50 last:border-0"
210
207
  >
211
- {session.pinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
208
+ <MessageSquare className="size-3.5 shrink-0 text-text-subtle" />
209
+ {session.tag && (
210
+ <span className="size-2 rounded-full shrink-0" style={{ backgroundColor: session.tag.color }} title={session.tag.name} />
211
+ )}
212
+ <span className="flex-1 min-w-0 text-xs font-medium truncate text-text-primary">
213
+ {session.title || "Untitled"}
214
+ </span>
215
+ {session.updatedAt && (
216
+ <span className="text-[10px] text-text-subtle shrink-0">
217
+ {formatRelativeDate(session.updatedAt)}
218
+ </span>
219
+ )}
220
+ <span
221
+ role="button"
222
+ tabIndex={0}
223
+ onClick={(e) => togglePin(e, session)}
224
+ className={`p-1 rounded transition-colors shrink-0 ${
225
+ session.pinned
226
+ ? "text-primary hover:text-primary/70"
227
+ : "text-text-subtle can-hover:opacity-0 can-hover:group-hover:opacity-100 hover:text-text-primary"
228
+ }`}
229
+ aria-label={session.pinned ? "Unpin session" : "Pin session"}
230
+ >
231
+ {session.pinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
212
232
  </span>
213
- </button>
233
+ </button>
234
+ </SessionContextMenu>
214
235
  );
215
236
  }
216
237
 
@@ -234,6 +255,12 @@ function EmptyPanel({ panelId }: { panelId: string }) {
234
255
  })}
235
256
  </div>
236
257
 
258
+ {activeProject && !loadingSessions && sessions.length > 0 && (
259
+ <div className="w-full max-w-sm">
260
+ <TagChipBar projectTags={projectTags} tagCounts={tagCounts} totalCount={sessions.length} selectedTagId={selectedTagId} onSelect={setSelectedTagId} />
261
+ </div>
262
+ )}
263
+
237
264
  {activeProject && !loadingSessions && pinnedSessions.length > 0 && (
238
265
  <div className="flex flex-col gap-2 w-full max-w-sm">
239
266
  <p className="text-xs text-text-subtle text-center">Pinned</p>
@@ -20,6 +20,12 @@ import { useTabDrag } from "@/hooks/use-tab-drag";
20
20
  import { useTouchTabDrag, wasTouchDragRecent } from "@/hooks/use-touch-tab-drag";
21
21
  import { openCommandPalette } from "@/hooks/use-global-keybindings";
22
22
  import { api, projectUrl } from "@/lib/api-client";
23
+ import { useProjectTags } from "@/components/chat/tag-filter-chips";
24
+ import {
25
+ ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent,
26
+ ContextMenuItem, ContextMenuSeparator,
27
+ } from "@/components/ui/context-menu";
28
+ import { Tag, Check } from "lucide-react";
23
29
  import { useNotificationStore, notificationColor } from "@/stores/notification-store";
24
30
  import { useStreamingStore } from "@/stores/streaming-store";
25
31
  import { useTabOverflow, getHiddenUnreadDirection } from "@/hooks/use-tab-overflow";
@@ -64,6 +70,37 @@ export const TabBar = memo(function TabBar({ panelId }: TabBarProps) {
64
70
  useTabDrag(effectivePanelId);
65
71
  const { handleTouchStart, handleTouchMove, handleTouchEnd } = useTouchTabDrag(effectivePanelId);
66
72
 
73
+ const { projectTags, loadTags } = useProjectTags(activeProject?.name);
74
+ const [sessionTagMap, setSessionTagMap] = useState<Record<string, { id: number; name: string; color: string }>>({});
75
+
76
+ // Fetch session tags for open chat tabs
77
+ const chatSessionIds = tabs.filter((t) => t.type === "chat" && t.metadata?.sessionId).map((t) => t.metadata!.sessionId as string);
78
+ useEffect(() => {
79
+ if (!activeProject?.name || chatSessionIds.length === 0) return;
80
+ api.get<{ sessions: { id: string; tag?: { id: number; name: string; color: string } | null }[] }>(
81
+ `${projectUrl(activeProject.name)}/chat/sessions?limit=50`,
82
+ ).then((data) => {
83
+ const map: Record<string, { id: number; name: string; color: string }> = {};
84
+ for (const s of data.sessions) { if (s.tag) map[s.id] = s.tag; }
85
+ setSessionTagMap(map);
86
+ }).catch(() => {});
87
+ }, [activeProject?.name, chatSessionIds.join(",")]); // eslint-disable-line react-hooks/exhaustive-deps
88
+
89
+ const assignTagToSession = useCallback(async (sessionId: string, tagId: number | null) => {
90
+ if (!activeProject?.name) return;
91
+ try {
92
+ if (tagId !== null) {
93
+ await api.patch(`${projectUrl(activeProject.name)}/chat/sessions/${sessionId}/tag`, { tagId });
94
+ const tag = projectTags.find((t) => t.id === tagId);
95
+ if (tag) setSessionTagMap((prev) => ({ ...prev, [sessionId]: { id: tag.id, name: tag.name, color: tag.color } }));
96
+ } else {
97
+ await api.del(`${projectUrl(activeProject.name)}/chat/sessions/${sessionId}/tag`);
98
+ setSessionTagMap((prev) => { const n = { ...prev }; delete n[sessionId]; return n; });
99
+ }
100
+ loadTags();
101
+ } catch { /* silent */ }
102
+ }, [activeProject?.name, projectTags, loadTags]);
103
+
67
104
  const notifications = useNotificationStore((s) => s.notifications);
68
105
  const streamingSessions = useStreamingStore((s) => s.sessions);
69
106
  const { canScrollLeft, canScrollRight, scrollLeft: doScrollLeft, scrollRight: doScrollRight } =
@@ -219,6 +256,35 @@ export const TabBar = memo(function TabBar({ panelId }: TabBarProps) {
219
256
  }}
220
257
  onRename={tab.type === "chat" ? (title) => handleRenameTab(tab, title) : undefined}
221
258
  onContextAction={(action) => handleTabContextAction(tab, action)}
259
+ tagColor={sessionId ? sessionTagMap[sessionId]?.color : undefined}
260
+ extraMenuContent={sessionId && projectTags.length > 0 ? (
261
+ <>
262
+ <ContextMenuSub>
263
+ <ContextMenuSubTrigger>
264
+ <Tag className="size-3.5 mr-2" />
265
+ Set Tag
266
+ </ContextMenuSubTrigger>
267
+ <ContextMenuSubContent>
268
+ {projectTags.map((pt) => (
269
+ <ContextMenuItem key={pt.id} onClick={() => assignTagToSession(sessionId, pt.id)}>
270
+ <span className="size-2.5 rounded-full mr-2 shrink-0" style={{ backgroundColor: pt.color }} />
271
+ {pt.name}
272
+ {sessionTagMap[sessionId]?.id === pt.id && <Check className="size-3 ml-auto" />}
273
+ </ContextMenuItem>
274
+ ))}
275
+ {sessionTagMap[sessionId] && (
276
+ <>
277
+ <ContextMenuSeparator />
278
+ <ContextMenuItem onClick={() => assignTagToSession(sessionId, null)}>
279
+ Remove tag
280
+ </ContextMenuItem>
281
+ </>
282
+ )}
283
+ </ContextMenuSubContent>
284
+ </ContextMenuSub>
285
+ <ContextMenuSeparator />
286
+ </>
287
+ ) : undefined}
222
288
  />
223
289
  );
224
290
  })}
@@ -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 {