@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.
- package/CHANGELOG.md +22 -0
- package/dist/web/assets/{ai-settings-section-L6XAmZEP.js → ai-settings-section-BHdBBJtS.js} +1 -1
- package/dist/web/assets/{audio-preview-VMboGrIH.js → audio-preview-D4AxF10w.js} +1 -1
- package/dist/web/assets/chat-tab-Bq2hmJ-B.js +12 -0
- package/dist/web/assets/code-editor-CMcDjype.js +8 -0
- package/dist/web/assets/{conflict-editor-943WUefe.js → conflict-editor-Br-ugFiK.js} +1 -1
- package/dist/web/assets/{csv-preview-BEBJD4a_.js → csv-preview-HMSavgBb.js} +1 -1
- package/dist/web/assets/{database-viewer-BV0Ebp0z.js → database-viewer-DxP0GmQK.js} +2 -2
- package/dist/web/assets/{diff-viewer-B3gAWXgA.js → diff-viewer-oEyE9UwV.js} +1 -1
- package/dist/web/assets/dist-D7KGU7Vl.js +1 -0
- package/dist/web/assets/extension-webview-CVqfQGjg.js +3 -0
- package/dist/web/assets/{image-preview-BEiYtg6_.js → image-preview-CY3sVd25.js} +1 -1
- package/dist/web/assets/index-BDRoldC9.js +23 -0
- package/dist/web/assets/index-CDSox8V2.css +2 -0
- package/dist/web/assets/{input-ClhO__YM.js → input-Dk49gO8E.js} +1 -1
- package/dist/web/assets/{markdown-renderer-t1ZBKbXZ.js → markdown-renderer-DwqWhkri.js} +1 -1
- package/dist/web/assets/{pdf-preview-CjfQxXE5.js → pdf-preview-Cl95qWE_.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-BZmfg410.js → port-forwarding-tab-iJ3MAjXa.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CSTO0jc2.js → postgres-viewer-Do_w0Cji.js} +2 -2
- package/dist/web/assets/{scroll-area-DW7L4Gnc.js → scroll-area-BEllam7_.js} +1 -1
- package/dist/web/assets/settings-tab-DyBeLmUh.js +1 -0
- package/dist/web/assets/{sqlite-viewer-D0oWgepE.js → sqlite-viewer-oZkGJfW2.js} +1 -1
- package/dist/web/assets/{terminal-tab-WBPZXu12.js → terminal-tab-UoDiWvzG.js} +1 -1
- package/dist/web/assets/{vendor-ui-B-T_damt.js → vendor-ui-B-89Uj8i.js} +1 -1
- package/dist/web/assets/{video-preview-BcMa4tim.js → video-preview-3MbkDYcA.js} +1 -1
- package/dist/web/index.html +7 -7
- package/dist/web/sw.js +1 -1
- package/docs/project-changelog.md +56 -0
- package/docs/system-architecture.md +10 -1
- package/package.json +1 -1
- package/src/server/routes/chat.ts +57 -2
- package/src/server/routes/project-scoped.ts +2 -0
- package/src/server/routes/tag-routes.ts +93 -0
- package/src/services/db.service.ts +35 -1
- package/src/services/project.service.ts +2 -0
- package/src/services/supervisor.ts +7 -2
- package/src/services/tag.service.ts +114 -0
- package/src/types/chat.ts +9 -0
- package/src/web/components/chat/chat-history-bar.tsx +106 -7
- package/src/web/components/chat/chat-welcome.tsx +54 -27
- package/src/web/components/chat/session-context-menu.tsx +101 -0
- package/src/web/components/chat/session-picker.tsx +3 -0
- package/src/web/components/chat/tag-filter-chips.tsx +58 -0
- package/src/web/components/extensions/extension-webview.tsx +5 -33
- package/src/web/components/layout/editor-panel.tsx +53 -26
- package/src/web/components/layout/upgrade-banner.tsx +47 -37
- package/src/web/components/settings/tag-settings-section.tsx +167 -0
- package/src/web/hooks/use-extension-ws.ts +7 -2
- package/src/web/styles/globals.css +14 -0
- package/dist/web/assets/chat-tab-DfO2rHO8.js +0 -12
- package/dist/web/assets/code-editor-BU7NX_SZ.js +0 -8
- package/dist/web/assets/dist-C5IgeqrV.js +0 -1
- package/dist/web/assets/extension-webview-C8rdBYLl.js +0 -3
- package/dist/web/assets/index-B0V_IYbX.css +0 -2
- package/dist/web/assets/index-CBsOxcqb.js +0 -23
- 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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
<
|
|
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
|
|
76
|
-
|
|
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
|
-
<
|
|
92
|
+
<SessionContextMenu
|
|
83
93
|
key={session.id}
|
|
84
|
-
|
|
85
|
-
|
|
94
|
+
session={session}
|
|
95
|
+
projectName={projectName}
|
|
96
|
+
projectTags={projectTags}
|
|
97
|
+
onTogglePin={togglePin}
|
|
98
|
+
onTagChanged={handleTagChanged}
|
|
86
99
|
>
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
34
|
-
//
|
|
35
|
-
|
|
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
|
-
//
|
|
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
|
|
180
|
-
|
|
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
|
-
<
|
|
196
|
+
<SessionContextMenu
|
|
187
197
|
key={session.id}
|
|
188
|
-
|
|
189
|
-
|
|
198
|
+
session={session}
|
|
199
|
+
projectName={activeProject!.name}
|
|
200
|
+
projectTags={projectTags}
|
|
201
|
+
onTogglePin={togglePin}
|
|
202
|
+
onTagChanged={handleTagChanged}
|
|
190
203
|
>
|
|
191
|
-
<
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|