polish: refine tab bar, sidebar colors, and session loading
- Fix session load error by handling 404 gracefully - Unify sidebar backgrounds to stone-100, make main-bg fully opaque - Add divider + gap before new tab button, hide when last tab active - Make tab hover cursor default, new tab button fully rounded - Remove glass background from chat header - Replace show/hide output button with clickable step labels - Fix chat sidebar header to use solid background - Set min-height on tab bar wrapper for loading state Made-with: Cursor
This commit is contained in:
parent
6d99d3c959
commit
a62f21bffb
@ -1259,7 +1259,10 @@ function ToolStep({
|
||||
: "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span className={`break-all${status === "running" ? " animate-pulse" : ""}`}>{label}</span>
|
||||
<span
|
||||
className={`break-all${status === "running" ? " animate-pulse" : ""}${outputText && !isSingleMedia && !diffText && status === "done" ? " cursor-pointer hover:underline" : ""}`}
|
||||
onClick={outputText && !isSingleMedia && !diffText && status === "done" ? () => setShowOutput((v) => !v) : undefined}
|
||||
>{label}</span>
|
||||
{/* Exit code badge for exec tools */}
|
||||
{kind === "exec" && status === "done" && output?.exitCode !== undefined && (
|
||||
<span
|
||||
@ -1456,22 +1459,6 @@ function ToolStep({
|
||||
!isSingleMedia &&
|
||||
!diffText && (
|
||||
<div className="mt-1">
|
||||
{status === "done" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setShowOutput((v) => !v)
|
||||
}
|
||||
className="text-[11px] hover:underline cursor-pointer"
|
||||
style={{
|
||||
color: "var(--color-accent)",
|
||||
}}
|
||||
>
|
||||
{showOutput
|
||||
? "Hide output"
|
||||
: "Show output"}
|
||||
</button>
|
||||
)}
|
||||
{(showOutput || status === "running") && (
|
||||
<pre
|
||||
className="mt-1 text-[11px] font-mono rounded-lg px-2.5 py-2 overflow-x-auto whitespace-pre-wrap break-all max-h-96 overflow-y-auto leading-relaxed"
|
||||
|
||||
@ -1686,7 +1686,10 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
`/api/web-sessions/${sessionId}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load session");
|
||||
console.warn(`Session ${sessionId} not found (${response.status}), starting fresh.`);
|
||||
setMessages([]);
|
||||
setLoadingSession(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@ -2062,7 +2065,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStreaming && (
|
||||
{isStreaming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStop()}
|
||||
@ -2074,25 +2077,6 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
<rect width="10" height="10" rx="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{isStreaming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editorRef.current?.submit()}
|
||||
disabled={(editorEmpty && attachedFiles.length === 0) || loadingSession}
|
||||
className="h-7 px-3 rounded-full flex items-center gap-1.5 text-[12px] font-medium disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: !editorEmpty || attachedFiles.length > 0 ? "var(--color-accent)" : "var(--color-surface-hover)",
|
||||
color: !editorEmpty || attachedFiles.length > 0 ? "white" : "var(--color-text-muted)",
|
||||
}}
|
||||
title="Add to queue"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 10 4 15 9 20" />
|
||||
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
|
||||
</svg>
|
||||
Queue
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
@ -2180,9 +2164,6 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
{/* Header — sticky glass bar */}
|
||||
<header
|
||||
className={`${compact ? "px-3 py-2" : "px-3 py-2 md:px-6 md:py-3"} flex items-center ${isSubagentMode ? "gap-3" : "justify-between"} z-20`}
|
||||
style={{
|
||||
background: "var(--color-bg-glass)",
|
||||
}}
|
||||
>
|
||||
{isSubagentMode ? (
|
||||
<>
|
||||
|
||||
@ -232,7 +232,7 @@ export function ChatSessionsSidebar({
|
||||
const grouped = groupSessions(filteredSessions);
|
||||
|
||||
const width = mobile ? "280px" : (widthProp ?? 260);
|
||||
const headerHeight = 40;
|
||||
const headerHeight = embedded ? 36 : 40;
|
||||
const content = (
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<div
|
||||
@ -452,11 +452,12 @@ 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 border-neutral-400/15 bg-neutral-100/50 dark:bg-neutral-900/50" : "border-b"}`}
|
||||
className={`absolute top-0 left-0 right-0 z-10 flex items-center justify-between px-4 backdrop-blur-md ${embedded ? "" : "border-b"}`}
|
||||
style={{
|
||||
height: headerHeight,
|
||||
borderColor: embedded ? undefined : "var(--color-border)",
|
||||
background: embedded ? undefined : "color-mix(in srgb, var(--color-sidebar-bg) 80%, transparent)",
|
||||
background: "var(--color-sidebar-bg)",
|
||||
boxShadow: embedded ? "inset 0 -1px 0 0 var(--color-border)" : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-1.5">
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { type Tab, HOME_TAB_ID } from "@/lib/tab-state";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const Tabs = dynamic(
|
||||
() => import("@sinm/react-chrome-tabs").then((mod) => mod.Tabs),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
import { appServeUrl } from "./app-viewer";
|
||||
|
||||
type TabBarProps = {
|
||||
@ -25,6 +32,24 @@ type ContextMenuState = {
|
||||
y: number;
|
||||
} | null;
|
||||
|
||||
function tabToFaviconClass(tab: Tab): string | undefined {
|
||||
switch (tab.type) {
|
||||
case "home": return "dench-favicon-home";
|
||||
case "chat": return "dench-favicon-chat";
|
||||
case "app": return "dench-favicon-app";
|
||||
case "cron": return "dench-favicon-cron";
|
||||
case "object": return "dench-favicon-object";
|
||||
default: return "dench-favicon-file";
|
||||
}
|
||||
}
|
||||
|
||||
function tabToFavicon(tab: Tab): string | boolean | undefined {
|
||||
if (tab.icon && tab.path && /\.(png|svg|jpe?g|webp)$/i.test(tab.icon)) {
|
||||
return appServeUrl(tab.path, tab.icon);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function TabBar({
|
||||
tabs,
|
||||
activeTabId,
|
||||
@ -40,9 +65,17 @@ export function TabBar({
|
||||
rightContent,
|
||||
}: TabBarProps) {
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
setIsDark(document.documentElement.classList.contains("dark") || mq.matches);
|
||||
const handler = () => setIsDark(document.documentElement.classList.contains("dark") || mq.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
const obs = new MutationObserver(handler);
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => { mq.removeEventListener("change", handler); obs.disconnect(); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
@ -55,44 +88,36 @@ export function TabBar({
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({ tabId, x: e.clientX, y: e.clientY });
|
||||
const handleContextMenu = useCallback((tabId: string, event: MouseEvent) => {
|
||||
if (!tabId || tabId === HOME_TAB_ID) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setContextMenu({ tabId, x: event.clientX, y: event.clientY });
|
||||
}, []);
|
||||
|
||||
const handleMiddleClick = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
onClose(tabId);
|
||||
}
|
||||
}, [onClose]);
|
||||
const homeTab = tabs.find((t) => t.id === HOME_TAB_ID);
|
||||
const nonHomeTabs = useMemo(() => tabs.filter((t) => t.id !== HOME_TAB_ID), [tabs]);
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
|
||||
setDragIndex(index);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
}, []);
|
||||
const chromeTabs = useMemo(() => {
|
||||
return nonHomeTabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
title: tab.title,
|
||||
active: tab.id === activeTabId,
|
||||
favicon: tabToFavicon(tab),
|
||||
faviconClass: tabToFaviconClass(tab),
|
||||
isCloseIconVisible: !tab.pinned,
|
||||
}));
|
||||
}, [nonHomeTabs, activeTabId]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverIndex(index);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, toIndex: number) => {
|
||||
e.preventDefault();
|
||||
if (dragIndex !== null && dragIndex !== toIndex) {
|
||||
onReorder(dragIndex, toIndex);
|
||||
}
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
}, [dragIndex, onReorder]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
}, []);
|
||||
const handleActive = useCallback((id: string) => onActivate(id), [onActivate]);
|
||||
const handleClose = useCallback((id: string) => onClose(id), [onClose]);
|
||||
const handleReorder = useCallback(
|
||||
(tabId: string, _fromIndex: number, toIndex: number) => {
|
||||
const fromIndex = tabs.findIndex((t) => t.id === tabId);
|
||||
if (fromIndex >= 0 && fromIndex !== toIndex) onReorder(fromIndex, toIndex);
|
||||
},
|
||||
[tabs, onReorder],
|
||||
);
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
@ -100,94 +125,43 @@ export function TabBar({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex items-stretch shrink-0 h-[36px] relative"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex items-stretch overflow-x-auto flex-1 min-w-0"
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
>
|
||||
<div className="dench-chrome-tabs-wrapper flex items-center shrink-0 relative">
|
||||
{leftContent && (
|
||||
<div className="flex items-center px-1.5 shrink-0">
|
||||
<div className="flex items-center px-1.5 shrink-0 z-10">
|
||||
{leftContent}
|
||||
</div>
|
||||
)}
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const isDragOver = dragOverIndex === index && dragIndex !== index;
|
||||
const isHome = tab.id === HOME_TAB_ID;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
draggable={!isHome}
|
||||
onClick={() => onActivate(tab.id)}
|
||||
onMouseDown={isHome ? undefined : (e) => handleMiddleClick(e, tab.id)}
|
||||
onContextMenu={isHome ? undefined : (e) => handleContextMenu(e, tab.id)}
|
||||
onDragStart={isHome ? undefined : (e) => handleDragStart(e, index)}
|
||||
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 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-surface)" : "transparent",
|
||||
borderLeft: isDragOver && !isHome ? "2px solid var(--color-accent)" : undefined,
|
||||
opacity: dragIndex === index ? 0.5 : 1,
|
||||
maxWidth: isHome ? undefined : 200,
|
||||
}}
|
||||
title={isHome ? "Home (New Chat)" : undefined}
|
||||
>
|
||||
{isHome ? (
|
||||
<HomeIcon />
|
||||
) : (
|
||||
<>
|
||||
{tab.pinned && <PinIcon />}
|
||||
<TabIcon type={tab.type} icon={tab.icon} appPath={tab.path} />
|
||||
<span className="truncate max-w-[140px]">{tab.title}</span>
|
||||
{!tab.pinned && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => { e.stopPropagation(); onClose(tab.id); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.stopPropagation(); onClose(tab.id); } }}
|
||||
className="ml-0.5 p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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 className="flex-1 min-w-0">
|
||||
<Tabs
|
||||
darkMode={isDark}
|
||||
tabs={chromeTabs}
|
||||
draggable
|
||||
onTabActive={handleActive}
|
||||
onTabClose={handleClose}
|
||||
onTabReorder={handleReorder}
|
||||
onContextMenu={handleContextMenu}
|
||||
pinnedRight={onNewTab ? (
|
||||
<div className="flex items-center gap-1.5 ml-1.5">
|
||||
{nonHomeTabs.length > 0 && nonHomeTabs[nonHomeTabs.length - 1].id !== activeTabId && (
|
||||
<div className="w-px h-4 shrink-0" style={{ background: "var(--color-border)" }} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewTab}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-full shrink-0 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>
|
||||
) : undefined}
|
||||
/>
|
||||
</div>
|
||||
{rightContent && (
|
||||
<div className="relative flex items-center gap-0.5 px-2 shrink-0">
|
||||
<div className="flex items-center gap-0.5 px-2 shrink-0 z-10">
|
||||
{rightContent}
|
||||
</div>
|
||||
)}
|
||||
@ -196,11 +170,8 @@ export function TabBar({
|
||||
{/* Context menu */}
|
||||
{contextMenu && contextTab && (
|
||||
<div
|
||||
className="fixed z-[9999] min-w-[180px] rounded-2xl p-1 bg-neutral-100/[0.67] dark:bg-neutral-900/[0.67] border border-white dark:border-white/10 backdrop-blur-md shadow-[0_0_25px_0_rgba(0,0,0,0.16)]"
|
||||
style={{
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
}}
|
||||
className="fixed z-9999 min-w-[180px] rounded-2xl p-1 bg-neutral-100/67 dark:bg-neutral-900/67 border border-white dark:border-white/10 backdrop-blur-md shadow-[0_0_25px_0_rgba(0,0,0,0.16)]"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
<ContextMenuItem
|
||||
label={contextTab.pinned ? "Unpin Tab" : "Pin Tab"}
|
||||
@ -259,102 +230,3 @@ function ContextMenuItem({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TabIcon({ type, icon, appPath }: { type: string; icon?: string; appPath?: string }) {
|
||||
if (icon && appPath && (icon.endsWith(".png") || icon.endsWith(".svg") || icon.endsWith(".jpg") || icon.endsWith(".jpeg") || icon.endsWith(".webp"))) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={appServeUrl(appPath, icon)}
|
||||
alt=""
|
||||
width={14}
|
||||
height={14}
|
||||
className="rounded-sm flex-shrink-0"
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "home":
|
||||
return <HomeIcon />;
|
||||
case "app":
|
||||
return <AppIcon />;
|
||||
case "chat":
|
||||
return <ChatIcon />;
|
||||
case "cron":
|
||||
return <CronIcon />;
|
||||
case "object":
|
||||
return <ObjectIcon />;
|
||||
default:
|
||||
return <FileIcon />;
|
||||
}
|
||||
}
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PinIcon() {
|
||||
return (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" stroke="none" className="flex-shrink-0" style={{ opacity: 0.5 }}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.7 }}>
|
||||
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AppIcon() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatIcon() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CronIcon() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
|
||||
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectIcon() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M3 9h18" /><path d="M9 21V9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "@sinm/react-chrome-tabs/css/chrome-tabs.css";
|
||||
@import "@sinm/react-chrome-tabs/css/chrome-tabs-dark-theme.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@ -10,9 +12,9 @@
|
||||
/* Background / Surface */
|
||||
--color-bg: #f5f5f4;
|
||||
--color-surface: #ffffff;
|
||||
--color-sidebar-bg: #ffffff;
|
||||
--color-main-bg: rgba(250, 250, 249, 0.5);
|
||||
--color-surface-hover: #f5f4f1;
|
||||
--color-sidebar-bg: #f5f5f4;
|
||||
--color-main-bg: #fafaf9;
|
||||
--color-surface-hover: #ececea;
|
||||
--color-surface-raised: #ffffff;
|
||||
|
||||
/* Borders */
|
||||
@ -25,9 +27,9 @@
|
||||
--color-text-muted: #8a8a82;
|
||||
|
||||
/* Accent (blue) */
|
||||
--color-accent: rgba(37, 99, 235, 0.9);
|
||||
--color-accent-hover: #1d4ed8;
|
||||
--color-accent-light: rgba(37, 99, 235, 0.08);
|
||||
--color-accent: #4FA1EE;
|
||||
--color-accent-hover: #3b8edb;
|
||||
--color-accent-light: rgba(79, 161, 238, 0.08);
|
||||
|
||||
/* Chat */
|
||||
--color-user-bubble: #eae8e4;
|
||||
@ -115,9 +117,9 @@
|
||||
--color-text-muted: #78776f;
|
||||
|
||||
/* Accent (blue, brighter for dark) */
|
||||
--color-accent: #3b82f6;
|
||||
--color-accent-hover: #60a5fa;
|
||||
--color-accent-light: rgba(59, 130, 246, 0.12);
|
||||
--color-accent: #4FA1EE;
|
||||
--color-accent-hover: #6bb3f2;
|
||||
--color-accent-light: rgba(79, 161, 238, 0.12);
|
||||
|
||||
/* Chat */
|
||||
--color-user-bubble: #1e1e1c;
|
||||
@ -1928,23 +1930,74 @@ body {
|
||||
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;
|
||||
/* ── @sinm/react-chrome-tabs theme overrides ── */
|
||||
.dench-chrome-tabs-wrapper {
|
||||
background: var(--color-bg);
|
||||
min-height: 36px;
|
||||
}
|
||||
.dench-chrome-tabs-wrapper .chrome-tabs {
|
||||
background: var(--color-bg);
|
||||
height: 36px;
|
||||
padding: 2px 3px 0 3px;
|
||||
border-radius: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
.dench-chrome-tabs-wrapper .chrome-tabs .chrome-tab {
|
||||
height: 34px;
|
||||
cursor: default;
|
||||
}
|
||||
.dench-chrome-tabs-wrapper .chrome-tabs .chrome-tab .chrome-tab-background > svg .chrome-tab-geometry {
|
||||
fill: var(--color-bg);
|
||||
}
|
||||
.dench-chrome-tabs-wrapper .chrome-tabs .chrome-tab[active] .chrome-tab-background > svg .chrome-tab-geometry {
|
||||
fill: var(--color-surface);
|
||||
}
|
||||
.dench-chrome-tabs-wrapper .chrome-tabs .chrome-tab .chrome-tab-title {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.dench-chrome-tabs-wrapper .chrome-tabs .chrome-tab[active] .chrome-tab-title {
|
||||
color: var(--color-text);
|
||||
}
|
||||
.dench-chrome-tabs-wrapper .chrome-tabs .chrome-tab .chrome-tab-dividers::before,
|
||||
.dench-chrome-tabs-wrapper .chrome-tabs .chrome-tab .chrome-tab-dividers::after {
|
||||
background: var(--color-border);
|
||||
}
|
||||
.dench-chrome-tabs-wrapper .chrome-tabs .chrome-tabs-bottom-bar {
|
||||
background: var(--color-surface);
|
||||
height: 1px;
|
||||
}
|
||||
.dench-chrome-tabs-wrapper .chrome-tabs-optional-shadow-below-bottom-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chrome-tab-active::before {
|
||||
left: -8px;
|
||||
background: radial-gradient(circle at 0 0, transparent 7.5px, var(--color-surface) 8px);
|
||||
/* Favicon icon classes (SVG data-uri with currentColor replaced by hex) */
|
||||
.dench-favicon-home,
|
||||
.dench-favicon-chat,
|
||||
.dench-favicon-file,
|
||||
.dench-favicon-app,
|
||||
.dench-favicon-cron,
|
||||
.dench-favicon-object {
|
||||
background-size: 14px 14px !important;
|
||||
background-position: center !important;
|
||||
background-repeat: no-repeat !important;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.chrome-tab-active::after {
|
||||
right: -8px;
|
||||
background: radial-gradient(circle at 100% 0, transparent 7.5px, var(--color-surface) 8px);
|
||||
.dench-favicon-home {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z'/%3E%3Cpolyline points='9 22 9 12 15 12 15 22'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
.dench-favicon-chat {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
.dench-favicon-file {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z'/%3E%3Cpath d='M14 2v4a2 2 0 0 0 2 2h4'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
.dench-favicon-app {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='7' height='7' x='3' y='3' rx='1'/%3E%3Crect width='7' height='7' x='14' y='3' rx='1'/%3E%3Crect width='7' height='7' x='3' y='14' rx='1'/%3E%3Crect width='7' height='7' x='14' y='14' rx='1'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
.dench-favicon-cron {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpolyline points='12 6 12 12 16 14'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
.dench-favicon-object {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='18' height='18' x='3' y='3' rx='2'/%3E%3Cpath d='M3 9h18'/%3E%3Cpath d='M9 21V9'/%3E%3C/svg%3E") !important;
|
||||
}
|
||||
|
||||
@ -222,8 +222,11 @@ const LEFT_SIDEBAR_MIN = 200;
|
||||
const LEFT_SIDEBAR_MAX = 480;
|
||||
const RIGHT_SIDEBAR_MIN = 260;
|
||||
const RIGHT_SIDEBAR_MAX = 900;
|
||||
const CHAT_SIDEBAR_MIN = 220;
|
||||
const CHAT_SIDEBAR_MAX = 480;
|
||||
const STORAGE_LEFT = "dench-workspace-left-sidebar-width";
|
||||
const STORAGE_RIGHT = "dench-workspace-right-sidebar-width";
|
||||
const STORAGE_CHAT_SIDEBAR = "dench-workspace-chat-sidebar-width";
|
||||
|
||||
function clamp(n: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, n));
|
||||
@ -509,19 +512,7 @@ function WorkspacePageInner() {
|
||||
const [leftSidebarCollapsed, setLeftSidebarCollapsed] = useState(false);
|
||||
const [rightSidebarCollapsed, setRightSidebarCollapsed] = useState(false);
|
||||
const [sidebarTab, setSidebarTab] = useState<"files" | "chats">("files");
|
||||
const [chatPopoverOpen, setChatPopoverOpen] = useState(false);
|
||||
const chatPopoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatPopoverOpen) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (chatPopoverRef.current && !chatPopoverRef.current.contains(e.target as Node)) {
|
||||
setChatPopoverOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [chatPopoverOpen]);
|
||||
const [chatSidebarOpen, setChatSidebarOpen] = useState(false);
|
||||
|
||||
// Terminal drawer state
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
@ -565,6 +556,7 @@ function WorkspacePageInner() {
|
||||
// Use static defaults so server and client match on first render (avoid hydration mismatch).
|
||||
const [leftSidebarWidth, setLeftSidebarWidth] = useState(260);
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState(320);
|
||||
const [chatSidebarWidth, setChatSidebarWidth] = useState(280);
|
||||
useEffect(() => {
|
||||
const left = window.localStorage.getItem(STORAGE_LEFT);
|
||||
const nLeft = left ? parseInt(left, 10) : NaN;
|
||||
@ -576,6 +568,11 @@ function WorkspacePageInner() {
|
||||
if (Number.isFinite(nRight)) {
|
||||
setRightSidebarWidth(clamp(nRight, RIGHT_SIDEBAR_MIN, RIGHT_SIDEBAR_MAX));
|
||||
}
|
||||
const chat = window.localStorage.getItem(STORAGE_CHAT_SIDEBAR);
|
||||
const nChat = chat ? parseInt(chat, 10) : NaN;
|
||||
if (Number.isFinite(nChat)) {
|
||||
setChatSidebarWidth(clamp(nChat, CHAT_SIDEBAR_MIN, CHAT_SIDEBAR_MAX));
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(STORAGE_LEFT, String(leftSidebarWidth));
|
||||
@ -583,6 +580,9 @@ function WorkspacePageInner() {
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(STORAGE_RIGHT, String(rightSidebarWidth));
|
||||
}, [rightSidebarWidth]);
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(STORAGE_CHAT_SIDEBAR, String(chatSidebarWidth));
|
||||
}, [chatSidebarWidth]);
|
||||
|
||||
// Keyboard shortcuts: Cmd+B = toggle left sidebar, Cmd+Shift+B = toggle right sidebar, Cmd+J = toggle terminal
|
||||
useEffect(() => {
|
||||
@ -1831,6 +1831,9 @@ function WorkspacePageInner() {
|
||||
|
||||
// Whether to show the main ChatPanel (no file/content selected)
|
||||
const showMainChat = !activePath || content.kind === "none";
|
||||
const showDesktopMainChatSidebar = !isMobile && showMainChat && chatSidebarOpen;
|
||||
const showDesktopFileChatSidebar =
|
||||
!isMobile && !showMainChat && fileContext && showChatSidebar && !rightSidebarCollapsed;
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
@ -1956,338 +1959,327 @@ function WorkspacePageInner() {
|
||||
|
||||
{/* Main content */}
|
||||
<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
|
||||
className="px-3 py-2 border-b flex-shrink-0 flex items-center justify-between gap-2"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 rounded-lg flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Open sidebar"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="4" x2="20" y1="12" y2="12" /><line x1="4" x2="20" y1="6" y2="6" /><line x1="4" x2="20" y1="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0 text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
|
||||
{activePath ? activePath.split("/").pop() : (context?.organization?.name || "Workspace")}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{activePath && content.kind !== "none" && (
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Mobile top bar — always visible on mobile */}
|
||||
{isMobile && (
|
||||
<div
|
||||
className="px-3 py-2 border-b flex-shrink-0 flex items-center justify-between gap-2"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
}}
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 rounded-lg flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Back to chat"
|
||||
title="Open sidebar"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="4" x2="20" y1="12" y2="12" /><line x1="4" x2="20" y1="6" y2="6" /><line x1="4" x2="20" y1="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab bar (desktop only, always visible -- home tab is always present) */}
|
||||
{!isMobile && (
|
||||
<TabBar
|
||||
tabs={tabState.tabs}
|
||||
activeTabId={tabState.activeTabId}
|
||||
onActivate={handleTabActivate}
|
||||
leftContent={leftSidebarCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLeftSidebarCollapsed(false)}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/5 cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Show sidebar (⌘B)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
</svg>
|
||||
</button>
|
||||
) : undefined}
|
||||
onClose={handleTabClose}
|
||||
onCloseOthers={handleTabCloseOthers}
|
||||
onCloseToRight={handleTabCloseToRight}
|
||||
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}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChatPopoverOpen((v) => !v)}
|
||||
className="p-1.5 rounded-lg cursor-pointer"
|
||||
style={{ color: chatPopoverOpen ? "var(--color-accent)" : "var(--color-text-muted)" }}
|
||||
title="Chat history"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
{chatPopoverOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1.5 w-72 h-96 rounded-2xl overflow-hidden z-[9999] bg-neutral-100/[0.67] dark:bg-neutral-900/[0.67] border border-white dark:border-white/10 backdrop-blur-md shadow-[0_0_25px_0_rgba(0,0,0,0.16)] flex flex-col"
|
||||
<div className="flex-1 min-w-0 text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
|
||||
{activePath ? activePath.split("/").pop() : (context?.organization?.name || "Workspace")}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{activePath && content.kind !== "none" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
}}
|
||||
className="p-2 rounded-lg flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Back to chat"
|
||||
>
|
||||
<ChatSessionsSidebar
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
activeSessionTitle={activeSessionTitle}
|
||||
streamingSessionIds={streamingSessionIds}
|
||||
subagents={subagents}
|
||||
activeSubagentKey={activeSubagentKey}
|
||||
loading={sessionsLoading}
|
||||
onSelectSession={(sessionId) => {
|
||||
setActiveSessionId(sessionId);
|
||||
setActiveSubagentKey(null);
|
||||
void chatRef.current?.loadSession(sessionId);
|
||||
setChatPopoverOpen(false);
|
||||
}}
|
||||
onNewSession={() => {
|
||||
setActiveSessionId(null);
|
||||
setActiveSubagentKey(null);
|
||||
void chatRef.current?.newSession();
|
||||
setChatPopoverOpen(false);
|
||||
}}
|
||||
onSelectSubagent={(key) => {
|
||||
handleSelectSubagent(key);
|
||||
setChatPopoverOpen(false);
|
||||
}}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onRenameSession={handleRenameSession}
|
||||
embedded
|
||||
/>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{activeSessionId && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="p-1.5 rounded-lg cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="More options"
|
||||
aria-label="More options"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="1" /><circle cx="5" cy="12" r="1" /><circle cx="19" cy="12" r="1" />
|
||||
</svg>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom">
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => handleDeleteSession(activeSessionId)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /></svg>
|
||||
Delete this chat
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveSessionId(null);
|
||||
setActiveSubagentKey(null);
|
||||
void chatRef.current?.newSession();
|
||||
}}
|
||||
className="p-1.5 rounded-lg cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="New chat"
|
||||
>
|
||||
<svg width="14" height="14" 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>
|
||||
</>
|
||||
) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* When a file is selected: show top bar with breadcrumbs (desktop only, mobile has unified top bar) */}
|
||||
{!isMobile && activePath && content.kind !== "none" && (
|
||||
<div
|
||||
className="px-6 border-b flex-shrink-0 flex items-center justify-between"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<Breadcrumbs
|
||||
path={activePath}
|
||||
onNavigate={handleBreadcrumbNavigate}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Back to chat button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
{/* Tab bar (desktop only, always visible -- home tab is always present) */}
|
||||
{!isMobile && (
|
||||
<TabBar
|
||||
tabs={tabState.tabs}
|
||||
activeTabId={tabState.activeTabId}
|
||||
onActivate={handleTabActivate}
|
||||
leftContent={leftSidebarCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLeftSidebarCollapsed(false)}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/5 cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Show sidebar (⌘B)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
</svg>
|
||||
</button>
|
||||
) : undefined}
|
||||
onClose={handleTabClose}
|
||||
onCloseOthers={handleTabCloseOthers}
|
||||
onCloseToRight={handleTabCloseToRight}
|
||||
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();
|
||||
});
|
||||
}}
|
||||
className="p-1.5 rounded-lg flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Back to chat"
|
||||
rightContent={showMainChat ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChatSidebarOpen((v) => !v)}
|
||||
className="p-1.5 rounded-lg cursor-pointer"
|
||||
style={{
|
||||
color: chatSidebarOpen ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
background: chatSidebarOpen ? "var(--color-surface-hover)" : "transparent",
|
||||
}}
|
||||
title="Chat history"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
{activeSessionId && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="p-1.5 rounded-lg cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="More options"
|
||||
aria-label="More options"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="1" /><circle cx="5" cy="12" r="1" /><circle cx="19" cy="12" r="1" />
|
||||
</svg>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom">
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => handleDeleteSession(activeSessionId)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /></svg>
|
||||
Delete this chat
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* When a file is selected: show top bar with breadcrumbs (desktop only, mobile has unified top bar) */}
|
||||
{!isMobile && activePath && content.kind !== "none" && (
|
||||
<div
|
||||
className="px-6 border-b flex-shrink-0 flex items-center justify-between"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Chat sidebar toggle (hidden for reserved/virtual paths) */}
|
||||
{fileContext && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowChatSidebar((v) => !v)}
|
||||
className="p-1.5 rounded-lg flex-shrink-0"
|
||||
style={{
|
||||
color: showChatSidebar ? "var(--color-accent)" : "var(--color-text-muted)",
|
||||
background: showChatSidebar ? "var(--color-accent-light)" : "transparent",
|
||||
}}
|
||||
title={showChatSidebar ? "Hide chat" : fileContext.isDirectory ? "Chat about this folder" : "Chat about this file"}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<Breadcrumbs
|
||||
path={activePath}
|
||||
onNavigate={handleBreadcrumbNavigate}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Back to chat button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
}}
|
||||
className="p-1.5 rounded-lg flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Back to chat"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Chat sidebar toggle (hidden for reserved/virtual paths) */}
|
||||
{fileContext && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowChatSidebar((v) => !v)}
|
||||
className="p-1.5 rounded-lg flex-shrink-0"
|
||||
style={{
|
||||
color: showChatSidebar ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
background: showChatSidebar ? "var(--color-surface-hover)" : "transparent",
|
||||
}}
|
||||
title={showChatSidebar ? "Hide chat" : fileContext.isDirectory ? "Chat about this folder" : "Chat about this file"}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 flex min-h-0">
|
||||
{showMainChat ? (
|
||||
<div className="flex-1 flex flex-col min-w-0" style={{ background: "var(--color-main-bg)" }}>
|
||||
<ChatPanel
|
||||
key={activeSubagent?.childSessionKey ?? "main"}
|
||||
ref={activeSubagent ? undefined : chatRef}
|
||||
sessionTitle={activeSessionTitle}
|
||||
initialSessionId={activeSessionId ?? undefined}
|
||||
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}
|
||||
onSubagentClick={handleSubagentClickFromChat}
|
||||
onFilePathClick={handleFilePathClickFromChat}
|
||||
onDeleteSession={activeSubagent ? undefined : handleDeleteSession}
|
||||
onRenameSession={activeSubagent ? undefined : handleRenameSession}
|
||||
compact={isMobile}
|
||||
sessionKey={activeSubagent?.childSessionKey}
|
||||
subagentTask={activeSubagent?.task}
|
||||
subagentLabel={activeSubagent?.label}
|
||||
onBack={activeSubagent ? handleBackFromSubagent : undefined}
|
||||
hideHeaderActions={!isMobile}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ContentRenderer
|
||||
content={content}
|
||||
workspaceExists={workspaceExists}
|
||||
expectedPath={workspaceRoot}
|
||||
tree={tree}
|
||||
activePath={activePath}
|
||||
browseDir={browseDir}
|
||||
treeLoading={treeLoading}
|
||||
members={context?.members}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onNavigateToObject={handleNavigateToObject}
|
||||
onRefreshObject={refreshCurrentObject}
|
||||
onRefreshTree={refreshTree}
|
||||
onNavigate={handleEditorNavigate}
|
||||
onOpenEntry={handleOpenEntry}
|
||||
searchFn={searchIndex}
|
||||
onSelectCronJob={handleSelectCronJob}
|
||||
onBackToCronDashboard={handleBackToCronDashboard}
|
||||
cronView={cronView}
|
||||
onCronViewChange={setCronView}
|
||||
cronCalMode={cronCalMode}
|
||||
onCronCalModeChange={setCronCalMode}
|
||||
cronDate={cronDate}
|
||||
onCronDateChange={setCronDate}
|
||||
cronRunFilter={cronRunFilter}
|
||||
onCronRunFilterChange={setCronRunFilter}
|
||||
cronRun={cronRun}
|
||||
onCronRunChange={setCronRun}
|
||||
onSendCommand={handleCronSendCommand}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 flex min-h-0">
|
||||
{showMainChat ? (
|
||||
/* Main chat view (default when no file is selected) */
|
||||
<>
|
||||
<div className="flex-1 flex flex-col min-w-0" style={{ background: "var(--color-main-bg)" }}>
|
||||
<ChatPanel
|
||||
key={activeSubagent?.childSessionKey ?? "main"}
|
||||
ref={activeSubagent ? undefined : chatRef}
|
||||
sessionTitle={activeSessionTitle}
|
||||
initialSessionId={activeSessionId ?? undefined}
|
||||
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}
|
||||
onSubagentClick={handleSubagentClickFromChat}
|
||||
onFilePathClick={handleFilePathClickFromChat}
|
||||
onDeleteSession={activeSubagent ? undefined : handleDeleteSession}
|
||||
onRenameSession={activeSubagent ? undefined : handleRenameSession}
|
||||
compact={isMobile}
|
||||
sessionKey={activeSubagent?.childSessionKey}
|
||||
subagentTask={activeSubagent?.task}
|
||||
subagentLabel={activeSubagent?.label}
|
||||
onBack={activeSubagent ? handleBackFromSubagent : undefined}
|
||||
hideHeaderActions={!isMobile}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* File content area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ContentRenderer
|
||||
content={content}
|
||||
workspaceExists={workspaceExists}
|
||||
expectedPath={workspaceRoot}
|
||||
tree={tree}
|
||||
activePath={activePath}
|
||||
browseDir={browseDir}
|
||||
treeLoading={treeLoading}
|
||||
members={context?.members}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onNavigateToObject={handleNavigateToObject}
|
||||
onRefreshObject={refreshCurrentObject}
|
||||
onRefreshTree={refreshTree}
|
||||
onNavigate={handleEditorNavigate}
|
||||
onOpenEntry={handleOpenEntry}
|
||||
searchFn={searchIndex}
|
||||
onSelectCronJob={handleSelectCronJob}
|
||||
onBackToCronDashboard={handleBackToCronDashboard}
|
||||
cronView={cronView}
|
||||
onCronViewChange={setCronView}
|
||||
cronCalMode={cronCalMode}
|
||||
onCronCalModeChange={setCronCalMode}
|
||||
cronDate={cronDate}
|
||||
onCronDateChange={setCronDate}
|
||||
cronRunFilter={cronRunFilter}
|
||||
onCronRunFilterChange={setCronRunFilter}
|
||||
cronRun={cronRun}
|
||||
onCronRunChange={setCronRun}
|
||||
onSendCommand={handleCronSendCommand}
|
||||
/>
|
||||
</div>
|
||||
{showDesktopMainChatSidebar && (
|
||||
<aside
|
||||
className="flex-shrink-0 min-h-0 border-l flex flex-col relative"
|
||||
style={{
|
||||
width: chatSidebarWidth,
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-bg)",
|
||||
}}
|
||||
>
|
||||
<ResizeHandle
|
||||
mode="right"
|
||||
containerRef={layoutRef}
|
||||
min={CHAT_SIDEBAR_MIN}
|
||||
max={CHAT_SIDEBAR_MAX}
|
||||
onResize={setChatSidebarWidth}
|
||||
/>
|
||||
<ChatSessionsSidebar
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
activeSessionTitle={activeSessionTitle}
|
||||
streamingSessionIds={streamingSessionIds}
|
||||
subagents={subagents}
|
||||
activeSubagentKey={activeSubagentKey}
|
||||
loading={sessionsLoading}
|
||||
onSelectSession={(sessionId) => {
|
||||
setActiveSessionId(sessionId);
|
||||
setActiveSubagentKey(null);
|
||||
void chatRef.current?.loadSession(sessionId);
|
||||
}}
|
||||
onNewSession={() => {
|
||||
setActiveSessionId(null);
|
||||
setActiveSubagentKey(null);
|
||||
void chatRef.current?.newSession();
|
||||
}}
|
||||
onSelectSubagent={handleSelectSubagent}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onRenameSession={handleRenameSession}
|
||||
embedded
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Chat sidebar (file/folder-scoped) — hidden for reserved paths, hidden on mobile */}
|
||||
{!isMobile && fileContext && showChatSidebar && !rightSidebarCollapsed && (
|
||||
<>
|
||||
<aside
|
||||
className="flex-shrink-0 border-l flex flex-col relative"
|
||||
style={{
|
||||
width: rightSidebarWidth,
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-bg)",
|
||||
}}
|
||||
>
|
||||
<ResizeHandle
|
||||
mode="right"
|
||||
containerRef={layoutRef}
|
||||
min={RIGHT_SIDEBAR_MIN}
|
||||
max={RIGHT_SIDEBAR_MAX}
|
||||
onResize={setRightSidebarWidth}
|
||||
/>
|
||||
<ChatPanel
|
||||
ref={compactChatRef}
|
||||
compact
|
||||
fileContext={fileContext}
|
||||
initialSessionId={fileChatSessionId ?? undefined}
|
||||
onFileChanged={handleFileChanged}
|
||||
onFilePathClick={handleFilePathClickFromChat}
|
||||
onActiveSessionChange={setFileChatSessionId}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{showDesktopFileChatSidebar && (
|
||||
<aside
|
||||
className="flex-shrink-0 min-h-0 border-l flex flex-col relative"
|
||||
style={{
|
||||
width: rightSidebarWidth,
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-bg)",
|
||||
}}
|
||||
>
|
||||
<ResizeHandle
|
||||
mode="right"
|
||||
containerRef={layoutRef}
|
||||
min={RIGHT_SIDEBAR_MIN}
|
||||
max={RIGHT_SIDEBAR_MAX}
|
||||
onResize={setRightSidebarWidth}
|
||||
/>
|
||||
<ChatPanel
|
||||
ref={compactChatRef}
|
||||
compact
|
||||
fileContext={fileContext}
|
||||
initialSessionId={fileChatSessionId ?? undefined}
|
||||
onFileChanged={handleFileChanged}
|
||||
onFilePathClick={handleFilePathClickFromChat}
|
||||
onActiveSessionChange={setFileChatSessionId}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@sinm/react-chrome-tabs": "^2.6.3",
|
||||
"@tanstack/match-sorter-utils": "^8.19.4",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tiptap/core": "^3.19.0",
|
||||
@ -51,6 +52,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"draggabilly": "^3.0.0",
|
||||
"framer-motion": "^12.34.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
|
||||
887
pnpm-lock.yaml
generated
887
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user