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 {...@@ -32,6 +32,7 @@ import {
32 SearchIcon,32 SearchIcon,
33 ServerIcon,33 ServerIcon,
34 SquarePenIcon,34 SquarePenIcon,
35 TerminalIcon,
35 Trash2Icon,36 Trash2Icon,
36 Undo2Icon,37 Undo2Icon,
37} from "lucide-react";38} from "lucide-react";
...@@ -124,6 +125,8 @@ import {...@@ -124,6 +125,8 @@ import {
124 prStatusIndicator,125 prStatusIndicator,
125 resolveThreadPr,126 resolveThreadPr,
126 settledPrHoverColorClass,127 settledPrHoverColorClass,
128 terminalStatusFromRunningIds,
129 type TerminalStatusIndicator,
127} from "./ThreadStatusIndicators";130} from "./ThreadStatusIndicators";
128import {131import {
129 resolveSnoozePresets,132 resolveSnoozePresets,
...@@ -136,6 +139,7 @@ import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";...@@ -136,6 +139,7 @@ import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
136import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";139import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
137import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";140import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";
138import { primaryServerProvidersAtom } from "../state/server";141import { primaryServerProvidersAtom } from "../state/server";
142import { useThreadRunningTerminalIds } from "../state/terminalSessions";
139import { stackedThreadToast, toastManager } from "./ui/toast";143import { stackedThreadToast, toastManager } from "./ui/toast";
140import { CommandDialogTrigger } from "./ui/command";144import { CommandDialogTrigger } from "./ui/command";
141import { Button } from "./ui/button";145import { Button } from "./ui/button";
...@@ -220,6 +224,10 @@ function WorkingDuration(props: { startedAt: string | null }) {...@@ -220,6 +224,10 @@ function WorkingDuration(props: { startedAt: string | null }) {
220 );224 );
221}225}
222226
227function terminalProcessLabel(count: number): string {
228 return `${count} terminal ${count === 1 ? "process" : "processes"} running`;
229}
230
223function SidebarV2ThreadTooltip({231function SidebarV2ThreadTooltip({
224 thread,232 thread,
225 projectTitle,233 projectTitle,
...@@ -229,6 +237,8 @@ function SidebarV2ThreadTooltip({...@@ -229,6 +237,8 @@ function SidebarV2ThreadTooltip({
229 modelInstanceId,237 modelInstanceId,
230 modelLabel,238 modelLabel,
231 branchMismatch,239 branchMismatch,
240 terminalStatus,
241 terminalProcessCount,
232}: {242}: {
233 thread: SidebarThreadSummary;243 thread: SidebarThreadSummary;
234 projectTitle: string | null;244 projectTitle: string | null;
...@@ -241,6 +251,8 @@ function SidebarV2ThreadTooltip({...@@ -241,6 +251,8 @@ function SidebarV2ThreadTooltip({
241 threadBranch: string;251 threadBranch: string;
242 currentBranch: string;252 currentBranch: string;
243 } | null;253 } | null;
254 terminalStatus: TerminalStatusIndicator | null;
255 terminalProcessCount: number;
244}) {256}) {
245 return (257 return (
246 <TooltipPopup258 <TooltipPopup
...@@ -295,6 +307,17 @@ function SidebarV2ThreadTooltip({...@@ -295,6 +307,17 @@ function SidebarV2ThreadTooltip({
295 <div className="min-w-0 truncate text-foreground/75">{modelLabel}</div>307 <div className="min-w-0 truncate text-foreground/75">{modelLabel}</div>
296 </div>308 </div>
297 ) : null}309 ) : 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}
298 {thread.session?.lastError ? (321 {thread.session?.lastError ? (
299 <div className="flex min-w-0 items-center gap-2 text-red-600 dark:text-red-400">322 <div className="flex min-w-0 items-center gap-2 text-red-600 dark:text-red-400">
300 <CircleAlertIcon className="size-3 shrink-0 stroke-current" />323 <CircleAlertIcon className="size-3 shrink-0 stroke-current" />
...@@ -424,6 +447,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -424,6 +447,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
424 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);447 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
425 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));448 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
426 const openPrLink = useOpenPrLink();449 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
428 // Same semantics as v1 (never-visited counts as read): flipping the beta457 // Same semantics as v1 (never-visited counts as read): flipping the beta
429 // flag must not light up every historical thread as unread.458 // flag must not light up every historical thread as unread.
...@@ -542,6 +571,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -542,6 +571,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
542 modelInstanceId={modelInstanceId}571 modelInstanceId={modelInstanceId}
543 modelLabel={modelLabel}572 modelLabel={modelLabel}
544 branchMismatch={branchMismatch}573 branchMismatch={branchMismatch}
574 terminalStatus={terminalStatus}
575 terminalProcessCount={terminalProcessCount}
545 />576 />
546 );577 );
547578
...@@ -735,6 +766,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -735,6 +766,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
735 #{pr.number}766 #{pr.number}
736 </button>767 </button>
737 ) : null;768 ) : 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
739 if (variant === "slim") {780 if (variant === "slim") {
740 return (781 return (
...@@ -774,6 +815,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -774,6 +815,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
774 />815 />
775 </span>816 </span>
776 {title}817 {title}
818 {terminalStatusIcon}
777 {/* The PR badge stays outside the hover-fading slot: it must819 {/* The PR badge stays outside the hover-fading slot: it must
778 remain visible AND clickable while the row is hovered. Only820 remain visible AND clickable while the row is hovered. Only
779 the time/jump label yields to the settle affordance. */}821 the time/jump label yields to the settle affordance. */}
...@@ -960,6 +1002,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -960,6 +1002,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
960 ) : (1002 ) : (
961 <span className="flex-1" />1003 <span className="flex-1" />
962 )}1004 )}
1005 {terminalStatusIcon}
963 {prBadge}1006 {prBadge}
964 {diff ? (1007 {diff ? (
965 <span className="shrink-0 font-mono">1008 <span className="shrink-0 font-mono">
...@@ -1028,7 +1071,7 @@ export default function SidebarV2() {...@@ -1028,7 +1071,7 @@ export default function SidebarV2() {
1028 reportFailure: false,1071 reportFailure: false,
1029 });1072 });
1030 const updateSettings = useUpdateClientSettings();1073 const updateSettings = useUpdateClientSettings();
1031 const { copyToClipboard: copyProjectPath } = useCopyToClipboard<{ path: string }>({1074 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
1032 onCopy: ({ path }) => {1075 onCopy: ({ path }) => {
1033 toastManager.add({1076 toastManager.add({
1034 type: "success",1077 type: "success",
...@@ -1046,6 +1089,25 @@ export default function SidebarV2() {...@@ -1046,6 +1089,25 @@ export default function SidebarV2() {
1046 );1089 );
1047 },1090 },
1048 });1091 });
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 });
1049 const [projectActionsTarget, setProjectActionsTarget] = useState<SidebarProjectSnapshot | null>(1111 const [projectActionsTarget, setProjectActionsTarget] = useState<SidebarProjectSnapshot | null>(
1050 null,1112 null,
1051 );1113 );
...@@ -1985,6 +2047,10 @@ export default function SidebarV2() {...@@ -1985,6 +2047,10 @@ export default function SidebarV2() {
1985 }2047 }
1986 const thread = threadByKeyRef.current.get(threadKey);2048 const thread = threadByKeyRef.current.get(threadKey);
1987 if (!thread) return;2049 if (!thread) return;
2050 const threadWorkspacePath =
2051 thread.worktreePath ??
2052 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
2053 null;
1988 // Un-settle works on every settled row: for explicit settles it2054 // Un-settle works on every settled row: for explicit settles it
1989 // clears the override, for auto-settled rows it pins the thread2055 // clears the override, for auto-settled rows it pins the thread
1990 // active until real activity clears the pin. Environments without2056 // active until real activity clears the pin. Environments without
...@@ -2033,6 +2099,8 @@ export default function SidebarV2() {...@@ -2033,6 +2099,8 @@ export default function SidebarV2() {
2033 : []),2099 : []),
2034 { id: "rename", label: "Rename thread" },2100 { id: "rename", label: "Rename thread" },
2035 { id: "mark-unread", label: "Mark unread" },2101 { 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" }] : []),
2036 { id: "delete", label: "Delete", destructive: true, icon: "trash" },2104 { id: "delete", label: "Delete", destructive: true, icon: "trash" },
2037 ],2105 ],
2038 position,2106 position,
...@@ -2085,6 +2153,24 @@ export default function SidebarV2() {...@@ -2085,6 +2153,24 @@ export default function SidebarV2() {
2085 case "mark-unread":2153 case "mark-unread":
2086 markThreadUnread(threadKey, thread.latestTurn?.completedAt);2154 markThreadUnread(threadKey, thread.latestTurn?.completedAt);
2087 return;2155 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;
2088 case "delete": {2174 case "delete": {
2089 if (confirmThreadDelete) {2175 if (confirmThreadDelete) {
2090 const confirmed = await settlePromise(() =>2176 const confirmed = await settlePromise(() =>
...@@ -2122,9 +2208,12 @@ export default function SidebarV2() {...@@ -2122,9 +2208,12 @@ export default function SidebarV2() {
2122 attemptUnsettle,2208 attemptUnsettle,
2123 attemptUnsnooze,2209 attemptUnsnooze,
2124 confirmThreadDelete,2210 confirmThreadDelete,
2211 copyBranchToClipboard,
2212 copyPathToClipboard,
2125 deleteThread,2213 deleteThread,
2126 handleMultiSelectContextMenu,2214 handleMultiSelectContextMenu,
2127 markThreadUnread,2215 markThreadUnread,
2216 projectCwdByKey,
2128 serverConfigs,2217 serverConfigs,
2129 startThreadRename,2218 startThreadRename,
2130 ],2219 ],
...@@ -2600,7 +2689,7 @@ export default function SidebarV2() {...@@ -2600,7 +2689,7 @@ export default function SidebarV2() {
2600 aria-label="Copy project path"2689 aria-label="Copy project path"
2601 title="Copy project path"2690 title="Copy project path"
2602 onClick={() =>2691 onClick={() =>
2603 copyProjectPath(member.workspaceRoot, { path: member.workspaceRoot })2692 copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot })
2604 }2693 }
2605 >2694 >
2606 <CopyIcon className="size-3.5" />2695 <CopyIcon className="size-3.5" />