@atercates/claude-deck 0.2.4 → 0.2.5

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 (38) hide show
  1. package/app/api/sessions/[id]/fork/route.ts +0 -1
  2. package/app/api/sessions/[id]/route.ts +0 -5
  3. package/app/api/sessions/[id]/summarize/route.ts +2 -3
  4. package/app/api/sessions/route.ts +2 -11
  5. package/app/page.tsx +4 -45
  6. package/components/ClaudeProjects/ClaudeProjectCard.tsx +19 -31
  7. package/components/ClaudeProjects/ClaudeSessionCard.tsx +20 -31
  8. package/components/NewSessionDialog/AdvancedSettings.tsx +3 -12
  9. package/components/NewSessionDialog/NewSessionDialog.types.ts +0 -10
  10. package/components/NewSessionDialog/ProjectSelector.tsx +2 -7
  11. package/components/NewSessionDialog/hooks/useNewSessionForm.ts +3 -36
  12. package/components/NewSessionDialog/index.tsx +0 -7
  13. package/components/Projects/index.ts +0 -1
  14. package/components/SessionList/hooks/useSessionListMutations.ts +0 -35
  15. package/components/views/DesktopView.tsx +1 -6
  16. package/components/views/MobileView.tsx +6 -1
  17. package/components/views/types.ts +1 -1
  18. package/data/sessions/index.ts +0 -1
  19. package/data/sessions/queries.ts +1 -27
  20. package/hooks/useSessions.ts +0 -12
  21. package/lib/db/queries.ts +4 -64
  22. package/lib/db/types.ts +0 -8
  23. package/lib/orchestration.ts +10 -15
  24. package/lib/providers/registry.ts +2 -56
  25. package/lib/providers.ts +19 -100
  26. package/lib/status-monitor.ts +40 -15
  27. package/package.json +1 -1
  28. package/server.ts +1 -1
  29. package/app/api/groups/[...path]/route.ts +0 -136
  30. package/app/api/groups/route.ts +0 -93
  31. package/components/NewSessionDialog/AgentSelector.tsx +0 -37
  32. package/components/Projects/ProjectCard.tsx +0 -276
  33. package/components/TmuxSessions.tsx +0 -132
  34. package/data/groups/index.ts +0 -1
  35. package/data/groups/mutations.ts +0 -95
  36. package/hooks/useGroups.ts +0 -37
  37. package/hooks/useKeybarVisibility.ts +0 -42
  38. package/lib/claude/process-manager.ts +0 -278
@@ -29,8 +29,11 @@ const TICK_INTERVAL_MS = 3000;
29
29
  const SESSION_NAME_CACHE_TTL = 10_000;
30
30
  const UUID_PATTERN = getManagedSessionPattern();
31
31
 
32
- // Cache for session display names (summary from SDK)
33
- const sessionNameCache = new Map<string, { name: string; cachedAt: number }>();
32
+ // Cache for session metadata (name + cwd from SDK)
33
+ const sessionMetaCache = new Map<
34
+ string,
35
+ { name: string; cwd: string | null; cachedAt: number }
36
+ >();
34
37
 
35
38
  // --- Types ---
36
39
 
