From ef03e7a6a944181fd5cf7f856aa06fb0fa16f203 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Tue, 28 Jul 2026 19:08:57 +1200 Subject: [PATCH] fix(web): restore sidebar v2 thread actions --- apps/web/src/components/SidebarV2.tsx | 93 ++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index be93f510c97..72ba7ef1baa 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -32,6 +32,7 @@ import { SearchIcon, ServerIcon, SquarePenIcon, + TerminalIcon, Trash2Icon, Undo2Icon, } from "lucide-react"; @@ -124,6 +125,8 @@ import { prStatusIndicator, resolveThreadPr, settledPrHoverColorClass, + terminalStatusFromRunningIds, + type TerminalStatusIndicator, } from "./ThreadStatusIndicators"; import { resolveSnoozePresets, @@ -136,6 +139,7 @@ import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; import { primaryServerProvidersAtom } from "../state/server"; +import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { CommandDialogTrigger } from "./ui/command"; import { Button } from "./ui/button"; @@ -220,6 +224,10 @@ function WorkingDuration(props: { startedAt: string | null }) { ); } +function terminalProcessLabel(count: number): string { + return `${count} terminal ${count === 1 ? "process" : "processes"} running`; +} + function SidebarV2ThreadTooltip({ thread, projectTitle, @@ -229,6 +237,8 @@ function SidebarV2ThreadTooltip({ modelInstanceId, modelLabel, branchMismatch, + terminalStatus, + terminalProcessCount, }: { thread: SidebarThreadSummary; projectTitle: string | null; @@ -241,6 +251,8 @@ function SidebarV2ThreadTooltip({ threadBranch: string; currentBranch: string; } | null; + terminalStatus: TerminalStatusIndicator | null; + terminalProcessCount: number; }) { return ( {modelLabel} ) : null} + {terminalStatus ? ( +
+ +
+ {terminalProcessLabel(terminalProcessCount)} +
+
+ ) : null} {thread.session?.lastError ? (
@@ -424,6 +447,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const openPrLink = useOpenPrLink(); + const runningTerminalIds = useThreadRunningTerminalIds({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + const terminalProcessCount = runningTerminalIds.length; // Same semantics as v1 (never-visited counts as read): flipping the beta // flag must not light up every historical thread as unread. @@ -542,6 +571,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { modelInstanceId={modelInstanceId} modelLabel={modelLabel} branchMismatch={branchMismatch} + terminalStatus={terminalStatus} + terminalProcessCount={terminalProcessCount} /> ); @@ -735,6 +766,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { #{pr.number} ) : null; + const terminalStatusIcon = terminalStatus ? ( + + + + ) : null; if (variant === "slim") { return ( @@ -774,6 +815,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { /> {title} + {terminalStatusIcon} {/* The PR badge stays outside the hover-fading slot: it must remain visible AND clickable while the row is hovered. Only the time/jump label yields to the settle affordance. */} @@ -960,6 +1002,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : ( )} + {terminalStatusIcon} {prBadge} {diff ? ( @@ -1028,7 +1071,7 @@ export default function SidebarV2() { reportFailure: false, }); const updateSettings = useUpdateClientSettings(); - const { copyToClipboard: copyProjectPath } = useCopyToClipboard<{ path: string }>({ + const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { toastManager.add({ type: "success", @@ -1046,6 +1089,25 @@ export default function SidebarV2() { ); }, }); + const { copyToClipboard: copyBranchToClipboard } = useCopyToClipboard<{ branch: string }>({ + target: "branch name", + onCopy: ({ branch }) => { + toastManager.add({ + type: "success", + title: "Branch copied", + description: branch, + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy branch", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + }); const [projectActionsTarget, setProjectActionsTarget] = useState( null, ); @@ -1985,6 +2047,10 @@ export default function SidebarV2() { } const thread = threadByKeyRef.current.get(threadKey); if (!thread) return; + const threadWorkspacePath = + thread.worktreePath ?? + projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? + null; // Un-settle works on every settled row: for explicit settles it // clears the override, for auto-settled rows it pins the thread // active until real activity clears the pin. Environments without @@ -2033,6 +2099,8 @@ export default function SidebarV2() { : []), { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, + { id: "copy-path", label: "Copy path", icon: "copy" }, + ...(thread.branch ? [{ id: "copy-branch", label: "Copy branch", icon: "copy" }] : []), { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ], position, @@ -2085,6 +2153,24 @@ export default function SidebarV2() { case "mark-unread": markThreadUnread(threadKey, thread.latestTurn?.completedAt); return; + case "copy-path": + if (!threadWorkspacePath) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Path unavailable", + description: "This thread does not have a workspace path to copy.", + }), + ); + return; + } + copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); + return; + case "copy-branch": + if (thread.branch) { + copyBranchToClipboard(thread.branch, { branch: thread.branch }); + } + return; case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -2122,9 +2208,12 @@ export default function SidebarV2() { attemptUnsettle, attemptUnsnooze, confirmThreadDelete, + copyBranchToClipboard, + copyPathToClipboard, deleteThread, handleMultiSelectContextMenu, markThreadUnread, + projectCwdByKey, serverConfigs, startThreadRename, ], @@ -2600,7 +2689,7 @@ export default function SidebarV2() { aria-label="Copy project path" title="Copy project path" onClick={() => - copyProjectPath(member.workspaceRoot, { path: member.workspaceRoot }) + copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot }) } >