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