@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
package/src/types/chat.ts CHANGED
@@ -56,6 +56,14 @@ export interface SessionConfig {
56
56
  title?: string;
57
57
  }
58
58
 
59
+ export interface ProjectTag {
60
+ id: number;
61
+ projectPath: string;
62
+ name: string;
63
+ color: string;
64
+ sortOrder: number;
65
+ }
66
+
59
67
  export interface SessionInfo {
60
68
  id: string;
61
69
  providerId: string;
@@ -64,6 +72,7 @@ export interface SessionInfo {
64
72
  createdAt: string;
65
73
  updatedAt?: string;
66
74
  pinned?: boolean;
75
+ tag?: { id: number; name: string; color: string } | null;
67
76
  }
68
77
 
69
78
  export interface SessionListResponse {
@@ -1,14 +1,16 @@
1
1
  import { useState, useEffect, useCallback, useRef } from "react";
2
- import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Bug, ClipboardCheck, Pin, PinOff, Trash2, Users, Bot } from "lucide-react";
2
+ import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Bug, ClipboardCheck, Pin, PinOff, Trash2, Users, Bot, Tags } from "lucide-react";
3
3
  import { Activity } from "lucide-react";
4
4
  import { api, projectUrl } from "@/lib/api-client";
5
5
  import { useTabStore } from "@/stores/tab-store";
6
6
  import { useNotificationStore } from "@/stores/notification-store";
7
7
  import { AISettingsSection } from "@/components/settings/ai-settings-section";
8
+ import { TagSettingsSection } from "@/components/settings/tag-settings-section";
9
+ import { SessionContextMenu } from "./session-context-menu";
8
10
  import { UsageDetailPanel } from "./usage-badge";
9
11
  import { TeamActivityPanel } from "./team-activity-panel";
10
12
  import { ProviderBadge } from "./provider-selector";
11
- import type { SessionInfo, SessionListResponse } from "../../../types/chat";
13
+ import type { SessionInfo, SessionListResponse, ProjectTag } from "../../../types/chat";
12
14
  import type { UsageInfo } from "../../../types/chat";
13
15
  import type { TeamMessageItem } from "@/hooks/use-chat";
14
16
 
