| 1 | // TabBar renders the browser-like workspace tab strip. Each tab represents one |
| 2 | // open project/global topic, so switching tabs switches the active conversation. |
| 3 | import { useEffect, useRef, useState } from "react"; |
| 4 | import type { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react"; |
| 5 | import { FileText, Plus, Search, X } from "lucide-react"; |
| 6 | import { normalizeCollaborationMode, normalizeMode, normalizeToolApprovalMode, type Mode, type TabMeta } from "../lib/types"; |
| 7 | import { projectColorValue } from "../lib/projectColors"; |
| 8 | import { useT } from "../lib/i18n"; |
| 9 | import { Tooltip } from "./Tooltip"; |
| 10 | import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu"; |
| 11 | import { WorktreeBadge } from "./WorktreeBadge"; |
| 12 | |
| 13 | interface TabBarProps { |
| 14 | tabs: TabMeta[]; |
| 15 | activeTabId?: string; |
| 16 | onTabChange: (tabId: string) => void; |
| 17 | onTabClose: (tabId: string) => void; |
| 18 | onTabsClose: (tabIds: string[], nextActiveTabId?: string) => void; |
| 19 | onTabsReorder: (tabIds: string[]) => void; |
| 20 | onNewTab: () => void; |
| 21 | onOpenPalette?: () => void; |
| 22 | commandCompact?: boolean; |
| 23 | revealActiveSignal?: number; |
| 24 | } |
| 25 | |
| 26 | type DropSide = "before" | "after"; |
| 27 | |
| 28 | function tabDisplayTitle(tab: TabMeta): string { |
| 29 | if (tab.tabType === "file" || tab.scope === "file") return tab.topicTitle?.trim() || tab.filePath?.split("/").filter(Boolean).pop() || "File"; |
| 30 | const title = tab.topicTitle?.trim(); |
| 31 | if (tab.scope === "global") return title || "Global"; |
| 32 | return title || "Untitled"; |
| 33 | } |
| 34 | |
| 35 | function tabFullTitle(tab: TabMeta): string { |
| 36 | if (tab.tabType === "file" || tab.scope === "file") return tab.filePath || tabDisplayTitle(tab); |
| 37 | if (tab.scope === "global") { |
| 38 | const title = tabDisplayTitle(tab); |
| 39 | const workspaceName = tab.workspaceName?.trim() || "Global"; |
| 40 | return title === workspaceName ? workspaceName : `${workspaceName} / ${title}`; |
| 41 | } |
| 42 | const workspaceName = tab.workspaceName?.trim() || "Project"; |
| 43 | return `${workspaceName} / ${tabDisplayTitle(tab)}`; |
| 44 | } |
| 45 | |
| 46 | function tabMode(tab: TabMeta): Mode { |
| 47 | return normalizeMode(tab.mode); |
| 48 | } |
| 49 | |
| 50 | function projectAccentStyle(color?: string): CSSProperties | undefined { |
| 51 | const value = projectColorValue(color); |
| 52 | if (!value) return undefined; |
| 53 | return { "--project-accent": value } as CSSProperties; |
| 54 | } |
| 55 | |
| 56 | export function TabBar({ tabs, activeTabId, onTabChange, onTabClose, onTabsClose, onTabsReorder, onNewTab, onOpenPalette, commandCompact = false, revealActiveSignal = 0 }: TabBarProps) { |
| 57 | const t = useT(); |
| 58 | const [draggingTabId, setDraggingTabId] = useState<string | null>(null); |
| 59 | const [dropTarget, setDropTarget] = useState<{ id: string; side: DropSide } | null>(null); |
| 60 | const [menuTabId, setMenuTabId] = useState<string | null>(null); |
| 61 | const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null); |
| 62 | const suppressClickRef = useRef(false); |
| 63 | const tabRefs = useRef(new Map<string, HTMLButtonElement>()); |
| 64 | const backendActiveTabId = tabs.find((tab) => tab.active)?.id; |
| 65 | const activeTabIdExists = Boolean(activeTabId && tabs.some((tab) => tab.id === activeTabId)); |
| 66 | const resolvedActiveTabId = activeTabIdExists ? activeTabId : backendActiveTabId; |
| 67 | const tabOrderKey = tabs.map((tab) => tab.id).join("\u0000"); |
| 68 | |
| 69 | useEffect(() => { |
| 70 | if (!resolvedActiveTabId) return; |
| 71 | const frame = window.requestAnimationFrame(() => { |
| 72 | tabRefs.current.get(resolvedActiveTabId)?.scrollIntoView({ |
| 73 | block: "nearest", |
| 74 | inline: "nearest", |
| 75 | }); |
| 76 | }); |
| 77 | return () => window.cancelAnimationFrame(frame); |
| 78 | }, [backendActiveTabId, resolvedActiveTabId, revealActiveSignal, tabOrderKey]); |
| 79 | |
| 80 | const handleClose = (tabId: string) => { |
| 81 | onTabClose(tabId); |
| 82 | }; |
| 83 | |
| 84 | const clearDragState = () => { |
| 85 | setDraggingTabId(null); |
| 86 | setDropTarget(null); |
| 87 | }; |
| 88 | |
| 89 | const dropSideForEvent = (event: DragEvent<HTMLButtonElement>): DropSide => { |
| 90 | const rect = event.currentTarget.getBoundingClientRect(); |
| 91 | return event.clientX > rect.left + rect.width / 2 ? "after" : "before"; |
| 92 | }; |
| 93 | |
| 94 | const reorderTabIds = (draggedId: string, targetId: string, side: DropSide): string[] => { |
| 95 | const ids = tabs.map((tab) => tab.id); |
| 96 | const from = ids.indexOf(draggedId); |
| 97 | const target = ids.indexOf(targetId); |
| 98 | if (from < 0 || target < 0 || draggedId === targetId) return ids; |
| 99 | const next = ids.filter((id) => id !== draggedId); |
| 100 | const targetAfterRemoval = next.indexOf(targetId); |
| 101 | const insertAt = side === "after" ? targetAfterRemoval + 1 : targetAfterRemoval; |
| 102 | next.splice(insertAt, 0, draggedId); |
| 103 | return next; |
| 104 | }; |
| 105 | |
| 106 | const handleDragStart = (event: DragEvent<HTMLButtonElement>, tabId: string) => { |
| 107 | setDraggingTabId(tabId); |
| 108 | setDropTarget(null); |
| 109 | event.dataTransfer.effectAllowed = "move"; |
| 110 | event.dataTransfer.setData("text/plain", tabId); |
| 111 | }; |
| 112 | |
| 113 | const handleDragOver = (event: DragEvent<HTMLButtonElement>, tabId: string) => { |
| 114 | if (!draggingTabId || draggingTabId === tabId) return; |
| 115 | event.preventDefault(); |
| 116 | event.dataTransfer.dropEffect = "move"; |
| 117 | setDropTarget({ id: tabId, side: dropSideForEvent(event) }); |
| 118 | }; |
| 119 | |
| 120 | const handleDrop = (event: DragEvent<HTMLButtonElement>, tabId: string) => { |
| 121 | event.preventDefault(); |
| 122 | const draggedId = draggingTabId || event.dataTransfer.getData("text/plain"); |
| 123 | const side = dropTarget?.id === tabId ? dropTarget.side : dropSideForEvent(event); |
| 124 | clearDragState(); |
| 125 | if (!draggedId || draggedId === tabId) return; |
| 126 | const next = reorderTabIds(draggedId, tabId, side); |
| 127 | if (next.join("\u0000") !== tabs.map((tab) => tab.id).join("\u0000")) { |
| 128 | suppressClickRef.current = true; |
| 129 | onTabsReorder(next); |
| 130 | } |
| 131 | }; |
| 132 | |
| 133 | const handleTabClick = (tabId: string) => { |
| 134 | if (suppressClickRef.current) { |
| 135 | suppressClickRef.current = false; |
| 136 | return; |
| 137 | } |
| 138 | onTabChange(tabId); |
| 139 | }; |
| 140 | |
| 141 | const handleTabAuxClick = (event: ReactMouseEvent<HTMLButtonElement>, tabId: string) => { |
| 142 | // DOM MouseEvent.button: 1 is the auxiliary button, normally the middle/wheel button. |
| 143 | if (event.button !== 1) return; |
| 144 | event.preventDefault(); |
| 145 | event.stopPropagation(); |
| 146 | handleClose(tabId); |
| 147 | }; |
| 148 | |
| 149 | const openTabMenu = (event: ReactMouseEvent<HTMLButtonElement> | ReactKeyboardEvent<HTMLButtonElement>, tabId: string) => { |
| 150 | event.preventDefault(); |
| 151 | event.stopPropagation(); |
| 152 | setMenuTabId(tabId); |
| 153 | setMenuPoint(contextMenuPointFromEvent(event)); |
| 154 | }; |
| 155 | |
| 156 | const closeTabMenu = () => { |
| 157 | setMenuTabId(null); |
| 158 | setMenuPoint(null); |
| 159 | }; |
| 160 | |
| 161 | const closeTabsFromMenu = (tabIds: string[], nextActiveTabId?: string) => { |
| 162 | closeTabMenu(); |
| 163 | onTabsClose(tabIds, nextActiveTabId); |
| 164 | }; |
| 165 | |
| 166 | const menuTabIndex = menuTabId ? tabs.findIndex((tab) => tab.id === menuTabId) : -1; |
| 167 | const tabMenuItems: ContextMenuItem[] = menuTabId && menuTabIndex >= 0 |
| 168 | ? [ |
| 169 | { |
| 170 | key: "close-current", |
| 171 | label: t("tabBar.closeTab"), |
| 172 | disabled: tabs.length <= 1, |
| 173 | onSelect: () => closeTabsFromMenu([menuTabId]), |
| 174 | }, |
| 175 | { |
| 176 | key: "close-other", |
| 177 | label: t("tabBar.closeOtherTabs"), |
| 178 | disabled: tabs.length <= 1, |
| 179 | onSelect: () => closeTabsFromMenu(tabs.filter((tab) => tab.id !== menuTabId).map((tab) => tab.id), menuTabId), |
| 180 | }, |
| 181 | { |
| 182 | key: "close-right", |
| 183 | label: t("tabBar.closeTabsToRight"), |
| 184 | disabled: menuTabIndex >= tabs.length - 1, |
| 185 | onSelect: () => { |
| 186 | const rightTabIds = tabs.slice(menuTabIndex + 1).map((tab) => tab.id); |
| 187 | const nextActiveTabId = resolvedActiveTabId && rightTabIds.includes(resolvedActiveTabId) ? menuTabId : undefined; |
| 188 | closeTabsFromMenu(rightTabIds, nextActiveTabId); |
| 189 | }, |
| 190 | }, |
| 191 | ] |
| 192 | : []; |
| 193 | |
| 194 | return ( |
| 195 | <div className="tabbar"> |
| 196 | <div className="tabbar__tabs"> |
| 197 | {tabs.map((tab) => { |
| 198 | const displayTitle = tabDisplayTitle(tab); |
| 199 | const fullTitle = tabFullTitle(tab); |
| 200 | const mode = tabMode(tab); |
| 201 | const collaborationMode = normalizeCollaborationMode(tab.collaborationMode, tab.goal, mode); |
| 202 | const planMode = collaborationMode === "plan"; |
| 203 | const goalMode = collaborationMode === "goal"; |
| 204 | const toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode, mode); |
| 205 | const stateTitle = [ |
| 206 | tab.running ? "Running" : "", |
| 207 | planMode ? "Plan" : "", |
| 208 | goalMode ? "Goal" : "", |
| 209 | toolApprovalMode === "auto" ? "Auto approve" : "", |
| 210 | toolApprovalMode === "yolo" ? "YOLO approval" : "", |
| 211 | ].filter(Boolean).join(" · "); |
| 212 | const annotatedTitle = stateTitle ? `${stateTitle} · ${fullTitle}` : fullTitle; |
| 213 | return ( |
| 214 | <button |
| 215 | key={tab.id} |
| 216 | ref={(node) => { |
| 217 | if (node) { |
| 218 | tabRefs.current.set(tab.id, node); |
| 219 | } else { |
| 220 | tabRefs.current.delete(tab.id); |
| 221 | } |
| 222 | }} |
| 223 | draggable |
| 224 | className={[ |
| 225 | "tabbar__tab", |
| 226 | tab.id === resolvedActiveTabId ? "tabbar__tab--active" : "", |
| 227 | tab.running ? "tabbar__tab--running" : "", |
| 228 | toolApprovalMode === "yolo" ? "tabbar__tab--yolo" : "", |
| 229 | draggingTabId === tab.id ? "tabbar__tab--dragging" : "", |
| 230 | dropTarget?.id === tab.id ? `tabbar__tab--drop-${dropTarget.side}` : "", |
| 231 | ].filter(Boolean).join(" ")} |
| 232 | title={annotatedTitle} |
| 233 | aria-label={annotatedTitle} |
| 234 | style={projectAccentStyle(tab.projectColor)} |
| 235 | onClick={() => handleTabClick(tab.id)} |
| 236 | onAuxClick={(event) => handleTabAuxClick(event, tab.id)} |
| 237 | onMouseDown={(event) => { |
| 238 | // Prevent the browser/webview middle-click auto-scroll before auxclick fires. |
| 239 | if (event.button === 1) event.preventDefault(); |
| 240 | }} |
| 241 | onContextMenu={(event) => openTabMenu(event, tab.id)} |
| 242 | onKeyDown={(event) => { |
| 243 | if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { |
| 244 | openTabMenu(event, tab.id); |
| 245 | } |
| 246 | }} |
| 247 | onDragStart={(event) => handleDragStart(event, tab.id)} |
| 248 | onDragOver={(event) => handleDragOver(event, tab.id)} |
| 249 | onDrop={(event) => handleDrop(event, tab.id)} |
| 250 | onDragEnd={clearDragState} |
| 251 | > |
| 252 | {tab.tabType === "file" || tab.scope === "file" ? ( |
| 253 | <FileText size={12} className="tabbar__file-icon" /> |
| 254 | ) : ( |
| 255 | <span |
| 256 | className={[ |
| 257 | "tabbar__status", |
| 258 | tab.running ? "tabbar__status--running" : "", |
| 259 | ].filter(Boolean).join(" ")} |
| 260 | /> |
| 261 | )} |
| 262 | <span className="tabbar__tab-label">{displayTitle}</span> |
| 263 | {tab.isolatedWorktree && <WorktreeBadge size={11} />} |
| 264 | {planMode && <span className="tabbar__mode-badge tabbar__mode-badge--plan">plan</span>} |
| 265 | {goalMode && <span className="tabbar__mode-badge tabbar__mode-badge--plan">goal</span>} |
| 266 | {toolApprovalMode === "auto" && <span className="tabbar__mode-badge tabbar__mode-badge--plan">auto</span>} |
| 267 | {toolApprovalMode === "yolo" && <span className="tabbar__mode-badge tabbar__mode-badge--yolo">yolo</span>} |
| 268 | <span |
| 269 | className="tabbar__tab-close" |
| 270 | onClick={(e) => { |
| 271 | e.stopPropagation(); |
| 272 | handleClose(tab.id); |
| 273 | }} |
| 274 | > |
| 275 | <X size={10} /> |
| 276 | </span> |
| 277 | </button> |
| 278 | ); |
| 279 | })} |
| 280 | </div> |
| 281 | <Tooltip label={t("tabBar.newSession")}> |
| 282 | <button className="tabbar__new" type="button" aria-label={t("tabBar.newSession")} onClick={onNewTab}> |
| 283 | <Plus size={13} /> |
| 284 | </button> |
| 285 | </Tooltip> |
| 286 | {onOpenPalette && <span className="tabbar__spacer" aria-hidden="true" />} |
| 287 | {onOpenPalette && ( |
| 288 | <button |
| 289 | className={["tabbar__command", commandCompact ? "tabbar__command--compact" : ""].filter(Boolean).join(" ")} |
| 290 | type="button" |
| 291 | onClick={onOpenPalette} |
| 292 | aria-label={t("palette.placeholder")} |
| 293 | title={t("palette.placeholder")} |
| 294 | > |
| 295 | <Search size={commandCompact ? 16 : 13} className="tabbar__command-icon" /> |
| 296 | {!commandCompact && ( |
| 297 | <> |
| 298 | <span className="tabbar__command-text tabbar__command-text--full">{t("tabBar.commandSearch")}</span> |
| 299 | <span className="tabbar__command-text tabbar__command-text--compact">{t("tabBar.commandSearchCompact")}</span> |
| 300 | <kbd className="tabbar__command-kbd">⌘K</kbd> |
| 301 | </> |
| 302 | )} |
| 303 | </button> |
| 304 | )} |
| 305 | <ContextMenu |
| 306 | open={Boolean(menuTabId)} |
| 307 | point={menuPoint} |
| 308 | items={tabMenuItems} |
| 309 | minWidth={170} |
| 310 | ariaLabel={t("tabBar.tabActions")} |
| 311 | onClose={closeTabMenu} |
| 312 | /> |
| 313 | </div> |
| 314 | ); |
| 315 | } |
| 316 |