From 6d99d3c95918c00e62583513c83590a04be0c25a Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 12 Mar 2026 14:13:47 -0700 Subject: [PATCH] feat: Chrome-style tabs with curved connectors, new chat tab button, and link handling - Tab bar uses distinct strip background with curved connectors on active tab - "+" button creates new chat tabs (like Chrome new tab) - Markdown links intercepted for in-app navigation and anchor scrolling - Fix borderColor shorthand conflict in database-viewer spinner - Align sidebar header height with tab bar Made-with: Cursor --- apps/web/app/components/chat-panel.tsx | 8 +- apps/web/app/components/ui/dropdown-menu.tsx | 2 +- .../workspace/chat-sessions-sidebar.tsx | 10 +- .../components/workspace/database-viewer.tsx | 6 +- .../components/workspace/document-view.tsx | 26 ++++- .../workspace/file-manager-tree.tsx | 19 ++-- .../components/workspace/knowledge-tree.tsx | 24 ++-- .../components/workspace/markdown-editor.tsx | 36 +++++- apps/web/app/components/workspace/tab-bar.tsx | 28 +++-- .../workspace/workspace-sidebar.tsx | 2 +- apps/web/app/globals.css | 21 ++++ apps/web/app/workspace/workspace-content.tsx | 107 ++++++++++++++---- 12 files changed, 214 insertions(+), 75 deletions(-) diff --git a/apps/web/app/components/chat-panel.tsx b/apps/web/app/components/chat-panel.tsx index 18030a8d989..d264dd9bb16 100644 --- a/apps/web/app/components/chat-panel.tsx +++ b/apps/web/app/components/chat-panel.tsx @@ -2219,18 +2219,16 @@ export const ChatPanel = forwardRef( > Chat: {fileContext.filename} - ) : ( + ) : currentSessionId ? (

- {currentSessionId - ? (sessionTitle || "Chat Session") - : "New Chat"} + {sessionTitle || "Chat Session"}

- )} + ) : null} {!hideHeaderActions && (
diff --git a/apps/web/app/components/ui/dropdown-menu.tsx b/apps/web/app/components/ui/dropdown-menu.tsx index 86cf37cd687..b7d93d5a8e0 100644 --- a/apps/web/app/components/ui/dropdown-menu.tsx +++ b/apps/web/app/components/ui/dropdown-menu.tsx @@ -49,7 +49,7 @@ function DropdownMenuContent({ return ( {/* Header overlay: backdrop blur + 80% bg; list scrolls under it */}
@@ -484,10 +484,10 @@ export function ChatSessionsSidebar({ {/* Children */} diff --git a/apps/web/app/components/workspace/markdown-editor.tsx b/apps/web/app/components/workspace/markdown-editor.tsx index e21bfb3f062..2798888cf2d 100644 --- a/apps/web/app/components/workspace/markdown-editor.tsx +++ b/apps/web/app/components/workspace/markdown-editor.tsx @@ -239,12 +239,46 @@ export function MarkdownEditor({ const href = link.getAttribute("href"); if (!href) {return;} - // Intercept workspace links to handle via client-side state + // Anchor links: scroll to heading within the editor + if (href.startsWith("#")) { + event.preventDefault(); + event.stopPropagation(); + const slug = href.slice(1); + const editorEl = editor.view.dom; + const heading = editorEl.querySelector(`[id="${CSS.escape(slug)}"]`) + ?? editorEl.querySelector(`h1, h2, h3, h4, h5, h6`); + if (heading) { + const allHeadings = Array.from(editorEl.querySelectorAll("h1, h2, h3, h4, h5, h6")); + const match = allHeadings.find((h) => { + const text = (h.textContent || "").trim().toLowerCase() + .replace(/[^\w\s-]/g, "").replace(/\s+/g, "-"); + return text === slug; + }); + (match ?? heading).scrollIntoView({ behavior: "smooth", block: "start" }); + } + return; + } + + // Workspace links: navigate via client-side state if (isWorkspaceLink(href)) { event.preventDefault(); event.stopPropagation(); onNavigate(href); + return; } + + // Relative links (not http/https): treat as workspace file navigation + if (!href.startsWith("http://") && !href.startsWith("https://") && !href.startsWith("mailto:")) { + event.preventDefault(); + event.stopPropagation(); + onNavigate(href); + return; + } + + // External links: open in new tab + event.preventDefault(); + event.stopPropagation(); + window.open(href, "_blank", "noopener,noreferrer"); }; const editorElement = editor.view.dom; diff --git a/apps/web/app/components/workspace/tab-bar.tsx b/apps/web/app/components/workspace/tab-bar.tsx index a248575486c..70998aec8b0 100644 --- a/apps/web/app/components/workspace/tab-bar.tsx +++ b/apps/web/app/components/workspace/tab-bar.tsx @@ -14,6 +14,7 @@ type TabBarProps = { onCloseAll: () => void; onReorder: (fromIndex: number, toIndex: number) => void; onTogglePin: (tabId: string) => void; + onNewTab?: () => void; leftContent?: React.ReactNode; rightContent?: React.ReactNode; }; @@ -34,6 +35,7 @@ export function TabBar({ onCloseAll, onReorder, onTogglePin, + onNewTab, leftContent, rightContent, }: TabBarProps) { @@ -99,10 +101,9 @@ export function TabBar({ return ( <>
handleDragOver(e, index)} onDrop={isHome ? undefined : (e) => handleDrop(e, index)} onDragEnd={isHome ? undefined : handleDragEnd} - className={`group flex items-center gap-1.5 text-[12.5px] font-medium cursor-pointer flex-shrink-0 relative transition-colors duration-75 select-none ${isHome ? "px-2.5" : "pl-3 pr-1.5"} ${isActive ? "mb-[-1px] rounded-t-lg" : ""}`} + className={`group flex items-center gap-1.5 text-[12.5px] font-medium cursor-pointer shrink-0 relative transition-colors duration-75 select-none border-none outline-none ${isHome ? "px-2.5" : "pl-3 pr-1.5"} ${isActive ? "chrome-tab-active rounded-t-[8px] z-2" : "z-1"}`} style={{ color: isActive ? "var(--color-text)" : "var(--color-text-muted)", - background: isActive ? "var(--color-main-bg)" : "transparent", - borderBottom: isActive ? "1px solid var(--color-main-bg)" : "none", + background: isActive ? "var(--color-surface)" : "transparent", borderLeft: isDragOver && !isHome ? "2px solid var(--color-accent)" : undefined, opacity: dragIndex === index ? 0.5 : 1, maxWidth: isHome ? undefined : 200, @@ -173,9 +172,22 @@ export function TabBar({ ); })} + {onNewTab && ( + + )}
{rightContent && ( -
+
{rightContent}
)} diff --git a/apps/web/app/components/workspace/workspace-sidebar.tsx b/apps/web/app/components/workspace/workspace-sidebar.tsx index 1507d016f47..b301f5c1b36 100644 --- a/apps/web/app/components/workspace/workspace-sidebar.tsx +++ b/apps/web/app/components/workspace/workspace-sidebar.tsx @@ -434,7 +434,7 @@ export function WorkspaceSidebar({ > {/* Header */}
{isBrowsing ? ( diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index ad47ee60d43..83f8a0b4542 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1927,3 +1927,24 @@ body { .spreadsheet-editor-grid .Spreadsheet__data-editor input { padding: 4px 8px; } + +/* Chrome-style tab connectors */ +.chrome-tab-active::before, +.chrome-tab-active::after { + content: ''; + position: absolute; + bottom: 0; + width: 8px; + height: 8px; + pointer-events: none; +} + +.chrome-tab-active::before { + left: -8px; + background: radial-gradient(circle at 0 0, transparent 7.5px, var(--color-surface) 8px); +} + +.chrome-tab-active::after { + right: -8px; + background: radial-gradient(circle at 100% 0, transparent 7.5px, var(--color-surface) 8px); +} diff --git a/apps/web/app/workspace/workspace-content.tsx b/apps/web/app/workspace/workspace-content.tsx index 47369d20625..4f892227429 100644 --- a/apps/web/app/workspace/workspace-content.tsx +++ b/apps/web/app/workspace/workspace-content.tsx @@ -59,7 +59,7 @@ import { generateTabId, loadTabs, saveTabs, openTab, closeTab, closeOtherTabs, closeTabsToRight, closeAllTabs, activateTab, reorderTabs, togglePinTab, - inferTabType, inferTabTitle, + inferTabType, inferTabTitle, updateTabTitle, } from "@/lib/tab-state"; import dynamic from "next/dynamic"; @@ -978,10 +978,27 @@ function WorkspacePageInner() { setTabState((prev) => activateTab(prev, tabId)); return; } + let tab: Tab | undefined; setTabState((prev) => { const next = activateTab(prev, tabId); - const tab = next.tabs.find((t) => t.id === tabId); - if (tab?.path) { + tab = next.tabs.find((t) => t.id === tabId); + return next; + }); + requestAnimationFrame(() => { + if (!tab) return; + if (tab.type === "chat") { + setActivePath(null); + setContent({ kind: "none" }); + if (tab.sessionId) { + setActiveSessionId(tab.sessionId); + setActiveSubagentKey(null); + void chatRef.current?.loadSession(tab.sessionId); + } else { + setActiveSessionId(null); + setActiveSubagentKey(null); + void chatRef.current?.newSession(); + } + } else if (tab.path) { const node = resolveNode(tree, tab.path); if (node) { void loadContent(node); @@ -995,30 +1012,36 @@ function WorkspacePageInner() { if (job) setContent({ kind: "cron-job", jobId, job }); } } - return next; }); }, [tree, loadContent, cronJobs]); const handleTabClose = useCallback((tabId: string) => { - setTabState((prev) => { - const next = closeTab(prev, tabId); - if (next.activeTabId !== prev.activeTabId) { - if (next.activeTabId === HOME_TAB_ID || !next.activeTabId) { - setActivePath(null); - setContent({ kind: "none" }); - } else { - const newActive = next.tabs.find((t) => t.id === next.activeTabId); - if (newActive?.path) { - const node = resolveNode(tree, newActive.path); - if (node) { - void loadContent(node); - } - } + const prev = tabState; + const next = closeTab(prev, tabId); + setTabState(next); + if (next.activeTabId !== prev.activeTabId) { + const newActive = next.tabs.find((t) => t.id === next.activeTabId); + if (!newActive || newActive.id === HOME_TAB_ID) { + setActivePath(null); + setContent({ kind: "none" }); + } else if (newActive.type === "chat") { + setActivePath(null); + setContent({ kind: "none" }); + if (newActive.sessionId) { + setActiveSessionId(newActive.sessionId); + setActiveSubagentKey(null); + requestAnimationFrame(() => { + void chatRef.current?.loadSession(newActive.sessionId!); + }); + } + } else if (newActive.path) { + const node = resolveNode(tree, newActive.path); + if (node) { + void loadContent(node); } } - return next; - }); - }, [tree, loadContent]); + } + }, [tree, loadContent, tabState]); // Keep ref in sync so keyboard shortcut can close active tab useEffect(() => { @@ -1795,6 +1818,17 @@ function WorkspacePageInner() { return s?.title || undefined; }, [activeSessionId, sessions]); + useEffect(() => { + if (!activeSessionTitle) return; + setTabState((prev) => { + const active = prev.tabs.find((t) => t.id === prev.activeTabId); + if (active?.type === "chat" && active.title !== activeSessionTitle) { + return updateTabTitle(prev, active.id, activeSessionTitle); + } + return prev; + }); + }, [activeSessionTitle]); + // Whether to show the main ChatPanel (no file/content selected) const showMainChat = !activePath || content.kind === "none"; @@ -1921,7 +1955,7 @@ function WorkspacePageInner() { {/* Main content */} -
+
{/* Mobile top bar — always visible on mobile */} {isMobile && (
{ + const newTab: Tab = { + id: generateTabId(), + type: "chat", + title: "New Chat", + }; + setActivePath(null); + setContent({ kind: "none" }); + setActiveSessionId(null); + setActiveSubagentKey(null); + setTabState((prev) => openTab(prev, newTab)); + requestAnimationFrame(() => { + void chatRef.current?.newSession(); + }); + }} rightContent={showMainChat ? ( <>
@@ -2142,6 +2191,20 @@ function WorkspacePageInner() { onActiveSessionChange={activeSubagent ? undefined : (id) => { setActiveSessionId(id); setActiveSubagentKey(null); + if (id) { + setTabState((prev) => { + const active = prev.tabs.find((t) => t.id === prev.activeTabId); + if (active?.type === "chat" && !active.sessionId) { + return { + ...prev, + tabs: prev.tabs.map((t) => + t.id === active.id ? { ...t, sessionId: id } : t, + ), + }; + } + return prev; + }); + } }} onSessionsChange={activeSubagent ? undefined : refreshSessions} onSubagentSpawned={activeSubagent ? undefined : handleSubagentSpawned}