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
This commit is contained in:
Mark 2026-03-12 14:13:47 -07:00
parent fbfdee21a5
commit 6d99d3c959
12 changed files with 214 additions and 75 deletions

View File

@ -2219,18 +2219,16 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
>
Chat: {fileContext.filename}
</h2>
) : (
) : currentSessionId ? (
<h2
className="text-sm font-semibold"
style={{
color: "var(--color-text)",
}}
>
{currentSessionId
? (sessionTitle || "Chat Session")
: "New Chat"}
{sessionTitle || "Chat Session"}
</h2>
)}
) : null}
</div>
{!hideHeaderActions && (
<div className="flex items-center gap-1 shrink-0">

View File

@ -49,7 +49,7 @@ function DropdownMenuContent({
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-[100] outline-none"
className="isolate z-[10000] outline-none"
align={align}
alignOffset={alignOffset}
side={side}

View File

@ -452,11 +452,11 @@ export function ChatSessionsSidebar({
</div>
{/* Header overlay: backdrop blur + 80% bg; list scrolls under it */}
<div
className={`absolute top-0 left-0 right-0 z-10 flex items-center justify-between px-4 py-2 backdrop-blur-md ${embedded ? "" : "border-b"}`}
className={`absolute top-0 left-0 right-0 z-10 flex items-center justify-between px-4 py-2 backdrop-blur-md ${embedded ? "border-b border-neutral-400/15 bg-neutral-100/50 dark:bg-neutral-900/50" : "border-b"}`}
style={{
height: headerHeight,
borderColor: embedded ? undefined : "var(--color-border)",
background: embedded ? "transparent" : "color-mix(in srgb, var(--color-sidebar-bg) 80%, transparent)",
background: embedded ? undefined : "color-mix(in srgb, var(--color-sidebar-bg) 80%, transparent)",
}}
>
<div className="min-w-0 flex-1 flex items-center gap-1.5">
@ -484,10 +484,10 @@ export function ChatSessionsSidebar({
<button
type="button"
onClick={onNewSession}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition-colors cursor-pointer shrink-0 ml-1.5"
className={`flex items-center gap-1 px-2 py-1 rounded-full text-[11px] font-medium transition-all cursor-pointer shrink-0 ml-1.5 ${embedded ? "hover:bg-neutral-400/15" : ""}`}
style={{
color: "var(--color-chat-sidebar-active-text)",
background: "var(--color-chat-sidebar-active-bg)",
color: embedded ? "var(--color-text)" : "var(--color-chat-sidebar-active-text)",
background: embedded ? "transparent" : "var(--color-chat-sidebar-active-bg)",
}}
title="New chat"
>

View File

@ -372,7 +372,7 @@ export function DatabaseViewer({ dbPath, filename }: DatabaseViewerProps) {
<div className="flex items-center justify-center h-full gap-3">
<div
className="w-5 h-5 border-2 rounded-full animate-spin"
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
style={{ borderRightColor: "var(--color-border)", borderBottomColor: "var(--color-border)", borderLeftColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
/>
<span className="text-sm" style={{ color: "var(--color-text-muted)" }}>
Loading database...
@ -651,7 +651,7 @@ function TableDataPanel({
<div className="flex items-center justify-center h-32">
<div
className="w-5 h-5 border-2 rounded-full animate-spin"
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
style={{ borderRightColor: "var(--color-border)", borderBottomColor: "var(--color-border)", borderLeftColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
/>
</div>
) : data.length === 0 ? (
@ -779,7 +779,7 @@ function QueryPanel({
{queryRunning ? (
<div
className="w-3.5 h-3.5 border-2 rounded-full animate-spin"
style={{ borderColor: "rgba(255,255,255,0.3)", borderTopColor: "white" }}
style={{ borderRightColor: "rgba(255,255,255,0.3)", borderBottomColor: "rgba(255,255,255,0.3)", borderLeftColor: "rgba(255,255,255,0.3)", borderTopColor: "white" }}
/>
) : (
<PlayIcon />

View File

@ -107,17 +107,39 @@ export function DocumentView({
// Intercept workspace-internal links in read mode (delegated click handler)
const handleLinkClick = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (!onNavigate) {return;}
const target = event.target as HTMLElement;
const link = target.closest("a");
if (!link) {return;}
const href = link.getAttribute("href");
if (!href) {return;}
if (isWorkspaceLink(href)) {
if (href.startsWith("#")) {
event.preventDefault();
event.stopPropagation();
const slug = href.slice(1);
const container = (event.currentTarget as HTMLElement);
const allHeadings = Array.from(container.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;
}) ?? container.querySelector(`[id="${CSS.escape(slug)}"]`);
match?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
if (!onNavigate) {return;}
if (isWorkspaceLink(href) || (!href.startsWith("http://") && !href.startsWith("https://") && !href.startsWith("mailto:"))) {
event.preventDefault();
event.stopPropagation();
onNavigate(href);
return;
}
event.preventDefault();
event.stopPropagation();
window.open(href, "_blank", "noopener,noreferrer");
},
[onNavigate],
);

View File

@ -106,16 +106,20 @@ function FolderIcon({ open }: { open?: boolean }) {
function TableIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" fill="#42a97a" fillOpacity="0.15" stroke="#42a97a" strokeWidth="1.2" />
<path d="M1.5 6.5h13" stroke="#42a97a" strokeWidth="1.2" />
<path d="M6 6.5v7" stroke="#42a97a" strokeWidth="1.2" />
</svg>
);
}
function KanbanIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="6" height="14" x="2" y="5" rx="1" /><rect width="6" height="10" x="9" y="5" rx="1" /><rect width="6" height="16" x="16" y="3" rx="1" />
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="3.5" height="9.5" rx="1" fill="#8b7cf6" fillOpacity="0.18" stroke="#8b7cf6" strokeWidth="1.1" />
<rect x="6.25" y="2.5" width="3.5" height="6.5" rx="1" fill="#8b7cf6" fillOpacity="0.18" stroke="#8b7cf6" strokeWidth="1.1" />
<rect x="11" y="2.5" width="3.5" height="11" rx="1" fill="#8b7cf6" fillOpacity="0.18" stroke="#8b7cf6" strokeWidth="1.1" />
</svg>
);
}
@ -624,13 +628,6 @@ function DraggableNode({
</span>
)}
{/* Type badge for objects */}
{node.type === "object" && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
style={{ background: "var(--color-accent-light)", color: "var(--color-accent)" }}>
{node.defaultView === "kanban" ? "board" : "table"}
</span>
)}
</div>
{/* Children */}

View File

@ -27,16 +27,20 @@ function FolderIcon({ open }: { open?: boolean }) {
function TableIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" fill="#42a97a" fillOpacity="0.15" stroke="#42a97a" strokeWidth="1.2" />
<path d="M1.5 6.5h13" stroke="#42a97a" strokeWidth="1.2" />
<path d="M6 6.5v7" stroke="#42a97a" strokeWidth="1.2" />
</svg>
);
}
function KanbanIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="6" height="14" x="2" y="5" rx="1" /><rect width="6" height="10" x="9" y="5" rx="1" /><rect width="6" height="16" x="16" y="3" rx="1" />
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="3.5" height="9.5" rx="1" fill="#8b7cf6" fillOpacity="0.18" stroke="#8b7cf6" strokeWidth="1.1" />
<rect x="6.25" y="2.5" width="3.5" height="6.5" rx="1" fill="#8b7cf6" fillOpacity="0.18" stroke="#8b7cf6" strokeWidth="1.1" />
<rect x="11" y="2.5" width="3.5" height="11" rx="1" fill="#8b7cf6" fillOpacity="0.18" stroke="#8b7cf6" strokeWidth="1.1" />
</svg>
);
}
@ -199,18 +203,6 @@ function TreeNodeItem({
{node.name.replace(/\.md$/, "")}
</span>
{/* Type badge for objects */}
{node.type === "object" && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
}}
>
{node.defaultView === "kanban" ? "board" : "table"}
</span>
)}
</button>
{/* Children */}

View File

@ -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;

View File

@ -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 (
<>
<div
className="flex items-stretch flex-shrink-0 h-[34px]"
className="flex items-stretch shrink-0 h-[36px] relative"
style={{
background: "var(--color-surface)",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-bg)",
}}
>
<div
@ -119,7 +120,6 @@ export function TabBar({
const isActive = tab.id === activeTabId;
const isDragOver = dragOverIndex === index && dragIndex !== index;
const isHome = tab.id === HOME_TAB_ID;
return (
<button
key={tab.id}
@ -132,11 +132,10 @@ export function TabBar({
onDragOver={isHome ? undefined : (e) => 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({
</button>
);
})}
{onNewTab && (
<button
type="button"
onClick={onNewTab}
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0 self-center cursor-pointer transition-colors hover:bg-black/5 dark:hover:bg-white/5"
style={{ color: "var(--color-text-muted)" }}
title="New chat"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14" /><path d="M5 12h14" />
</svg>
</button>
)}
</div>
{rightContent && (
<div className="relative flex items-center gap-0.5 px-2 shrink-0 h-[34px]">
<div className="relative flex items-center gap-0.5 px-2 shrink-0">
{rightContent}
</div>
)}

View File

@ -434,7 +434,7 @@ export function WorkspaceSidebar({
>
{/* Header */}
<div
className="flex items-center gap-2 px-3 h-[34px] border-b"
className="flex items-center gap-2 px-3 h-[36px] border-b"
style={{ borderColor: "var(--color-border)" }}
>
{isBrowsing ? (

View File

@ -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);
}

View File

@ -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 */}
<main className="flex-1 flex flex-col min-w-0 overflow-hidden" style={{ background: "var(--color-main-bg)" }}>
<main className="flex-1 flex flex-col min-w-0 overflow-hidden" style={{ background: "var(--color-surface)" }}>
{/* Mobile top bar — always visible on mobile */}
{isMobile && (
<div
@ -1989,6 +2023,21 @@ function WorkspacePageInner() {
onCloseAll={handleTabCloseAll}
onReorder={handleTabReorder}
onTogglePin={handleTabTogglePin}
onNewTab={() => {
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 ? (
<>
<div className="relative" ref={chatPopoverRef}>
@ -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}