authorgravatar for Noojuno@users.noreply.github.comJono Kemball <Noojuno@users.noreply.github.com> 2026-07-30 13:48:01+12:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-29 21:48:01-04:00
log35a684000d2aa6d106aed93df52a9105883f9f3f
tree5bd2c726c034f9ef11092196714ec796b86f9f80
parent9e3e9bb4f081a6ad331cb94b42b2bd2d93339b81
signature Signed by PGP key B5690EEEBB952194

fix(web): restore sidebar v2 thread actions and terminal icon (#4712)


1 files changed, 91 insertions(+), 2 deletions(-)

apps/web/src/components/SidebarV2.tsx+91-2
......@@ -32,6 +32,7 @@ import {
3232 SearchIcon,
3333 ServerIcon,
3434 SquarePenIcon,
35 TerminalIcon,
3536 Trash2Icon,
3637 Undo2Icon,
3738} from "lucide-react";
......@@ -124,6 +125,8 @@ import {
124125 prStatusIndicator,
125126 resolveThreadPr,
126127 settledPrHoverColorClass,
128 terminalStatusFromRunningIds,
129 type TerminalStatusIndicator,
127130} from "./ThreadStatusIndicators";
128131import {
129132 resolveSnoozePresets,
......@@ -136,6 +139,7 @@ import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
136139import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
137140import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";
138141import { primaryServerProvidersAtom } from "../state/server";
142import { useThreadRunningTerminalIds } from "../state/terminalSessions";
139143import { stackedThreadToast, toastManager } from "./ui/toast";
140144import { CommandDialogTrigger } from "./ui/command";
141145import { Button } from "./ui/button";
......@@ -220,6 +224,10 @@ function WorkingDuration(props: { startedAt: string | null }) {
220224 );
221225}
222226
227function terminalProcessLabel(count: number): string {
228 return `${count} terminal ${count === 1 ? "process" : "processes"} running`;
229}
230
223231function SidebarV2ThreadTooltip({
224232 thread,
225233 projectTitle,
......@@ -229,6 +237,8 @@ function SidebarV2ThreadTooltip({
229237 modelInstanceId,
230238 modelLabel,
231239 branchMismatch,
240 terminalStatus,
241 terminalProcessCount,
232242}: {
233243 thread: SidebarThreadSummary;
234244 projectTitle: string | null;
......@@ -241,6 +251,8 @@ function SidebarV2ThreadTooltip({
241251 threadBranch: string;
242252 currentBranch: string;
243253 } | null;
254 terminalStatus: TerminalStatusIndicator | null;
255 terminalProcessCount: number;
244256}) {
245257 return (
246258 <TooltipPopup
......@@ -295,6 +307,17 @@ function SidebarV2ThreadTooltip({
295307 <div className="min-w-0 truncate text-foreground/75">{modelLabel}</div>
296308 </div>
297309 ) : null}
310 {terminalStatus ? (
311 <div className="flex min-w-0 items-center gap-2">
312 <TerminalIcon
313 aria-hidden
314 className={cn("size-3 shrink-0", terminalStatus.colorClass)}
315 />
316 <div className="min-w-0 truncate text-foreground/75">
317 {terminalProcessLabel(terminalProcessCount)}
318 </div>
319 </div>
320 ) : null}
298321 {thread.session?.lastError ? (
299322 <div className="flex min-w-0 items-center gap-2 text-red-600 dark:text-red-400">
300323 <CircleAlertIcon className="size-3 shrink-0 stroke-current" />
......@@ -424,6 +447,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
424447 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
425448 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
426449 const openPrLink = useOpenPrLink();
450 const runningTerminalIds = useThreadRunningTerminalIds({
451 environmentId: thread.environmentId,
452 threadId: thread.id,
453 });
454 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
455 const terminalProcessCount = runningTerminalIds.length;
427456
428457 // Same semantics as v1 (never-visited counts as read): flipping the beta
429458 // flag must not light up every historical thread as unread.
......@@ -542,6 +571,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
542571 modelInstanceId={modelInstanceId}
543572 modelLabel={modelLabel}
544573 branchMismatch={branchMismatch}
574 terminalStatus={terminalStatus}
575 terminalProcessCount={terminalProcessCount}
545576 />
546577 );
547578
......@@ -735,6 +766,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
735766 #{pr.number}
736767 </button>
737768 ) : null;
769 const terminalStatusIcon = terminalStatus ? (
770 <span
771 role="img"
772 aria-label={terminalProcessLabel(terminalProcessCount)}
773 data-testid={`sidebar-v2-terminal-status-${thread.id}`}
774 className={cn("inline-flex shrink-0 items-center justify-center", terminalStatus.colorClass)}
775 >
776 <TerminalIcon className={cn("size-3.5", terminalStatus.pulse && "animate-status-pulse")} />
777 </span>
778 ) : null;
738779
739780 if (variant === "slim") {
740781 return (
......@@ -774,6 +815,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
774815 />
775816 </span>
776817 {title}
818 {terminalStatusIcon}
777819 {/* The PR badge stays outside the hover-fading slot: it must
778820 remain visible AND clickable while the row is hovered. Only
779821 the time/jump label yields to the settle affordance. */}
......@@ -960,6 +1002,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
9601002 ) : (
9611003 <span className="flex-1" />
9621004 )}
1005 {terminalStatusIcon}
9631006 {prBadge}
9641007 {diff ? (
9651008 <span className="shrink-0 font-mono">
......@@ -1028,7 +1071,7 @@ export default function SidebarV2() {
10281071 reportFailure: false,
10291072 });
10301073 const updateSettings = useUpdateClientSettings();
1031 const { copyToClipboard: copyProjectPath } = useCopyToClipboard<{ path: string }>({
1074 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
10321075 onCopy: ({ path }) => {
10331076 toastManager.add({
10341077 type: "success",
......@@ -1046,6 +1089,25 @@ export default function SidebarV2() {
10461089 );
10471090 },
10481091 });
1092 const { copyToClipboard: copyBranchToClipboard } = useCopyToClipboard<{ branch: string }>({
1093 target: "branch name",
1094 onCopy: ({ branch }) => {
1095 toastManager.add({
1096 type: "success",
1097 title: "Branch copied",
1098 description: branch,
1099 });
1100 },
1101 onError: (error) => {
1102 toastManager.add(
1103 stackedThreadToast({
1104 type: "error",
1105 title: "Failed to copy branch",
1106 description: error instanceof Error ? error.message : "An error occurred.",
1107 }),
1108 );
1109 },
1110 });
10491111 const [projectActionsTarget, setProjectActionsTarget] = useState<SidebarProjectSnapshot | null>(
10501112 null,
10511113 );
......@@ -1985,6 +2047,10 @@ export default function SidebarV2() {
19852047 }
19862048 const thread = threadByKeyRef.current.get(threadKey);
19872049 if (!thread) return;
2050 const threadWorkspacePath =
2051 thread.worktreePath ??
2052 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
2053 null;
19882054 // Un-settle works on every settled row: for explicit settles it
19892055 // clears the override, for auto-settled rows it pins the thread
19902056 // active until real activity clears the pin. Environments without
......@@ -2033,6 +2099,8 @@ export default function SidebarV2() {
20332099 : []),
20342100 { id: "rename", label: "Rename thread" },
20352101 { id: "mark-unread", label: "Mark unread" },
2102 { id: "copy-path", label: "Copy path", icon: "copy" },
2103 ...(thread.branch ? [{ id: "copy-branch", label: "Copy branch", icon: "copy" }] : []),
20362104 { id: "delete", label: "Delete", destructive: true, icon: "trash" },
20372105 ],
20382106 position,
......@@ -2085,6 +2153,24 @@ export default function SidebarV2() {
20852153 case "mark-unread":
20862154 markThreadUnread(threadKey, thread.latestTurn?.completedAt);
20872155 return;
2156 case "copy-path":
2157 if (!threadWorkspacePath) {
2158 toastManager.add(
2159 stackedThreadToast({
2160 type: "error",
2161 title: "Path unavailable",
2162 description: "This thread does not have a workspace path to copy.",
2163 }),
2164 );
2165 return;
2166 }
2167 copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath });
2168 return;
2169 case "copy-branch":
2170 if (thread.branch) {
2171 copyBranchToClipboard(thread.branch, { branch: thread.branch });
2172 }
2173 return;
20882174 case "delete": {
20892175 if (confirmThreadDelete) {
20902176 const confirmed = await settlePromise(() =>
......@@ -2122,9 +2208,12 @@ export default function SidebarV2() {
21222208 attemptUnsettle,
21232209 attemptUnsnooze,
21242210 confirmThreadDelete,
2211 copyBranchToClipboard,
2212 copyPathToClipboard,
21252213 deleteThread,
21262214 handleMultiSelectContextMenu,
21272215 markThreadUnread,
2216 projectCwdByKey,
21282217 serverConfigs,
21292218 startThreadRename,
21302219 ],
......@@ -2600,7 +2689,7 @@ export default function SidebarV2() {
26002689 aria-label="Copy project path"
26012690 title="Copy project path"
26022691 onClick={() =>
2603 copyProjectPath(member.workspaceRoot, { path: member.workspaceRoot })
2692 copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot })
26042693 }
26052694 >
26062695 <CopyIcon className="size-3.5" />