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:
parent
fbfdee21a5
commit
6d99d3c959
@ -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">
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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],
|
||||
);
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user