Skip to content

Commit 7dc4d5e

Browse files
committed
feat(sidebar): add context menus with archive/delete for threads and worktrees
- Right-click on thread or worktree rows opens context menu - Inline confirmation flow: first click enters confirm state, second click executes - Delete wired to existing backend handlers - Archive callbacks plumbed through props (backend pending) - Bump version to v0.5.0
1 parent 1a2fa19 commit 7dc4d5e

5 files changed

Lines changed: 260 additions & 5 deletions

File tree

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pi-desktop/desktop",
3-
"version": "0.4.0",
3+
"version": "0.5.0",
44
"private": true,
55
"productName": "Pi Desktop",
66
"description": "Pi Desktop Electron desktop shell",

apps/desktop/src/renderer/src/components/workspace/chat/response-divider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export function ResponseDivider({
4040
: `Response · Worked for ${formatDuration(now - userTimestamp)}`;
4141

4242
return (
43-
<div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-6 py-3 select-none">
43+
<div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-6 pt-1 pb-3 select-none">
4444
<div className="h-px flex-1 bg-white/[0.06]" />
4545
<div
4646
className={cn(

apps/desktop/src/renderer/src/components/workspace/left-sidebar.tsx

Lines changed: 251 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
ArrowDown,
77
ArrowUp,
88
Check,
9+
Archive,
910
Copy,
1011
Folder,
1112
GitBranch,
@@ -95,6 +96,8 @@ export interface LeftSidebarProps {
9596
onSelectThread: (threadId: string) => void;
9697
onDeleteWorktree?: (worktreeId: string) => void;
9798
onDeleteThread?: (threadId: string) => void;
99+
onArchiveWorktree?: (worktreeId: string) => void;
100+
onArchiveThread?: (threadId: string) => void;
98101
onAddRepository: () => void;
99102
onOpenFilter?: () => void;
100103
onNewAgent?: () => void;
@@ -256,20 +259,36 @@ interface WorktreeRowProps {
256259
threadLastViewedAt?: Record<string, number>;
257260
onSelect: (id: string) => void;
258261
onSelectThread: (threadId: string) => void;
262+
onContextMenu?: (
263+
e: React.MouseEvent,
264+
worktreeId: string,
265+
worktreeLabel: string,
266+
) => void;
267+
onThreadContextMenu?: (
268+
e: React.MouseEvent,
269+
threadId: string,
270+
threadTitle: string,
271+
) => void;
259272
}
260273

261274
interface ThreadRowProps {
262275
thread: WorktreeSnapshot["threads"][number];
263276
isActive: boolean;
264277
indicatorState: IndicatorState;
265278
onSelect: (id: string) => void;
279+
onContextMenu?: (
280+
e: React.MouseEvent,
281+
threadId: string,
282+
threadTitle: string,
283+
) => void;
266284
}
267285

268286
function ThreadRowImpl({
269287
thread,
270288
isActive,
271289
indicatorState,
272290
onSelect,
291+
onContextMenu,
273292
}: ThreadRowProps) {
274293
const threadTitle = thread.title.trim() || "Untitled thread";
275294

@@ -278,6 +297,11 @@ function ThreadRowImpl({
278297
<button
279298
type="button"
280299
onClick={() => onSelect(thread.id)}
300+
onContextMenu={(e) => {
301+
e.preventDefault();
302+
e.stopPropagation();
303+
onContextMenu?.(e, thread.id, threadTitle);
304+
}}
281305
className={cn(
282306
"group flex w-full min-w-0 items-center gap-2 pl-[11px] pr-2 py-1.5 text-left text-[12px]",
283307
"transition-colors duration-150",
@@ -306,6 +330,8 @@ function WorktreeRowImpl({
306330
threadLastViewedAt,
307331
onSelect,
308332
onSelectThread,
333+
onContextMenu,
334+
onThreadContextMenu,
309335
}: WorktreeRowProps) {
310336
const ahead = session.git.ahead ?? 0;
311337
const behind = session.git.behind ?? 0;
@@ -340,6 +366,11 @@ function WorktreeRowImpl({
340366
<button
341367
type="button"
342368
onClick={() => onSelect(session.id)}
369+
onContextMenu={(e) => {
370+
e.preventDefault();
371+
e.stopPropagation();
372+
onContextMenu?.(e, session.id, session.label);
373+
}}
343374
className={cn(
344375
"group flex w-full min-w-0 items-center gap-2 pl-[22px] pr-2 py-2 text-left text-[13px]",
345376
"transition-colors duration-150",
@@ -404,6 +435,7 @@ function WorktreeRowImpl({
404435
isActive={isThreadActive}
405436
indicatorState={threadIndicatorState}
406437
onSelect={onSelectThread}
438+
onContextMenu={onThreadContextMenu}
407439
/>
408440
);
409441
})}
@@ -515,8 +547,10 @@ export function LeftSidebarImpl({
515547
onSelectRepository,
516548
onSelectWorktree,
517549
onSelectThread,
518-
onDeleteThread: _onDeleteThread,
519-
onDeleteWorktree: _onDeleteWorktree,
550+
onDeleteThread,
551+
onDeleteWorktree,
552+
onArchiveThread,
553+
onArchiveWorktree,
520554
onRemoveRepository,
521555
onCopyRepositoryPath,
522556
onOpenInFinder,
@@ -551,6 +585,27 @@ export function LeftSidebarImpl({
551585
const contextMenuRef = React.useRef<HTMLDivElement>(null);
552586
const [isCreatingSession, setIsCreatingSession] = React.useState(false);
553587

588+
// Item context menu for threads/worktrees
589+
const [itemMenu, setItemMenu] = React.useState<{
590+
isOpen: boolean;
591+
x: number;
592+
y: number;
593+
type: "thread" | "worktree";
594+
id: string;
595+
label: string;
596+
confirming: "archive" | "delete" | null;
597+
}>({
598+
isOpen: false,
599+
x: 0,
600+
y: 0,
601+
type: "thread",
602+
id: "",
603+
label: "",
604+
confirming: null,
605+
});
606+
607+
const itemMenuRef = React.useRef<HTMLDivElement>(null);
608+
554609
const [expandedRepositoryIds, setExpandedRepositoryIds] = React.useState<
555610
Set<string>
556611
>(() => (activeRepositoryId ? new Set([activeRepositoryId]) : new Set()));
@@ -598,6 +653,108 @@ export function LeftSidebarImpl({
598653
};
599654
}, [contextMenu.isOpen]);
600655

656+
React.useEffect(() => {
657+
if (!itemMenu.isOpen) return;
658+
659+
const handleClickOutside = (e: MouseEvent) => {
660+
if (
661+
itemMenuRef.current &&
662+
!itemMenuRef.current.contains(e.target as Node)
663+
) {
664+
setItemMenu((prev) => ({ ...prev, isOpen: false, confirming: null }));
665+
}
666+
};
667+
668+
const handleEscape = (e: KeyboardEvent) => {
669+
if (e.key === "Escape") {
670+
setItemMenu((prev) => ({ ...prev, isOpen: false, confirming: null }));
671+
}
672+
};
673+
674+
document.addEventListener("mousedown", handleClickOutside);
675+
document.addEventListener("keydown", handleEscape);
676+
677+
return () => {
678+
document.removeEventListener("mousedown", handleClickOutside);
679+
document.removeEventListener("keydown", handleEscape);
680+
};
681+
}, [itemMenu.isOpen]);
682+
683+
const closeItemMenu = React.useCallback(() => {
684+
setItemMenu((prev) => ({ ...prev, isOpen: false, confirming: null }));
685+
}, []);
686+
687+
const handleItemContextMenu = React.useCallback(
688+
(
689+
e: React.MouseEvent,
690+
type: "thread" | "worktree",
691+
id: string,
692+
label: string,
693+
) => {
694+
e.preventDefault();
695+
e.stopPropagation();
696+
setItemMenu({
697+
isOpen: true,
698+
x: e.clientX,
699+
y: e.clientY,
700+
type,
701+
id,
702+
label,
703+
confirming: null,
704+
});
705+
},
706+
[],
707+
);
708+
709+
const handleThreadContextMenu = React.useCallback(
710+
(e: React.MouseEvent, threadId: string, threadTitle: string) => {
711+
handleItemContextMenu(e, "thread", threadId, threadTitle);
712+
},
713+
[handleItemContextMenu],
714+
);
715+
716+
const handleWorktreeContextMenu = React.useCallback(
717+
(e: React.MouseEvent, worktreeId: string, worktreeLabel: string) => {
718+
handleItemContextMenu(e, "worktree", worktreeId, worktreeLabel);
719+
},
720+
[handleItemContextMenu],
721+
);
722+
723+
const handleItemMenuConfirmAction = React.useCallback(
724+
(action: "archive" | "delete") => {
725+
if (itemMenu.confirming === action) {
726+
// Second click — execute
727+
if (action === "delete") {
728+
if (itemMenu.type === "thread") {
729+
onDeleteThread?.(itemMenu.id);
730+
} else {
731+
onDeleteWorktree?.(itemMenu.id);
732+
}
733+
} else {
734+
if (itemMenu.type === "thread") {
735+
onArchiveThread?.(itemMenu.id);
736+
} else {
737+
onArchiveWorktree?.(itemMenu.id);
738+
}
739+
}
740+
closeItemMenu();
741+
} else {
742+
// First click — enter confirming state
743+
setItemMenu((prev) => ({ ...prev, confirming: action }));
744+
}
745+
},
746+
[
747+
itemMenu.confirming,
748+
itemMenu.type,
749+
itemMenu.id,
750+
onDeleteThread,
751+
onDeleteWorktree,
752+
onArchiveThread,
753+
onArchiveWorktree,
754+
closeItemMenu,
755+
],
756+
);
757+
601758
const handleCreateSession = React.useCallback(async () => {
602759
if (isCreatingSession) return;
603760
setIsCreatingSession(true);
@@ -844,6 +1001,10 @@ export function LeftSidebarImpl({
8441001
threadLastViewedAt={threadLastViewedAt}
8451002
onSelect={onSelectWorktree}
8461003
onSelectThread={onSelectThread}
1004+
onContextMenu={handleWorktreeContextMenu}
1005+
onThreadContextMenu={
1006+
handleThreadContextMenu
1007+
}
8471008
/>
8481009
);
8491010
})}
@@ -966,6 +1127,94 @@ export function LeftSidebarImpl({
9661127
)}
9671128
</div>
9681129
)}
1130+
1131+
{/* Thread / Worktree Context Menu */}
1132+
{itemMenu.isOpen && (
1133+
<div
1134+
ref={itemMenuRef}
1135+
className="fixed z-[100] min-w-[180px] border border-white/[0.06] bg-[var(--color-bg-primary)] p-0 shadow-lg"
1136+
style={{ left: itemMenu.x, top: itemMenu.y }}
1137+
>
1138+
<div className="px-3 py-2 border-b border-white/[0.06]">
1139+
<span className="block truncate text-[12px] text-white/60">
1140+
{itemMenu.label}
1141+
</span>
1142+
<span className="text-[10px] text-white/30">
1143+
{itemMenu.type === "thread" ? "Thread" : "Worktree"}
1144+
</span>
1145+
</div>
1146+
1147+
{itemMenu.confirming === "archive" ? (
1148+
<div className="px-3 py-2 space-y-1.5">
1149+
<p className="text-[11px] text-white/50">
1150+
Archive this {itemMenu.type}?
1151+
</p>
1152+
<div className="flex items-center gap-2">
1153+
<button
1154+
type="button"
1155+
onClick={() => handleItemMenuConfirmAction("archive")}
1156+
className="flex-1 py-1 text-[11px] text-amber-400 hover:bg-amber-500/10 transition-colors duration-150"
1157+
>
1158+
Archive
1159+
</button>
1160+
<button
1161+
type="button"
1162+
onClick={() =>
1163+
setItemMenu((prev) => ({ ...prev, confirming: null }))
1164+
}
1165+
className="flex-1 py-1 text-[11px] text-white/40 hover:bg-white/[0.04] transition-colors duration-150"
1166+
>
1167+
Cancel
1168+
</button>
1169+
</div>
1170+
</div>
1171+
) : itemMenu.confirming === "delete" ? (
1172+
<div className="px-3 py-2 space-y-1.5">
1173+
<p className="text-[11px] text-white/50">
1174+
Delete this {itemMenu.type}? This cannot be undone.
1175+
</p>
1176+
<div className="flex items-center gap-2">
1177+
<button
1178+
type="button"
1179+
onClick={() => handleItemMenuConfirmAction("delete")}
1180+
className="flex-1 py-1 text-[11px] text-rose-400 hover:bg-rose-500/10 transition-colors duration-150"
1181+
>
1182+
Delete
1183+
</button>
1184+
<button
1185+
type="button"
1186+
onClick={() =>
1187+
setItemMenu((prev) => ({ ...prev, confirming: null }))
1188+
}
1189+
className="flex-1 py-1 text-[11px] text-white/40 hover:bg-white/[0.04] transition-colors duration-150"
1190+
>
1191+
Cancel
1192+
</button>
1193+
</div>
1194+
</div>
1195+
) : (
1196+
<>
1197+
<button
1198+
type="button"
1199+
onClick={() => handleItemMenuConfirmAction("archive")}
1200+
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] text-white/70 hover:bg-white/[0.04] transition-colors duration-150"
1201+
>
1202+
<Archive className="size-4" />
1203+
Archive
1204+
</button>
1205+
<div className="my-0.5 border-t border-white/[0.06]" />
1206+
<button
1207+
type="button"
1208+
onClick={() => handleItemMenuConfirmAction("delete")}
1209+
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] text-rose-400 hover:bg-rose-500/10 transition-colors duration-150"
1210+
>
1211+
<Trash className="size-4" />
1212+
Delete
1213+
</button>
1214+
</>
1215+
)}
1216+
</div>
1217+
)}
9691218
</>
9701219
)}
9711220
</aside>

apps/desktop/src/renderer/src/components/workspace/workspace-shell.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ export interface WorkspaceShellProps {
7272
onDeleteWorktree?: (worktreeId: string) => void | Promise<void>;
7373
onCloseThread: (threadId: string) => void | Promise<void>;
7474
onDeleteThread?: (threadId: string) => void | Promise<void>;
75+
onArchiveWorktree?: (worktreeId: string) => void;
76+
onArchiveThread?: (threadId: string) => void;
7577
onOpenGit: () => void;
7678
onToggleTerminal: () => void;
7779
isTerminalVisible: boolean;
@@ -165,6 +167,8 @@ function WorkspaceShellImpl({
165167
onDeleteWorktree,
166168
onCloseThread,
167169
onDeleteThread,
170+
onArchiveWorktree,
171+
onArchiveThread,
168172
onOpenGit: _onOpenGit,
169173
onToggleTerminal,
170174
isTerminalVisible,
@@ -348,6 +352,8 @@ function WorkspaceShellImpl({
348352
onSelectThread={onSelectThread}
349353
onDeleteWorktree={onDeleteWorktree}
350354
onDeleteThread={onDeleteThread}
355+
onArchiveWorktree={onArchiveWorktree}
356+
onArchiveThread={onArchiveThread}
351357
onAddRepository={onAddRepository}
352358
gitPanel={gitPanel}
353359
filesPanel={filesPanel}

0 commit comments

Comments
 (0)