@@ -108,6 +110,10 @@ export function ChatHistoryBar({
108
110
  const [editingTitle, setEditingTitle] = useState("");
109
111
  const [hasMore, setHasMore] = useState(false);
110
112
  const [loadingMore, setLoadingMore] = useState(false);
113
+ const [projectTags, setProjectTags] = useState<ProjectTag[]>([]);
114
+ const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
115
+ const [tagCounts, setTagCounts] = useState<Record<number, number>>({});
116
+ const [showTagSettings, setShowTagSettings] = useState(false);
111
117
  const editInputRef = useRef<HTMLInputElement>(null);
112
118
  const openTab = useTabStore((s) => s.openTab);
113
119
  const PAGE_SIZE = 50;
@@ -155,6 +161,22 @@ export function ChatHistoryBar({
155
161
  if (activePanel === "history" && sessions.length === 0) load();
156
162
  }, [activePanel]); // eslint-disable-line react-hooks/exhaustive-deps
157
163
 
164
+ // Fetch tags
165
+ const loadTags = useCallback(async () => {
166
+ if (!projectName) return;
167
+ try {
168
+ const data = await api.get<{ tags: ProjectTag[]; counts: Record<number, number> }>(
169
+ `${projectUrl(projectName)}/tags`,
170
+ );
171
+ setProjectTags(data.tags);
172
+ setTagCounts(data.counts);
173
+ } catch { /* silent */ }
174
+ }, [projectName]);
175
+
176
+ useEffect(() => {
177
+ if (activePanel === "history" && projectName) loadTags();
178
+ }, [activePanel, projectName, loadTags]);
179
+
158
180
  function openSession(session: SessionInfo) {
159
181
  if (onSelectSession) {
160
182
  onSelectSession(session);
@@ -222,10 +244,35 @@ export function ChatHistoryBar({
222
244
  } catch { /* silent */ }
223
245
  }, [projectName]);
224
246
 
225
- // Filter sessions by search query
226
- const filteredSessions = searchQuery.trim()
227
- ? sessions.filter((s) => (s.title || "").toLowerCase().includes(searchQuery.toLowerCase()))
228
- : sessions;
247
+ const handleTagChanged = useCallback((sid: string, tag: { id: number; name: string; color: string } | null) => {
248
+ setSessions((prev) => prev.map((s) => s.id === sid ? { ...s, tag } : s));
249
+ loadTags(); // Refetch counts from API for accuracy
250
+ }, [loadTags]);
251
+
252
+ // Keyboard shortcuts: 1-9 to assign tags to current session
253
+ useEffect(() => {
254
+ if (activePanel !== "history") return;
255
+ const handler = (e: KeyboardEvent) => {
256
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
257
+ const num = parseInt(e.key);
258
+ if (num >= 1 && num <= projectTags.length && sessionId) {
259
+ const tag = projectTags[num - 1];
260
+ if (tag) {
261
+ api.patch(`${projectUrl(projectName)}/chat/sessions/${sessionId}/tag`, { tagId: tag.id }).catch(() => {});
262
+ handleTagChanged(sessionId, { id: tag.id, name: tag.name, color: tag.color });
263
+ }
264
+ }
265
+ };
266
+ window.addEventListener("keydown", handler);
267
+ return () => window.removeEventListener("keydown", handler);
268
+ }, [activePanel, projectTags, sessionId, projectName, handleTagChanged]);
269
+
270
+ // Filter sessions by search query + tag
271
+ const filteredSessions = sessions.filter((s) => {
272
+ if (searchQuery.trim() && !(s.title || "").toLowerCase().includes(searchQuery.toLowerCase())) return false;
273
+ if (selectedTagId !== null && s.tag?.id !== selectedTagId) return false;
274
+ return true;
275
+ });
229
276
 
230
277
  // Usage badge display — only meaningful for Claude (SDK) provider
231
278
  const isClaudeProvider = !providerId || providerId === "claude";
@@ -368,6 +415,45 @@ export function ChatHistoryBar({
368
415
  </button>
369
416
  </div>
370
417
 
418
+ {/* Tag filter chips */}
419
+ {projectTags.length > 0 && (
420
+ <div className="flex items-center gap-1 px-2 py-1 overflow-x-auto border-b border-border/30 scrollbar-none">
421
+ <button
422
+ onClick={() => setSelectedTagId(null)}
423
+ className={`shrink-0 rounded-md border px-2 py-1 text-[10px] transition-colors ${
424
+ selectedTagId === null ? "bg-primary/20 border-primary text-primary" : "border-border bg-surface text-text-secondary"
425
+ }`}
426
+ >All ({sessions.length})</button>
427
+ {projectTags.map((tag) => (
428
+ <button
429
+ key={tag.id}
430
+ onClick={() => setSelectedTagId(selectedTagId === tag.id ? null : tag.id)}
431
+ className={`shrink-0 flex items-center gap-1 rounded-md border px-2 py-1 text-[10px] transition-colors ${
432
+ selectedTagId === tag.id ? "border-current" : "border-border bg-surface"
433
+ }`}
434
+ style={selectedTagId === tag.id ? { backgroundColor: tag.color + "20", color: tag.color, borderColor: tag.color } : undefined}
435
+ >
436
+ <span className="size-2 rounded-full shrink-0" style={{ backgroundColor: tag.color }} />
437
+ {tag.name} ({tagCounts[tag.id] ?? 0})
438
+ </button>
439
+ ))}
440
+ <button
441
+ onClick={() => setShowTagSettings(!showTagSettings)}
442
+ className={`shrink-0 p-1 rounded transition-colors ${showTagSettings ? "text-primary bg-primary/10" : "text-text-subtle hover:text-text-secondary"}`}
443
+ title="Manage tags"
444
+ >
445
+ <Tags className="size-3" />
446
+ </button>
447
+ </div>
448
+ )}
449
+
450
+ {/* Tag management panel (inline) */}
451
+ {showTagSettings && (
452
+ <div className="border-b border-border/30 px-2 py-2 max-h-[180px] overflow-y-auto bg-surface-elevated/50">
453
+ <TagSettingsSection projectName={projectName} onTagsChanged={loadTags} />
454
+ </div>
455
+ )}
456
+
371
457
  <div className="max-h-[200px] overflow-y-auto">
372
458
  {loading && sessions.length === 0 ? (
373
459
  <div className="flex items-center justify-center py-3">
@@ -380,11 +466,23 @@ export function ChatHistoryBar({
380
466
  ) : (
381
467
  <>
382
468
  {filteredSessions.map((session) => (
383
- <div
469
+ <SessionContextMenu
384
470
  key={session.id}
471
+ session={session}
472
+ projectName={projectName}
473
+ projectTags={projectTags}
474
+ onTogglePin={togglePin}
475
+ onStartEditing={startEditing}
476
+ onDeleteSession={deleteSession}
477
+ onTagChanged={handleTagChanged}
478
+ >
479
+ <div
385
480
  className="flex items-center gap-2 w-full px-3 py-1.5 text-left hover:bg-surface-elevated transition-colors group"
386
481
  >
387
482
  <ProviderBadge providerId={session.providerId} />
483
+ {session.tag && (
484
+ <span className="size-2 rounded-full shrink-0" style={{ backgroundColor: session.tag.color }} title={session.tag.name} />
485
+ )}
388
486
  {editingId === session.id ? (
389
487
  <form
390
488
  className="flex items-center gap-1 flex-1 min-w-0"
@@ -450,6 +548,7 @@ export function ChatHistoryBar({
450
548
  <span className="text-[10px] text-text-subtle shrink-0 w-10 text-right">{formatDate(session.updatedAt)}</span>
451
549
  )}
452
550
  </div>
551
+ </SessionContextMenu>
453
552
  ))}
454
553
  {hasMore && !searchQuery && (
455
554
  <button
@@ -1,6 +1,8 @@
1
1
  import { useState, useEffect, useCallback } from "react";
2
2
  import { Bot, ChevronDown, ChevronUp, MessageSquare, Pin, PinOff } from "lucide-react";
3
3
  import { api, projectUrl } from "@/lib/api-client";
4
+ import { useProjectTags, TagChipBar } from "./tag-filter-chips";
5
+ import { SessionContextMenu } from "./session-context-menu";
4
6
  import type { SessionInfo } from "../../../types/chat";
5
7
 
6
8
  const MAX_RECENT_SESSIONS = 5;
@@ -33,6 +35,8 @@ export function ChatWelcome({ projectName, onSelectSession }: ChatWelcomeProps)
33
35
  const [sessions, setSessions] = useState<SessionInfo[]>([]);
34
36
  const [loading, setLoading] = useState(false);
35
37
  const [showAll, setShowAll] = useState(false);
38
+ const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
39
+ const { projectTags, tagCounts, loadTags } = useProjectTags(projectName);
36
40
 
37
41
  const loadSessions = useCallback(async () => {
38
42
  if (!projectName) return;
@@ -72,41 +76,58 @@ export function ChatWelcome({ projectName, onSelectSession }: ChatWelcomeProps)
72
76
  }
73
77
  }, [projectName]);
74
78
 
75
- const pinnedSessions = sessions.filter((s) => s.pinned);
76
- const allRecentSessions = sessions.filter((s) => !s.pinned);
79
+ const handleTagChanged = useCallback((sid: string, tag: { id: number; name: string; color: string } | null) => {
80
+ setSessions((prev) => prev.map((s) => s.id === sid ? { ...s, tag } : s));
81
+ loadTags();
82
+ }, [loadTags]);
83
+
84
+ const filtered = selectedTagId !== null ? sessions.filter((s) => s.tag?.id === selectedTagId) : sessions;
85
+ const pinnedSessions = filtered.filter((s) => s.pinned);
86
+ const allRecentSessions = filtered.filter((s) => !s.pinned);
77
87
  const recentSessions = showAll ? allRecentSessions : allRecentSessions.slice(0, MAX_RECENT_SESSIONS);
78
88
  const hasMore = allRecentSessions.length > MAX_RECENT_SESSIONS;
79
89
 
80
90
  function renderSessionRow(session: SessionInfo) {
81
91
  return (
82
- <button
92
+ <SessionContextMenu
83
93
  key={session.id}
84
- onClick={() => onSelectSession(session)}
85
- 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"
94
+ session={session}
95
+ projectName={projectName}
96
+ projectTags={projectTags}
97
+ onTogglePin={togglePin}
98
+ onTagChanged={handleTagChanged}
86
99
  >
87
- <MessageSquare className="size-3.5 shrink-0 text-text-subtle" />
88
- <span className="flex-1 min-w-0 text-xs font-medium truncate text-text-primary">
89
- {session.title || "Untitled"}
90
- </span>
91
- {session.updatedAt && (
92
- <span className="text-[10px] text-text-subtle shrink-0">
93
- {formatRelativeDate(session.updatedAt)}
94
- </span>
95
- )}
96
- <span
97
- role="button"
98
- tabIndex={0}
99
- onClick={(e) => togglePin(e, session)}
100
- className={`p-1 rounded transition-colors shrink-0 ${
101
- session.pinned
102
- ? "text-primary hover:text-primary/70"
103
- : "text-text-subtle can-hover:opacity-0 can-hover:group-hover:opacity-100 hover:text-text-primary"
104
- }`}
105
- aria-label={session.pinned ? "Unpin session" : "Pin session"}
100
+ <button
101
+ onClick={() => onSelectSession(session)}
102
+ 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"
106
103
  >
107
- {session.pinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
108
- </span>
109
- </button>
104
+ <MessageSquare className="size-3.5 shrink-0 text-text-subtle" />
105
+ {session.tag && (
106
+ <span className="size-2 rounded-full shrink-0" style={{ backgroundColor: session.tag.color }} title={session.tag.name} />
107
+ )}
108
+ <span className="flex-1 min-w-0 text-xs font-medium truncate text-text-primary">
109
+ {session.title || "Untitled"}
110
+ </span>
111
+ {session.updatedAt && (
112
+ <span className="text-[10px] text-text-subtle shrink-0">
113
+ {formatRelativeDate(session.updatedAt)}
114
+ </span>
115
+ )}
116
+ <span
117
+ role="button"
118
+ tabIndex={0}
119
+ onClick={(e) => togglePin(e, session)}
120
+ className={`p-1 rounded transition-colors shrink-0 ${
121
+ session.pinned
122
+ ? "text-primary hover:text-primary/70"
123
+ : "text-text-subtle can-hover:opacity-0 can-hover:group-hover:opacity-100 hover:text-text-primary"
124
+ }`}
125
+ aria-label={session.pinned ? "Unpin session" : "Pin session"}
126
+ >
127
+ {session.pinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
128
+ </span>
129
+ </button>
130
+ </SessionContextMenu>
110
131
  );
111
132
  }
112
133
 
@@ -117,6 +138,12 @@ export function ChatWelcome({ projectName, onSelectSession }: ChatWelcomeProps)
117
138
  <p className="text-sm">Send a message to start a new conversation</p>
118
139
  </div>
119
140
 
141
+ {!loading && sessions.length > 0 && (
142
+ <div className="w-full max-w-sm px-4">
143
+ <TagChipBar projectTags={projectTags} tagCounts={tagCounts} totalCount={sessions.length} selectedTagId={selectedTagId} onSelect={setSelectedTagId} />
144
+ </div>
145
+ )}
146
+
120
147
  {!loading && pinnedSessions.length > 0 && (
121
148
  <div className="flex flex-col gap-2 w-full max-w-sm px-4">
122
149
  <p className="text-xs text-text-subtle text-center">Pinned</p>
@@ -0,0 +1,101 @@
1
+ import { useCallback } from "react";
2
+ import { Check, Pin, PinOff, Pencil, Trash2, Tag } from "lucide-react";
3
+ import {
4
+ ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem,
5
+ ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent,
6
+ ContextMenuSeparator,
7
+ } from "@/components/ui/context-menu";
8
+ import { api, projectUrl } from "@/lib/api-client";
9
+ import type { SessionInfo, ProjectTag } from "../../../types/chat";
10
+
11
+ interface SessionContextMenuProps {
12
+ session: SessionInfo;
13
+ projectName: string;
14
+ projectTags: ProjectTag[];
15
+ children: React.ReactNode;
16
+ onTogglePin: (e: React.MouseEvent, session: SessionInfo) => void;
17
+ onStartEditing?: (session: SessionInfo, e: React.MouseEvent) => void;
18
+ onDeleteSession?: (e: React.MouseEvent, session: SessionInfo) => void;
19
+ onTagChanged: (sessionId: string, tag: { id: number; name: string; color: string } | null) => void;
20
+ }
21
+
22
+ export function SessionContextMenu({
23
+ session, projectName, projectTags, children,
24
+ onTogglePin, onStartEditing, onDeleteSession, onTagChanged,
25
+ }: SessionContextMenuProps) {
26
+ const assignTag = useCallback(async (tagId: number | null) => {
27
+ try {
28
+ if (tagId) {
29
+ await api.patch(`${projectUrl(projectName)}/chat/sessions/${session.id}/tag`, { tagId });
30
+ const tag = projectTags.find((t) => t.id === tagId);
31
+ if (tag) onTagChanged(session.id, { id: tag.id, name: tag.name, color: tag.color });
32
+ } else {
33
+ await api.del(`${projectUrl(projectName)}/chat/sessions/${session.id}/tag`);
34
+ onTagChanged(session.id, null);
35
+ }
36
+ } catch { /* silent */ }
37
+ }, [session.id, projectName, projectTags, onTagChanged]);
38
+
39
+ return (
40
+ <ContextMenu>
41
+ <ContextMenuTrigger asChild>
42
+ {children}
43
+ </ContextMenuTrigger>
44
+ <ContextMenuContent>
45
+ <ContextMenuItem
46
+ onClick={(e) => onTogglePin(e as unknown as React.MouseEvent, session)}
47
+ >
48
+ {session.pinned ? <PinOff className="size-3.5 mr-2" /> : <Pin className="size-3.5 mr-2" />}
49
+ {session.pinned ? "Unpin" : "Pin"}
50
+ </ContextMenuItem>
51
+ {onStartEditing && (
52
+ <ContextMenuItem
53
+ onClick={(e) => onStartEditing(session, e as unknown as React.MouseEvent)}
54
+ >
55
+ <Pencil className="size-3.5 mr-2" />
56
+ Rename
57
+ </ContextMenuItem>
58
+ )}
59
+
60
+ {projectTags.length > 0 && (
61
+ <ContextMenuSub>
62
+ <ContextMenuSubTrigger>
63
+ <Tag className="size-3.5 mr-2" />
64
+ Set Tag
65
+ </ContextMenuSubTrigger>
66
+ <ContextMenuSubContent>
67
+ {projectTags.map((tag) => (
68
+ <ContextMenuItem key={tag.id} onClick={() => assignTag(tag.id)}>
69
+ <span className="size-2.5 rounded-full mr-2 shrink-0" style={{ backgroundColor: tag.color }} />
70
+ {tag.name}
71
+ {session.tag?.id === tag.id && <Check className="size-3 ml-auto" />}
72
+ </ContextMenuItem>
73
+ ))}
74
+ {session.tag && (
75
+ <>
76
+ <ContextMenuSeparator />
77
+ <ContextMenuItem onClick={() => assignTag(null)}>
78
+ Remove tag
79
+ </ContextMenuItem>
80
+ </>
81
+ )}
82
+ </ContextMenuSubContent>
83
+ </ContextMenuSub>
84
+ )}
85
+
86
+ {onDeleteSession && (
87
+ <>
88
+ <ContextMenuSeparator />
89
+ <ContextMenuItem
90
+ className="text-red-500 focus:text-red-500"
91
+ onClick={(e) => onDeleteSession(e as unknown as React.MouseEvent, session)}
92
+ >
93
+ <Trash2 className="size-3.5 mr-2" />
94
+ Delete
95
+ </ContextMenuItem>
96
+ </>
97
+ )}
98
+ </ContextMenuContent>
99
+ </ContextMenu>
100
+ );
101
+ }
@@ -99,6 +99,9 @@ export function SessionPicker({
99
99
  <div className="flex flex-col min-w-0 flex-1">
100
100
  <span className="flex items-center gap-1.5 truncate text-xs font-medium">
101
101
  <ProviderBadge providerId={session.providerId} />
102
+ {session.tag && (
103
+ <span className="size-2 rounded-full shrink-0" style={{ backgroundColor: session.tag.color }} title={session.tag.name} />
104
+ )}
102
105
  {session.title}
103
106
  </span>
104
107
  <span className="text-xs text-text-subtle">
@@ -0,0 +1,58 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { api, projectUrl } from "@/lib/api-client";
3
+ import type { ProjectTag } from "../../../types/chat";
4
+
5
+ /** Fetch project tags + counts; returns state for filter chips */
6
+ export function useProjectTags(projectName: string | undefined) {
7
+ const [projectTags, setProjectTags] = useState<ProjectTag[]>([]);
8
+ const [tagCounts, setTagCounts] = useState<Record<number, number>>({});
9
+
10
+ const loadTags = useCallback(async () => {
11
+ if (!projectName) return;
12
+ try {
13
+ const data = await api.get<{ tags: ProjectTag[]; counts: Record<number, number> }>(
14
+ `${projectUrl(projectName)}/tags`,
15
+ );
16
+ setProjectTags(data.tags);
17
+ setTagCounts(data.counts);
18
+ } catch { /* silent */ }
19
+ }, [projectName]);
20
+
21
+ useEffect(() => { loadTags(); }, [loadTags]);
22
+
23
+ return { projectTags, tagCounts, loadTags };
24
+ }
25
+
26
+ /** Horizontal chip bar for filtering sessions by tag */
27
+ export function TagChipBar({ projectTags, tagCounts, totalCount, selectedTagId, onSelect }: {
28
+ projectTags: ProjectTag[];
29
+ tagCounts: Record<number, number>;
30
+ totalCount: number;
31
+ selectedTagId: number | null;
32
+ onSelect: (tagId: number | null) => void;
33
+ }) {
34
+ if (projectTags.length === 0) return null;
35
+ return (
36
+ <div className="flex items-center gap-1 px-2 py-1.5 overflow-x-auto scrollbar-none">
37
+ <button
38
+ onClick={() => onSelect(null)}
39
+ className={`shrink-0 rounded-md border px-2 py-1 text-[10px] transition-colors ${
40
+ selectedTagId === null ? "bg-primary/20 border-primary text-primary" : "border-border bg-surface text-text-secondary hover:bg-surface-elevated"
41
+ }`}
42
+ >All ({totalCount})</button>
43
+ {projectTags.map((tag) => (
44
+ <button
45
+ key={tag.id}
46
+ onClick={() => onSelect(selectedTagId === tag.id ? null : tag.id)}
47
+ className={`shrink-0 flex items-center gap-1 rounded-md border px-2 py-1 text-[10px] transition-colors ${
48
+ selectedTagId === tag.id ? "border-current" : "border-border bg-surface hover:bg-surface-elevated"
49
+ }`}
50
+ style={selectedTagId === tag.id ? { backgroundColor: tag.color + "20", color: tag.color, borderColor: tag.color } : undefined}
51
+ >
52
+ <span className="size-2 rounded-full shrink-0" style={{ backgroundColor: tag.color }} />
53
+ {tag.name} ({tagCounts[tag.id] ?? 0})
54
+ </button>
55
+ ))}
56
+ </div>
57
+ );
58
+ }
@@ -1,6 +1,5 @@
1
1
  import { useRef, useEffect, useState, useCallback } from "react";
2
2
  import { useExtensionStore } from "@/stores/extension-store";
3
- import { useTabStore } from "@/stores/tab-store";
4
3
  import { getAuthToken } from "@/lib/api-client";
5
4
  import { Loader2 } from "lucide-react";
6
5
 
@@ -30,9 +29,10 @@ interface ExtensionWebviewProps {
30
29
  export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
31
30
  const panelId = metadata?.panelId as string | undefined;
32
31
  const viewType = metadata?.viewType as string | undefined;
33
- const currentProject = useTabStore((s) => s.currentProject);
34
- // Prefer currentProject (reflects URL/active project) over stale tab metadata
35
- const projectName = currentProject || (metadata?.projectName as string | undefined) || undefined;
32
+ // Use the tab's own project name (frozen at creation time) NOT the global
33
+ // currentProject. Old project's ExtensionWebview must not react to project
34
+ // switches, which would dispatch commands for the wrong project.
35
+ const projectName = (metadata?.projectName as string | undefined) || undefined;
36
36
  const [timedOut, setTimedOut] = useState(false);
37
37
  // Track whether extensions are activated (contributions received from WS)
38
38
  const extensionsReady = useExtensionStore((s) => s.contributions !== null);
@@ -62,12 +62,9 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
62
62
 
63
63
  // On reload: resolve project path and dispatch command once.
64
64
  // Wait for extensions to be activated (contributions received) before dispatching.
65
- // Skip if project-sync effect already dispatched for this project
66
- // (panel is briefly undefined during dispose→recreate transition).
67
65
  useEffect(() => {
68
66
  if (panel || !viewType || !extensionsReady) return;
69
- // If we already dispatched for this project (via project-sync effect),
70
- // don't dispatch again — the panel is just temporarily missing.
67
+ // Already dispatched for this project panel is just temporarily missing
71
68
  if (projectName && projectName === prevProjectRef.current) return;
72
69
  if (projectName) prevProjectRef.current = projectName;
73
70
  const command = viewType.includes(".") ? viewType : `${viewType}.view`;
@@ -94,31 +91,6 @@ export function ExtensionWebview({ metadata }: ExtensionWebviewProps) {
94
91
  return () => { cancelled = true; };
95
92
  }, [panel, viewType, projectName, extensionsReady]);
96
93
 
97
- // When panel exists, ensure correct project is loaded.
98
- // On mount: dispatch command so extension can reload if project differs.
99
- // On project switch: dispatch command with new project path.
100
- // Extension deduplicates same-project calls (noop if already correct).
101
- useEffect(() => {
102
- if (!panel || !viewType || !projectName) return;
103
- // Skip if we already dispatched for this project
104
- if (projectName === prevProjectRef.current) return;
105
- prevProjectRef.current = projectName;
106
- const command = viewType.includes(".") ? viewType : `${viewType}.view`;
107
- (async () => {
108
- try {
109
- const token = getAuthToken();
110
- const res = await fetch("/api/projects", token ? { headers: { Authorization: `Bearer ${token}` } } : {});
111
- const json = await res.json() as { ok: boolean; data?: { name: string; path: string }[] };
112
- const match = json.data?.find((p) => p.name === projectName);
113
- if (match) {
114
- window.dispatchEvent(new CustomEvent("ext:command:execute", {
115
- detail: { command, args: [match.path] },
116
- }));
117
- }
118
- } catch {}
119
- })();
120
- }, [panel, viewType, projectName]);
121
-
122
94
  // Check activation errors for this extension
123
95
  const extensionId = metadata?.extensionId as string | undefined;
124
96
  const activationError = useExtensionStore((s) => {
@@ -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>