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:
Mark 2026-03-12 17:42:57 -07:00
parent 6d99d3c959
commit a62f21bffb
8 changed files with 1363 additions and 648 deletions

View File

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

View File

@ -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 ? (
<>

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff