Skip to content
This repository was archived by the owner on Apr 13, 2026. It is now read-only.

Commit 2278298

Browse files
committed
refactor: extract SessionItem component and optimize store subscriptions
1 parent 20ab960 commit 2278298

5 files changed

Lines changed: 561 additions & 735 deletions

File tree

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import { Comment01Icon, HelpCircleIcon } from '@hugeicons/core-free-icons';
2+
import { HugeiconsIcon } from '@hugeicons/react';
3+
import { formatDistanceToNowStrict } from 'date-fns';
4+
import { Pin, PinOff, Trash2 } from 'lucide-react';
5+
import { memo, useCallback, useState } from 'react';
6+
import { useShallow } from 'zustand/react/shallow';
7+
import { cn } from '../../lib/utils';
8+
import { useStore } from '../../store';
9+
import { SessionActionsContextMenuItems } from '../SessionActionsMenu';
10+
import {
11+
ContextMenu,
12+
ContextMenuPopup,
13+
ContextMenuTrigger,
14+
} from '../ui/context-menu';
15+
import { Spinner } from '../ui/spinner';
16+
17+
function formatRelativeTime(timestamp: number): string {
18+
const distance = formatDistanceToNowStrict(timestamp, { addSuffix: false });
19+
return distance
20+
.replace(/ seconds?/, 's')
21+
.replace(/ minutes?/, 'm')
22+
.replace(/ hours?/, 'h')
23+
.replace(/ days?/, 'd')
24+
.replace(/ months?/, 'mo')
25+
.replace(/ years?/, 'y');
26+
}
27+
28+
interface SessionItemProps {
29+
sessionId: string;
30+
workspaceId: string;
31+
/** Whether to show as pinned (PinOff icon on hover) or unpinned (Pin icon on hover) */
32+
isPinned?: boolean;
33+
/** Which timestamp to display: 'created' | 'updated' */
34+
sortBy: 'created' | 'updated';
35+
}
36+
37+
export const SessionItem = memo(function SessionItem({
38+
sessionId,
39+
workspaceId,
40+
isPinned = false,
41+
sortBy,
42+
}: SessionItemProps) {
43+
// Per-item store subscriptions — only this item re-renders when its data changes
44+
const session = useStore(
45+
useCallback(
46+
(state) =>
47+
state.sessions[workspaceId]?.find((s) => s.sessionId === sessionId) ??
48+
null,
49+
[workspaceId, sessionId],
50+
),
51+
);
52+
53+
const isSelected = useStore(
54+
useCallback((state) => state.selectedSessionId === sessionId, [sessionId]),
55+
);
56+
57+
const processingStatus = useStore(
58+
useCallback(
59+
(state) => state.sessionProcessing[sessionId]?.status ?? 'idle',
60+
[sessionId],
61+
),
62+
);
63+
64+
const actions = useStore(
65+
useShallow((state) => ({
66+
selectWorkspace: state.selectWorkspace,
67+
selectSession: state.selectSession,
68+
togglePinSession: state.togglePinSession,
69+
updateSession: state.updateSession,
70+
request: state.request,
71+
})),
72+
);
73+
74+
// Per-item local state for editing and delete confirmation
75+
const [isEditing, setIsEditing] = useState(false);
76+
const [editingValue, setEditingValue] = useState('');
77+
const [isConfirming, setIsConfirming] = useState(false);
78+
79+
if (!session) return null;
80+
81+
const displaySummary = session.summary || 'New Chat';
82+
const isProcessing = processingStatus === 'processing';
83+
const isAwaitingApproval = processingStatus === 'awaiting_approval';
84+
const isFailed = processingStatus === 'failed';
85+
86+
const handleClick = () => {
87+
actions.selectWorkspace(workspaceId);
88+
actions.selectSession(sessionId);
89+
};
90+
91+
const handlePinToggle = (e: React.MouseEvent) => {
92+
e.stopPropagation();
93+
actions.togglePinSession(sessionId);
94+
};
95+
96+
const handleStartRename = () => {
97+
setIsEditing(true);
98+
setEditingValue(session.summary || 'New Chat');
99+
};
100+
101+
const handleSaveRename = async () => {
102+
const trimmed = editingValue.trim();
103+
if (trimmed) {
104+
const workspace = useStore.getState().workspaces[workspaceId];
105+
if (workspace) {
106+
try {
107+
await actions.request('session.config.setSummary', {
108+
cwd: workspace.worktreePath,
109+
sessionId,
110+
summary: trimmed,
111+
});
112+
actions.updateSession(workspaceId, sessionId, { summary: trimmed });
113+
} catch (error) {
114+
console.error('Failed to rename session:', error);
115+
}
116+
}
117+
}
118+
setIsEditing(false);
119+
};
120+
121+
const handleCancelRename = () => {
122+
setIsEditing(false);
123+
};
124+
125+
const handleStartDelete = (e: React.MouseEvent) => {
126+
e.stopPropagation();
127+
setIsConfirming(true);
128+
};
129+
130+
const handleConfirmDelete = async (e: React.MouseEvent) => {
131+
e.stopPropagation();
132+
const state = useStore.getState();
133+
const workspace = state.workspaces[workspaceId];
134+
const sessionData = state.sessions[workspaceId]?.find(
135+
(s) => s.sessionId === sessionId,
136+
);
137+
if (!workspace || !sessionData) return;
138+
139+
const isLocalOnly = sessionData.messageCount === 0;
140+
try {
141+
if (!isLocalOnly) {
142+
const result = await actions.request('sessions.remove', {
143+
cwd: workspace.worktreePath,
144+
sessionId,
145+
});
146+
if (!result.success) {
147+
console.error('Failed to delete session:', result.error);
148+
return;
149+
}
150+
}
151+
state.removeSession(workspaceId, sessionId);
152+
if (state.selectedSessionId === sessionId) {
153+
const remaining = (state.sessions[workspaceId] || [])
154+
.filter((s) => s.sessionId !== sessionId)
155+
.sort((a, b) => b.modified - a.modified);
156+
state.selectSession(
157+
remaining.length > 0 ? remaining[0].sessionId : null,
158+
);
159+
}
160+
} catch (error) {
161+
console.error('Failed to delete session:', error);
162+
}
163+
setIsConfirming(false);
164+
};
165+
166+
const handleMouseLeave = () => {
167+
if (isConfirming) setIsConfirming(false);
168+
};
169+
170+
const timestamp = sortBy === 'created' ? session.created : session.modified;
171+
172+
return (
173+
<ContextMenu>
174+
<ContextMenuTrigger
175+
className={cn(
176+
'flex items-center gap-2 px-3 py-1.5 mb-1 cursor-pointer rounded transition-colors group',
177+
isSelected
178+
? 'bg-accent text-foreground'
179+
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
180+
isFailed && 'text-destructive-foreground',
181+
)}
182+
onClick={handleClick}
183+
onMouseLeave={handleMouseLeave}
184+
>
185+
<button className="hidden group-hover:block" onClick={handlePinToggle}>
186+
{isPinned ? (
187+
<PinOff size={14} strokeWidth={1.5} />
188+
) : (
189+
<Pin size={14} strokeWidth={1.5} />
190+
)}
191+
</button>
192+
<div className="group-hover:hidden">
193+
{isProcessing ? (
194+
<Spinner className="size-3.5" />
195+
) : isAwaitingApproval ? (
196+
<HugeiconsIcon
197+
icon={HelpCircleIcon}
198+
size={14}
199+
strokeWidth={1.5}
200+
className="text-warning-foreground"
201+
/>
202+
) : isPinned ? (
203+
<Pin size={14} strokeWidth={1.5} />
204+
) : (
205+
<HugeiconsIcon icon={Comment01Icon} size={14} strokeWidth={1.5} />
206+
)}
207+
</div>
208+
{isEditing ? (
209+
<input
210+
className="flex-1 text-sm bg-transparent border border-primary rounded px-1 py-0.5 outline-none"
211+
value={editingValue}
212+
onChange={(e) => setEditingValue(e.target.value)}
213+
onBlur={handleSaveRename}
214+
onKeyDown={(e) => {
215+
if (e.key === 'Enter') {
216+
handleSaveRename();
217+
} else if (e.key === 'Escape') {
218+
handleCancelRename();
219+
}
220+
}}
221+
onClick={(e) => e.stopPropagation()}
222+
autoFocus
223+
onFocus={(e) => e.target.select()}
224+
/>
225+
) : (
226+
<span className="flex-1 text-sm truncate">{displaySummary}</span>
227+
)}
228+
<span
229+
className={cn(
230+
'text-sm text-muted-foreground group-hover:hidden',
231+
isConfirming && 'hidden',
232+
)}
233+
>
234+
{formatRelativeTime(timestamp)}
235+
</span>
236+
{isConfirming ? (
237+
<button
238+
className="text-xs text-destructive cursor-pointer rounded bg-muted px-2 py-0.5 hover:bg-destructive/10 transition-colors"
239+
onClick={handleConfirmDelete}
240+
>
241+
Confirm
242+
</button>
243+
) : (
244+
<button
245+
className="hidden group-hover:block cursor-pointer text-muted-foreground hover:text-destructive transition-colors"
246+
onClick={handleStartDelete}
247+
>
248+
<Trash2 size={14} strokeWidth={1.5} />
249+
</button>
250+
)}
251+
</ContextMenuTrigger>
252+
<ContextMenuPopup>
253+
<SessionActionsContextMenuItems
254+
sessionId={sessionId}
255+
workspaceId={workspaceId}
256+
onRenameStart={handleStartRename}
257+
/>
258+
</ContextMenuPopup>
259+
</ContextMenu>
260+
);
261+
});

src/renderer/components/Repo/useRepoDelete.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@ export const useRepoDelete = () => {
1111
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
1212
const [repoToDelete, setRepoToDelete] = useState<RepoDeleteInfo | null>(null);
1313
const deleteRepo = useStore((state) => state.deleteRepo);
14-
const workspaces = useStore((state) => state.workspaces);
1514

1615
const handleDeleteRepoClick = (
1716
e: React.MouseEvent,
1817
repoPath: string,
1918
repoName: string,
2019
) => {
2120
e.stopPropagation();
21+
// Read workspaces at call time — no subscription needed
22+
const workspaces = useStore.getState().workspaces;
2223
const workspaceCount = Object.values(workspaces).filter(
2324
(w) => w.repoPath === repoPath,
2425
).length;

0 commit comments

Comments
 (0)