@@ -45,6 +48,7 @@ interface StateFile {
45
48
 
46
49
  export interface SessionStatusSnapshot {
47
50
  sessionName: string;
51
+ cwd: string | null;
48
52
  status: SessionStatus;
49
53
  lastLine: string;
50
54
  waitingContext?: string;
@@ -103,28 +107,35 @@ async function listTmuxSessions(): Promise<Map<string, string>> {
103
107
  }
104
108
  }
105
109
 
106
- // --- Session display name resolution ---
110
+ // --- Session metadata resolution ---
107
111
 
108
- async function resolveSessionDisplayName(sessionId: string): Promise<string> {
109
- const cached = sessionNameCache.get(sessionId);
112
+ async function resolveSessionMeta(
113
+ sessionId: string
114
+ ): Promise<{ name: string; cwd: string | null }> {
115
+ const cached = sessionMetaCache.get(sessionId);
110
116
  if (cached && Date.now() - cached.cachedAt < SESSION_NAME_CACHE_TTL) {
111
- return cached.name;
117
+ return { name: cached.name, cwd: cached.cwd };
112
118
  }
113
119
 
114
120
  try {
115
121
  const info = await getSessionInfo(sessionId);
116
122
  if (info) {
117
123
  const name = info.customTitle || info.summary || sessionId.slice(0, 8);
118
- sessionNameCache.set(sessionId, { name, cachedAt: Date.now() });
119
- return name;
124
+ const cwd = info.cwd || null;
125
+ sessionMetaCache.set(sessionId, { name, cwd, cachedAt: Date.now() });
126
+ return { name, cwd };
120
127
  }
121
128
  } catch {
122
129
  // SDK lookup failed — use short ID
123
130
  }
124
131
 
125
132
  const fallback = sessionId.slice(0, 8);
126
- sessionNameCache.set(sessionId, { name: fallback, cachedAt: Date.now() });
127
- return fallback;
133
+ sessionMetaCache.set(sessionId, {
134
+ name: fallback,
135
+ cwd: null,
136
+ cachedAt: Date.now(),
137
+ });
138
+ return { name: fallback, cwd: null };
128
139
  }
129
140
 
130
141
  // --- Snapshot building ---
@@ -137,17 +148,18 @@ async function buildSnapshot(
137
148
 
138
149
  const entries = await Promise.all(
139
150
  [...tmuxSessions.entries()].map(async ([sessionId, tmuxName]) => {
140
- const displayName = await resolveSessionDisplayName(sessionId);
141
- return { sessionId, tmuxName, displayName };
151
+ const meta = await resolveSessionMeta(sessionId);
152
+ return { sessionId, tmuxName, ...meta };
142
153
  })
143
154
  );
144
155
 
145
- for (const { sessionId, tmuxName, displayName } of entries) {
156
+ for (const { sessionId, tmuxName, name, cwd } of entries) {
146
157
  const agentType = getProviderIdFromSessionName(tmuxName) || "claude";
147
158
  const state = stateFiles.get(sessionId);
148
159
 
149
160
  snap[sessionId] = {
150
- sessionName: displayName,
161
+ sessionName: name,
162
+ cwd,
151
163
  status: state?.status || "idle",
152
164
  lastLine: state?.lastLine || "",
153
165
  ...(state?.status === "waiting" && state.waitingContext
@@ -158,6 +170,19 @@ async function buildSnapshot(
158
170
  };
159
171
  }
160
172
 
173
+ // Filter out hidden sessions
174
+ try {
175
+ const db = getDb();
176
+ const hiddenRows = db
177
+ .prepare("SELECT item_id FROM hidden_items WHERE item_type = 'session'")
178
+ .all() as { item_id: string }[];
179
+ for (const { item_id } of hiddenRows) {
180
+ delete snap[item_id];
181
+ }
182
+ } catch {
183
+ // DB errors shouldn't break the monitor
184
+ }
185
+
161
186
  return snap;
162
187
  }
163
188
 
@@ -250,7 +275,7 @@ export function onStateFileChange(): void {
250
275
  }
251
276
 
252
277
  export function invalidateSessionName(sessionId: string): void {
253
- sessionNameCache.delete(sessionId);
278
+ sessionMetaCache.delete(sessionId);
254
279
  }
255
280
 
256
281
  export function startStatusMonitor(): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atercates/claude-deck",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "claude-deck": "./scripts/claude-deck"
package/server.ts CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  } from "./lib/auth";
16
16
 
17
17
  const dev = process.env.NODE_ENV !== "production";
18
- const hostname = "0.0.0.0";
18
+ const hostname = process.env.HOST || (dev ? "localhost" : "0.0.0.0");
19
19
 
20
20
  // Support: npm run dev -- -p 3012
21
21
  const pFlagIndex = process.argv.indexOf("-p");
@@ -1,136 +0,0 @@
1
- import { NextResponse } from "next/server";
2
- import { queries, type Group } from "@/lib/db";
3
-
4
- // GET /api/groups/[...path] - Get a single group
5
- export async function GET(
6
- request: Request,
7
- { params }: { params: Promise<{ path: string[] }> }
8
- ) {
9
- const { path: pathParts } = await params;
10
- const path = pathParts.join("/");
11
-
12
- try {
13
- const group = await queries.getGroup(path) as Group | undefined;
14
-
15
- if (!group) {
16
- return NextResponse.json({ error: "Group not found" }, { status: 404 });
17
- }
18
-
19
- return NextResponse.json({
20
- group: { ...group, expanded: Boolean(group.expanded) },
21
- });
22
- } catch (error) {
23
- console.error("Error fetching group:", error);
24
- return NextResponse.json(
25
- { error: "Failed to fetch group" },
26
- { status: 500 }
27
- );
28
- }
29
- }
30
-
31
- // PATCH /api/groups/[...path] - Update group (name, expanded, order)
32
- export async function PATCH(
33
- request: Request,
34
- { params }: { params: Promise<{ path: string[] }> }
35
- ) {
36
- const { path: pathParts } = await params;
37
- const path = pathParts.join("/");
38
-
39
- try {
40
- const body = await request.json();
41
- const { name, expanded, sort_order } = body;
42
-
43
- // Check if group exists
44
- const group = await queries.getGroup(path) as Group | undefined;
45
- if (!group) {
46
- return NextResponse.json({ error: "Group not found" }, { status: 404 });
47
- }
48
-
49
- // Protect default group from being renamed
50
- if (path === "sessions" && name !== undefined && name !== "Sessions") {
51
- return NextResponse.json(
52
- { error: "Cannot rename the default group" },
53
- { status: 400 }
54
- );
55
- }
56
-
57
- // Update name
58
- if (name !== undefined) {
59
- await queries.updateGroupName(name, path);
60
- }
61
-
62
- // Update expanded state
63
- if (expanded !== undefined) {
64
- await queries.updateGroupExpanded(!!expanded, path);
65
- }
66
-
67
- // Update sort order
68
- if (sort_order !== undefined) {
69
- await queries.updateGroupOrder(sort_order, path);
70
- }
71
-
72
- const updatedGroup = await queries.getGroup(path) as Group;
73
- return NextResponse.json({
74
- group: { ...updatedGroup, expanded: Boolean(updatedGroup.expanded) },
75
- });
76
- } catch (error) {
77
- console.error("Error updating group:", error);
78
- return NextResponse.json(
79
- { error: "Failed to update group" },
80
- { status: 500 }
81
- );
82
- }
83
- }
84
-
85
- // DELETE /api/groups/[...path] - Delete group (moves sessions to parent or default)
86
- export async function DELETE(
87
- request: Request,
88
- { params }: { params: Promise<{ path: string[] }> }
89
- ) {
90
- const { path: pathParts } = await params;
91
- const path = pathParts.join("/");
92
-
93
- try {
94
- // Protect default group
95
- if (path === "sessions") {
96
- return NextResponse.json(
97
- { error: "Cannot delete the default group" },
98
- { status: 400 }
99
- );
100
- }
101
-
102
- // Check if group exists
103
- const group = await queries.getGroup(path) as Group | undefined;
104
- if (!group) {
105
- return NextResponse.json({ error: "Group not found" }, { status: 404 });
106
- }
107
-
108
- // Find parent group or use default
109
- const pathParts2 = path.split("/");
110
- pathParts2.pop();
111
- const parentPath =
112
- pathParts2.length > 0 ? pathParts2.join("/") : "sessions";
113
-
114
- // Move all sessions in this group to parent
115
- await queries.moveSessionsToGroup(parentPath, path);
116
-
117
- // Also move sessions from any subgroups
118
- const allGroups = await queries.getAllGroups() as Group[];
119
- const subgroups = allGroups.filter((g) => g.path.startsWith(path + "/"));
120
- for (const subgroup of subgroups) {
121
- await queries.moveSessionsToGroup(parentPath, subgroup.path);
122
- await queries.deleteGroup(subgroup.path);
123
- }
124
-
125
- // Delete the group
126
- await queries.deleteGroup(path);
127
-
128
- return NextResponse.json({ success: true, movedTo: parentPath });
129
- } catch (error) {
130
- console.error("Error deleting group:", error);
131
- return NextResponse.json(
132
- { error: "Failed to delete group" },
133
- { status: 500 }
134
- );
135
- }
136
- }
@@ -1,93 +0,0 @@
1
- import { NextResponse } from "next/server";
2
- import { queries, type Group } from "@/lib/db";
3
-
4
- // GET /api/groups - List all groups
5
- export async function GET() {
6
- try {
7
- const groups = await queries.getAllGroups() as Group[];
8
-
9
- // Convert expanded from 0/1 to boolean
10
- const formattedGroups = groups.map((g) => ({
11
- ...g,
12
- expanded: Boolean(g.expanded),
13
- }));
14
-
15
- return NextResponse.json({ groups: formattedGroups });
16
- } catch (error) {
17
- console.error("Error fetching groups:", error);
18
- return NextResponse.json(
19
- { error: "Failed to fetch groups" },
20
- { status: 500 }
21
- );
22
- }
23
- }
24
-
25
- // POST /api/groups - Create a new group
26
- export async function POST(request: Request) {
27
- try {
28
- const body = await request.json();
29
- const { name, parentPath } = body;
30
-
31
- if (!name || typeof name !== "string") {
32
- return NextResponse.json({ error: "Name is required" }, { status: 400 });
33
- }
34
-
35
- // Sanitize name to create path
36
- const sanitizedName = name
37
- .toLowerCase()
38
- .replace(/[^a-z0-9-]/g, "-")
39
- .replace(/-+/g, "-")
40
- .replace(/^-|-$/g, "");
41
-
42
- if (!sanitizedName) {
43
- return NextResponse.json(
44
- { error: "Invalid group name" },
45
- { status: 400 }
46
- );
47
- }
48
-
49
- // Build full path
50
- const path = parentPath ? `${parentPath}/${sanitizedName}` : sanitizedName;
51
-
52
- // Check if group already exists
53
- const existing = await queries.getGroup(path) as Group | undefined;
54
- if (existing) {
55
- return NextResponse.json(
56
- { error: "Group already exists", group: existing },
57
- { status: 409 }
58
- );
59
- }
60
-
61
- // If parent path specified, ensure parent exists
62
- if (parentPath) {
63
- const parent = await queries.getGroup(parentPath) as Group | undefined;
64
- if (!parent) {
65
- return NextResponse.json(
66
- { error: "Parent group does not exist" },
67
- { status: 400 }
68
- );
69
- }
70
- }
71
-
72
- // Get max sort order for new group
73
- const groups = await queries.getAllGroups() as Group[];
74
- const maxOrder = groups.reduce((max, g) => Math.max(max, g.sort_order), 0);
75
-
76
- // Create the group
77
- await queries.createGroup(path, name, maxOrder + 1);
78
-
79
- const newGroup = await queries.getGroup(path) as Group;
80
- return NextResponse.json(
81
- {
82
- group: { ...newGroup, expanded: Boolean(newGroup.expanded) },
83
- },
84
- { status: 201 }
85
- );
86
- } catch (error) {
87
- console.error("Error creating group:", error);
88
- return NextResponse.json(
89
- { error: "Failed to create group" },
90
- { status: 500 }
91
- );
92
- }
93
- }
@@ -1,37 +0,0 @@
1
- import {
2
- Select,
3
- SelectContent,
4
- SelectItem,
5
- SelectTrigger,
6
- SelectValue,
7
- } from "@/components/ui/select";
8
- import type { AgentType } from "@/lib/providers";
9
- import { AGENT_OPTIONS } from "./NewSessionDialog.types";
10
-
11
- interface AgentSelectorProps {
12
- value: AgentType;
13
- onChange: (value: AgentType) => void;
14
- }
15
-
16
- export function AgentSelector({ value, onChange }: AgentSelectorProps) {
17
- return (
18
- <div className="space-y-2">
19
- <label className="text-sm font-medium">Agent</label>
20
- <Select value={value} onValueChange={(v) => onChange(v as AgentType)}>
21
- <SelectTrigger>
22
- <SelectValue />
23
- </SelectTrigger>
24
- <SelectContent>
25
- {AGENT_OPTIONS.map((option) => (
26
- <SelectItem key={option.value} value={option.value}>
27
- <span className="font-medium">{option.label}</span>
28
- <span className="text-muted-foreground ml-2 text-xs">
29
- {option.description}
30
- </span>
31
- </SelectItem>
32
- ))}
33
- </SelectContent>
34
- </Select>
35
- </div>
36
- );
37
- }
@@ -1,276 +0,0 @@
1
- "use client";
2
-
3
- import { useState, useRef, useEffect } from "react";
4
- import { cn } from "@/lib/utils";
5
- import {
6
- ChevronRight,
7
- ChevronDown,
8
- MoreHorizontal,
9
- Settings,
10
- Plus,
11
- Server,
12
- Trash2,
13
- Pencil,
14
- FolderOpen,
15
- Terminal,
16
- } from "lucide-react";
17
- import { Button } from "@/components/ui/button";
18
- import {
19
- DropdownMenu,
20
- DropdownMenuContent,
21
- DropdownMenuItem,
22
- DropdownMenuSeparator,
23
- DropdownMenuTrigger,
24
- } from "@/components/ui/dropdown-menu";
25
- import {
26
- ContextMenu,
27
- ContextMenuContent,
28
- ContextMenuItem,
29
- ContextMenuSeparator,
30
- ContextMenuTrigger,
31
- } from "@/components/ui/context-menu";
32
- import {
33
- Tooltip,
34
- TooltipContent,
35
- TooltipTrigger,
36
- } from "@/components/ui/tooltip";
37
- import type { Project, DevServer } from "@/lib/db";
38
-
39
- interface ProjectCardProps {
40
- project: Project;
41
- sessionCount: number;
42
- runningDevServers?: DevServer[];
43
- onClick?: () => void;
44
- onToggleExpanded?: (expanded: boolean) => void;
45
- onEdit?: () => void;
46
- onNewSession?: () => void;
47
- onOpenTerminal?: () => void;
48
- onStartDevServer?: () => void;
49
- onOpenInEditor?: () => void;
50
- onDelete?: () => void;
51
- onRename?: (newName: string) => void;
52
- }
53
-
54
- export function ProjectCard({
55
- project,
56
- sessionCount,
57
- runningDevServers = [],
58
- onClick,
59
- onToggleExpanded,
60
- onEdit,
61
- onNewSession,
62
- onOpenTerminal,
63
- onStartDevServer,
64
- onOpenInEditor,
65
- onDelete,
66
- onRename,
67
- }: ProjectCardProps) {
68
- const [isEditing, setIsEditing] = useState(false);
69
- const [editName, setEditName] = useState(project.name);
70
- const inputRef = useRef<HTMLInputElement>(null);
71
- const justStartedEditingRef = useRef(false);
72
-
73
- const hasRunningServers = runningDevServers.length > 0;
74
- // Uncategorized can have New Session, Open Terminal, and Rename, but not Edit/Delete/DevServer
75
- const hasActions = project.is_uncategorized
76
- ? onNewSession || onOpenTerminal || onRename
77
- : onEdit ||
78
- onNewSession ||
79
- onOpenTerminal ||
80
- onStartDevServer ||
81
- onDelete ||
82
- onRename;
83
-
84
- useEffect(() => {
85
- if (isEditing && inputRef.current) {
86
- const input = inputRef.current;
87
- // Mark that we just started editing to ignore immediate blur
88
- justStartedEditingRef.current = true;
89
- // Small timeout to ensure input is fully mounted
90
- setTimeout(() => {
91
- input.focus();
92
- input.select();
93
- // Clear the flag after focus is established
94
- setTimeout(() => {
95
- justStartedEditingRef.current = false;
96
- }, 100);
97
- }, 0);
98
- }
99
- }, [isEditing]);
100
-
101
- const handleRename = () => {
102
- // Ignore blur events that happen immediately after starting to edit
103
- if (justStartedEditingRef.current) return;
104
-
105
- if (editName.trim() && editName !== project.name && onRename) {
106
- onRename(editName.trim());
107
- }
108
- setIsEditing(false);
109
- };
110
-
111
- const handleClick = (_e: React.MouseEvent) => {
112
- if (isEditing) return;
113
- onClick?.();
114
- onToggleExpanded?.(!project.expanded);
115
- };
116
-
117
- const renderMenuItems = (isContextMenu: boolean) => {
118
- const MenuItem = isContextMenu ? ContextMenuItem : DropdownMenuItem;
119
- const MenuSeparator = isContextMenu
120
- ? ContextMenuSeparator
121
- : DropdownMenuSeparator;
122
-
123
- return (
124
- <>
125
- {onNewSession && (
126
- <MenuItem onClick={() => onNewSession()}>
127
- <Plus className="mr-2 h-3 w-3" />
128
- New session
129
- </MenuItem>
130
- )}
131
- {onOpenTerminal && (
132
- <MenuItem onClick={() => onOpenTerminal()}>
133
- <Terminal className="mr-2 h-3 w-3" />
134
- Open terminal
135
- </MenuItem>
136
- )}
137
- {onEdit && (
138
- <MenuItem onClick={() => onEdit()}>
139
- <Settings className="mr-2 h-3 w-3" />
140
- Project settings
141
- </MenuItem>
142
- )}
143
- {onRename && (
144
- <MenuItem onClick={() => setIsEditing(true)}>
145
- <Pencil className="mr-2 h-3 w-3" />
146
- Rename
147
- </MenuItem>
148
- )}
149
- {onOpenInEditor && (
150
- <MenuItem onClick={() => onOpenInEditor()}>
151
- <FolderOpen className="mr-2 h-3 w-3" />
152
- Open in editor
153
- </MenuItem>
154
- )}
155
- {onStartDevServer && (
156
- <>
157
- <MenuSeparator />
158
- <MenuItem onClick={() => onStartDevServer()}>
159
- <Server className="mr-2 h-3 w-3" />
160
- Start dev server
161
- </MenuItem>
162
- </>
163
- )}
164
- {onDelete && (
165
- <>
166
- <MenuSeparator />
167
- <MenuItem
168
- onClick={() => onDelete()}
169
- className="text-red-500 focus:text-red-500"
170
- >
171
- <Trash2 className="mr-2 h-3 w-3" />
172
- Delete project
173
- </MenuItem>
174
- </>
175
- )}
176
- </>
177
- );
178
- };
179
-
180
- const cardContent = (
181
- <div
182
- onClick={handleClick}
183
- className={cn(
184
- "group flex cursor-pointer items-center gap-1 rounded-md px-2 py-1.5",
185
- "min-h-[36px] md:min-h-[28px]",
186
- "hover:bg-accent/50"
187
- )}
188
- >
189
- {/* Expand/collapse toggle */}
190
- <button className="flex-shrink-0 p-0.5">
191
- {project.expanded ? (
192
- <ChevronDown className="text-muted-foreground h-4 w-4" />
193
- ) : (
194
- <ChevronRight className="text-muted-foreground h-4 w-4" />
195
- )}
196
- </button>
197
-
198
- {/* Project name */}
199
- {isEditing ? (
200
- <input
201
- ref={inputRef}
202
- type="text"
203
- value={editName}
204
- onChange={(e) => setEditName(e.target.value)}
205
- onBlur={handleRename}
206
- onKeyDown={(e) => {
207
- if (e.key === "Enter") handleRename();
208
- if (e.key === "Escape") {
209
- setEditName(project.name);
210
- setIsEditing(false);
211
- }
212
- }}
213
- onClick={(e) => e.stopPropagation()}
214
- className="border-primary min-w-0 flex-1 border-b bg-transparent text-sm font-medium outline-none"
215
- />
216
- ) : (
217
- <span className="min-w-0 flex-1 truncate text-sm font-medium">
218
- {project.name}
219
- </span>
220
- )}
221
-
222
- {/* Running servers indicator */}
223
- {hasRunningServers && (
224
- <Tooltip>
225
- <TooltipTrigger asChild>
226
- <div className="flex flex-shrink-0 items-center gap-1 text-green-500">
227
- <Server className="h-3 w-3" />
228
- <span className="text-xs">{runningDevServers.length}</span>
229
- </div>
230
- </TooltipTrigger>
231
- <TooltipContent>
232
- <p>
233
- {runningDevServers.length} dev server
234
- {runningDevServers.length > 1 ? "s" : ""} running
235
- </p>
236
- </TooltipContent>
237
- </Tooltip>
238
- )}
239
-
240
- {/* Session count */}
241
- <span className="text-muted-foreground flex-shrink-0 text-xs">
242
- {sessionCount}
243
- </span>
244
-
245
- {/* Actions menu */}
246
- {hasActions && (
247
- <DropdownMenu>
248
- <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
249
- <Button
250
- variant="ghost"
251
- size="icon-sm"
252
- className="h-7 w-7 flex-shrink-0 opacity-100 md:h-6 md:w-6 md:opacity-0 md:group-hover:opacity-100"
253
- >
254
- <MoreHorizontal className="h-4 w-4" />
255
- </Button>
256
- </DropdownMenuTrigger>
257
- <DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
258
- {renderMenuItems(false)}
259
- </DropdownMenuContent>
260
- </DropdownMenu>
261
- )}
262
- </div>
263
- );
264
-
265
- // Wrap with context menu if actions are available
266
- if (hasActions) {
267
- return (
268
- <ContextMenu>
269
- <ContextMenuTrigger asChild>{cardContent}</ContextMenuTrigger>
270
- <ContextMenuContent>{renderMenuItems(true)}</ContextMenuContent>
271
- </ContextMenu>
272
- );
273
- }
274
-
275
- return cardContent;
276
- }