🚀 RELEASE: new chat

This commit is contained in:
kumarabhirup 2026-02-13 19:24:24 -08:00
parent dbde31ec20
commit c8fb0280ea
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
7 changed files with 468 additions and 109 deletions

View File

@ -443,6 +443,8 @@ type ChatPanelProps = {
fileContext?: FileContext; fileContext?: FileContext;
/** Compact mode for workspace sidebar (smaller UI, built-in session tabs). */ /** Compact mode for workspace sidebar (smaller UI, built-in session tabs). */
compact?: boolean; compact?: boolean;
/** Override the header title when a session is active (e.g. show the session's actual title). */
sessionTitle?: string;
/** Called when file content may have changed after agent edits. */ /** Called when file content may have changed after agent edits. */
onFileChanged?: (newContent: string) => void; onFileChanged?: (newContent: string) => void;
/** Called when active session changes (for external sidebar highlighting). */ /** Called when active session changes (for external sidebar highlighting). */
@ -456,6 +458,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
{ {
fileContext, fileContext,
compact, compact,
sessionTitle,
onFileChanged, onFileChanged,
onActiveSessionChange, onActiveSessionChange,
onSessionsChange, onSessionsChange,
@ -1155,7 +1158,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
}} }}
> >
{currentSessionId {currentSessionId
? "Chat Session" ? (sessionTitle || "Chat Session")
: "New Chat"} : "New Chat"}
</h2> </h2>
<p <p
@ -1384,6 +1387,26 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
"var(--color-chat-input-bg)", "var(--color-chat-input-bg)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
}} }}
onDragOver={(e) => {
if (e.dataTransfer?.types.includes("application/x-file-mention")) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
}}
onDrop={(e) => {
const data = e.dataTransfer?.getData("application/x-file-mention");
if (!data) {return;}
e.preventDefault();
e.stopPropagation();
try {
const { name, path } = JSON.parse(data) as { name: string; path: string };
if (name && path) {
editorRef.current?.insertFileMention(name, path);
}
} catch {
// ignore malformed data
}
}}
> >
<ChatEditor <ChatEditor
ref={editorRef} ref={editorRef}

View File

@ -7,10 +7,11 @@ import {
useRef, useRef,
} from "react"; } from "react";
import { useEditor, EditorContent } from "@tiptap/react"; import { useEditor, EditorContent } from "@tiptap/react";
import type { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder"; import Placeholder from "@tiptap/extension-placeholder";
import Suggestion from "@tiptap/suggestion"; import Suggestion from "@tiptap/suggestion";
import { Extension, type Editor } from "@tiptap/core"; import { Extension } from "@tiptap/core";
import { FileMentionNode, chatFileMentionPluginKey } from "./file-mention-extension"; import { FileMentionNode, chatFileMentionPluginKey } from "./file-mention-extension";
import { import {
createFileMentionRenderer, createFileMentionRenderer,
@ -193,6 +194,10 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
const submitRef = useRef(onSubmit); const submitRef = useRef(onSubmit);
submitRef.current = onSubmit; submitRef.current = onSubmit;
// Ref to access the TipTap editor from within ProseMirror's handleDOMEvents
// (the handlers are defined at useEditor() call time, before the editor exists).
const editorRefInternal = useRef<Editor | null>(null);
const editor = useEditor({ const editor = useEditor({
immediatelyRender: false, immediatelyRender: false,
extensions: [ extensions: [
@ -225,12 +230,61 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
} }
return false; return false;
}, },
// Handle drag-and-drop of files from the sidebar.
// Using handleDOMEvents ensures our handler runs BEFORE
// ProseMirror's built-in drop processing, which would
// otherwise consume the event or insert the text/plain
// fallback data as raw text.
handleDOMEvents: {
dragover: (_view, event) => {
const de = event;
if (de.dataTransfer?.types.includes("application/x-file-mention")) {
de.preventDefault();
de.dataTransfer.dropEffect = "copy";
return true;
}
return false;
},
drop: (_view, event) => {
const de = event;
const data = de.dataTransfer?.getData("application/x-file-mention");
if (!data) {return false;}
de.preventDefault();
de.stopPropagation();
try {
const { name, path } = JSON.parse(data) as { name: string; path: string };
if (name && path) {
editorRefInternal.current
?.chain()
.focus()
.insertContent([
{
type: "chatFileMention",
attrs: { label: name, path },
},
{ type: "text", text: " " },
])
.run();
}
} catch {
// ignore malformed data
}
return true;
},
},
}, },
onUpdate: ({ editor: ed }) => { onUpdate: ({ editor: ed }) => {
onChange?.(ed.isEmpty); onChange?.(ed.isEmpty);
}, },
}); });
// Keep internal ref in sync so handleDOMEvents handlers can access the editor
useEffect(() => {
editorRefInternal.current = editor ?? null;
}, [editor]);
// Handle Enter-to-submit via a keydown listener on the editor DOM // Handle Enter-to-submit via a keydown listener on the editor DOM
useEffect(() => { useEffect(() => {
if (!editor) {return;} if (!editor) {return;}
@ -255,53 +309,6 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
return () => el.removeEventListener("keydown", handleKeyDown); return () => el.removeEventListener("keydown", handleKeyDown);
}, [editor]); }, [editor]);
// Handle drag-and-drop of files from the sidebar
useEffect(() => {
if (!editor) {return;}
const el = editor.view.dom;
const handleDragOver = (e: DragEvent) => {
if (e.dataTransfer?.types.includes("application/x-file-mention")) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
};
const handleDrop = (e: DragEvent) => {
const data = e.dataTransfer?.getData("application/x-file-mention");
if (!data) {return;}
e.preventDefault();
e.stopPropagation();
try {
const { name, path } = JSON.parse(data);
if (name && path) {
editor
.chain()
.focus()
.insertContent([
{
type: "chatFileMention",
attrs: { label: name, path },
},
{ type: "text", text: " " },
])
.run();
}
} catch {
// ignore malformed data
}
};
el.addEventListener("dragover", handleDragOver);
el.addEventListener("drop", handleDrop);
return () => {
el.removeEventListener("dragover", handleDragOver);
el.removeEventListener("drop", handleDrop);
};
}, [editor]);
// Disable/enable editor // Disable/enable editor
useEffect(() => { useEffect(() => {
if (editor) { if (editor) {

View File

@ -6,6 +6,7 @@ type BreadcrumbsProps = {
}; };
export function Breadcrumbs({ path, onNavigate }: BreadcrumbsProps) { export function Breadcrumbs({ path, onNavigate }: BreadcrumbsProps) {
const isAbsolute = path.startsWith("/");
const segments = path.split("/").filter(Boolean); const segments = path.split("/").filter(Boolean);
return ( return (
@ -30,7 +31,7 @@ export function Breadcrumbs({ path, onNavigate }: BreadcrumbsProps) {
</button> </button>
{segments.map((segment, idx) => { {segments.map((segment, idx) => {
const partialPath = segments.slice(0, idx + 1).join("/"); const partialPath = (isAbsolute ? "/" : "") + segments.slice(0, idx + 1).join("/");
const isLast = idx === segments.length - 1; const isLast = idx === segments.length - 1;
return ( return (

View File

@ -0,0 +1,254 @@
"use client";
import { useCallback, useState } from "react";
type WebSession = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messageCount: number;
};
type ChatSessionsSidebarProps = {
sessions: WebSession[];
activeSessionId: string | null;
/** Title of the currently active session (shown in the header). */
activeSessionTitle?: string;
onSelectSession: (sessionId: string) => void;
onNewSession: () => void;
};
/** Format a timestamp into a human-readable relative time string. */
function timeAgo(ts: number): string {
const now = Date.now();
const diff = now - ts;
const seconds = Math.floor(diff / 1000);
if (seconds < 60) {return "just now";}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {return `${minutes}m ago`;}
const hours = Math.floor(minutes / 60);
if (hours < 24) {return `${hours}h ago`;}
const days = Math.floor(hours / 24);
if (days < 30) {return `${days}d ago`;}
const months = Math.floor(days / 30);
if (months < 12) {return `${months}mo ago`;}
return `${Math.floor(months / 12)}y ago`;
}
function PlusIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
);
}
function ChatBubbleIcon() {
return (
<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>
);
}
export function ChatSessionsSidebar({
sessions,
activeSessionId,
activeSessionTitle,
onSelectSession,
onNewSession,
}: ChatSessionsSidebarProps) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
const handleSelect = useCallback(
(id: string) => {
onSelectSession(id);
},
[onSelectSession],
);
// Group sessions: today, yesterday, this week, this month, older
const grouped = groupSessions(sessions);
return (
<aside
className="flex flex-col h-full border-l flex-shrink-0"
style={{
width: 260,
borderColor: "var(--color-border)",
background: "var(--color-surface)",
}}
>
<div
className="flex items-center justify-between px-6 py-4 border-b flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<div className="min-w-0 flex-1">
<span
className="text-sm font-medium truncate block"
style={{ color: "var(--color-text)" }}
>
Chats
</span>
</div>
<button
type="button"
onClick={onNewSession}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer flex-shrink-0 ml-2"
style={{
color: "var(--color-accent)",
background: "var(--color-accent-light)",
}}
title="New chat"
>
<PlusIcon />
New
</button>
</div>
{/* Session list */}
<div className="flex-1 overflow-y-auto">
{sessions.length === 0 ? (
<div className="px-4 py-8 text-center">
<div
className="mx-auto w-10 h-10 rounded-xl flex items-center justify-center mb-3"
style={{
background: "var(--color-surface-hover)",
color: "var(--color-text-muted)",
}}
>
<ChatBubbleIcon />
</div>
<p
className="text-xs"
style={{ color: "var(--color-text-muted)" }}
>
No conversations yet.
<br />
Start a new chat to begin.
</p>
</div>
) : (
<div className="px-2 py-1">
{grouped.map((group) => (
<div key={group.label}>
<div
className="px-2 pt-3 pb-1 text-[10px] font-medium uppercase tracking-wider"
style={{ color: "var(--color-text-muted)" }}
>
{group.label}
</div>
{group.sessions.map((session) => {
const isActive = session.id === activeSessionId;
const isHovered = session.id === hoveredId;
return (
<button
key={session.id}
type="button"
onClick={() => handleSelect(session.id)}
onMouseEnter={() => setHoveredId(session.id)}
onMouseLeave={() => setHoveredId(null)}
className="w-full text-left px-2 py-2 rounded-lg transition-colors cursor-pointer"
style={{
background: isActive
? "var(--color-accent-light)"
: isHovered
? "var(--color-surface-hover)"
: "transparent",
}}
>
<div
className="text-xs font-medium truncate"
style={{
color: isActive
? "var(--color-accent)"
: "var(--color-text)",
}}
>
{session.title || "Untitled chat"}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span
className="text-[10px]"
style={{ color: "var(--color-text-muted)" }}
>
{timeAgo(session.updatedAt)}
</span>
{session.messageCount > 0 && (
<span
className="text-[10px]"
style={{ color: "var(--color-text-muted)" }}
>
{session.messageCount} msg{session.messageCount !== 1 ? "s" : ""}
</span>
)}
</div>
</button>
);
})}
</div>
))}
</div>
)}
</div>
</aside>
);
}
// ── Grouping helpers ──
type SessionGroup = {
label: string;
sessions: WebSession[];
};
function groupSessions(sessions: WebSession[]): SessionGroup[] {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const yesterdayStart = todayStart - 86400000;
const weekStart = todayStart - 7 * 86400000;
const monthStart = todayStart - 30 * 86400000;
const today: WebSession[] = [];
const yesterday: WebSession[] = [];
const thisWeek: WebSession[] = [];
const thisMonth: WebSession[] = [];
const older: WebSession[] = [];
for (const s of sessions) {
const t = s.updatedAt;
if (t >= todayStart) {today.push(s);}
else if (t >= yesterdayStart) {yesterday.push(s);}
else if (t >= weekStart) {thisWeek.push(s);}
else if (t >= monthStart) {thisMonth.push(s);}
else {older.push(s);}
}
const groups: SessionGroup[] = [];
if (today.length > 0) {groups.push({ label: "Today", sessions: today });}
if (yesterday.length > 0) {groups.push({ label: "Yesterday", sessions: yesterday });}
if (thisWeek.length > 0) {groups.push({ label: "This Week", sessions: thisWeek });}
if (thisMonth.length > 0) {groups.push({ label: "This Month", sessions: thisMonth });}
if (older.length > 0) {groups.push({ label: "Older", sessions: older });}
return groups;
}

View File

@ -50,6 +50,8 @@ type FileManagerTreeProps = {
onNavigateUp?: () => void; onNavigateUp?: () => void;
/** Current browse directory (absolute path), or null when in workspace mode. */ /** Current browse directory (absolute path), or null when in workspace mode. */
browseDir?: string | null; browseDir?: string | null;
/** Absolute path of the workspace root. Nodes matching this path are rendered as a special non-collapsible workspace entry point. */
workspaceRoot?: string | null;
}; };
// --- System file detection (client-side mirror) --- // --- System file detection (client-side mirror) ---
@ -154,6 +156,17 @@ function ChevronIcon({ open }: { open: boolean }) {
); );
} }
function WorkspaceGridIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>
);
}
function NodeIcon({ node, open }: { node: TreeNode; open?: boolean }) { function NodeIcon({ node, open }: { node: TreeNode; open?: boolean }) {
// Chat items use the chat bubble icon // Chat items use the chat bubble icon
if (node.path.startsWith("~chats/") || node.path === "~chats") { if (node.path.startsWith("~chats/") || node.path === "~chats") {
@ -365,6 +378,7 @@ function DraggableNode({
onContextMenu, onContextMenu,
compact, compact,
dragOverPath, dragOverPath,
workspaceRoot,
}: { }: {
node: TreeNode; node: TreeNode;
depth: number; depth: number;
@ -381,16 +395,19 @@ function DraggableNode({
onContextMenu: (e: React.MouseEvent, node: TreeNode) => void; onContextMenu: (e: React.MouseEvent, node: TreeNode) => void;
compact?: boolean; compact?: boolean;
dragOverPath: string | null; dragOverPath: string | null;
workspaceRoot?: string | null;
}) { }) {
// Workspace root in browse mode: non-expandable entry point back to workspace
const isWorkspaceRoot = !!workspaceRoot && node.path === workspaceRoot;
const hasChildren = node.children && node.children.length > 0; const hasChildren = node.children && node.children.length > 0;
const isExpandable = hasChildren || node.type === "folder" || node.type === "object"; const isExpandable = isWorkspaceRoot ? false : (hasChildren || node.type === "folder" || node.type === "object");
const isExpanded = expandedPaths.has(node.path); const isExpanded = isWorkspaceRoot ? false : expandedPaths.has(node.path);
const isActive = activePath === node.path; const isActive = activePath === node.path;
const isSelected = selectedPath === node.path; const isSelected = selectedPath === node.path;
const isRenaming = renamingPath === node.path; const isRenaming = renamingPath === node.path;
const isSysFile = isSystemFile(node.path); const isSysFile = isSystemFile(node.path);
const isVirtual = isVirtualNode(node); const isVirtual = isVirtualNode(node);
const isProtected = isSysFile || isVirtual; const isProtected = isSysFile || isVirtual || isWorkspaceRoot;
const isDragOver = dragOverPath === node.path && isExpandable; const isDragOver = dragOverPath === node.path && isExpandable;
const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({ const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({
@ -465,37 +482,53 @@ function DraggableNode({
className="w-full flex items-center gap-1.5 py-1 px-2 rounded-md text-left text-sm transition-colors duration-100 cursor-pointer select-none" className="w-full flex items-center gap-1.5 py-1 px-2 rounded-md text-left text-sm transition-colors duration-100 cursor-pointer select-none"
style={{ style={{
paddingLeft: `${depth * 16 + 8}px`, paddingLeft: `${depth * 16 + 8}px`,
background: showDropHighlight background: isWorkspaceRoot
? "var(--color-accent-light)" ? "var(--color-accent-light)"
: isSelected : showDropHighlight
? "var(--color-surface-hover)" ? "var(--color-accent-light)"
: isActive : isSelected
? "var(--color-surface-hover)" ? "var(--color-surface-hover)"
: "transparent", : isActive
color: isActive || isSelected ? "var(--color-text)" : "var(--color-text-muted)", ? "var(--color-surface-hover)"
outline: showDropHighlight ? "1px dashed var(--color-accent)" : "none", : "transparent",
color: isWorkspaceRoot
? "var(--color-accent)"
: isActive || isSelected ? "var(--color-text)" : "var(--color-text-muted)",
outline: isWorkspaceRoot
? "1.5px solid var(--color-accent)"
: showDropHighlight ? "1px dashed var(--color-accent)" : "none",
outlineOffset: "-1px", outlineOffset: "-1px",
borderRadius: "6px", borderRadius: isWorkspaceRoot ? "8px" : "6px",
marginTop: isWorkspaceRoot ? "2px" : undefined,
marginBottom: isWorkspaceRoot ? "2px" : undefined,
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!isActive && !isSelected && !showDropHighlight) { if (isWorkspaceRoot) {
(e.currentTarget as HTMLElement).style.opacity = "0.8";
} else if (!isActive && !isSelected && !showDropHighlight) {
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
} }
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (!isActive && !isSelected && !showDropHighlight) { if (isWorkspaceRoot) {
(e.currentTarget as HTMLElement).style.opacity = "1";
} else if (!isActive && !isSelected && !showDropHighlight) {
(e.currentTarget as HTMLElement).style.background = "transparent"; (e.currentTarget as HTMLElement).style.background = "transparent";
} }
}} }}
> >
{/* Expand/collapse chevron */} {/* Expand/collapse chevron intercept click so it only toggles without navigating */}
<span className="flex-shrink-0 w-4 h-4 flex items-center justify-center" style={{ opacity: isExpandable ? 1 : 0 }}> <span
className="flex-shrink-0 w-4 h-4 flex items-center justify-center"
style={{ opacity: isExpandable ? 1 : 0, cursor: isExpandable ? "pointer" : undefined }}
onClick={isExpandable ? (e) => { e.stopPropagation(); onToggleExpand(node.path); } : undefined}
>
{isExpandable && <ChevronIcon open={isExpanded} />} {isExpandable && <ChevronIcon open={isExpanded} />}
</span> </span>
{/* Icon */} {/* Icon */}
<span className="flex-shrink-0 flex items-center" style={{ color: typeColor(node) }}> <span className="flex-shrink-0 flex items-center" style={{ color: isWorkspaceRoot ? "var(--color-accent)" : typeColor(node) }}>
<NodeIcon node={node} open={isExpanded} /> {isWorkspaceRoot ? <WorkspaceGridIcon /> : <NodeIcon node={node} open={isExpanded} />}
</span> </span>
{/* Label or rename input */} {/* Label or rename input */}
@ -509,8 +542,16 @@ function DraggableNode({
<span className="truncate flex-1">{node.name.replace(/\.md$/, "")}</span> <span className="truncate flex-1">{node.name.replace(/\.md$/, "")}</span>
)} )}
{/* Lock badge for system/virtual files */} {/* Workspace badge for the workspace root entry point */}
{isProtected && !compact && ( {isWorkspaceRoot && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0 font-medium"
style={{ background: "var(--color-accent)", color: "white" }}>
workspace
</span>
)}
{/* Lock badge for system/virtual files (skip for workspace root -- it has its own badge) */}
{isProtected && !isWorkspaceRoot && !compact && (
<span className="flex-shrink-0 ml-1"> <span className="flex-shrink-0 ml-1">
<LockBadge /> <LockBadge />
</span> </span>
@ -549,6 +590,7 @@ function DraggableNode({
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
compact={compact} compact={compact}
dragOverPath={dragOverPath} dragOverPath={dragOverPath}
workspaceRoot={workspaceRoot}
/> />
))} ))}
</div> </div>
@ -617,7 +659,7 @@ function flattenVisible(tree: TreeNode[], expanded: Set<string>): TreeNode[] {
// --- Main Exported Component --- // --- Main Exported Component ---
export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact, parentDir, onNavigateUp, browseDir: _browseDir }: FileManagerTreeProps) { export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact, parentDir, onNavigateUp, browseDir: _browseDir, workspaceRoot }: FileManagerTreeProps) {
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set()); const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set());
const [selectedPath, setSelectedPath] = useState<string | null>(null); const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [renamingPath, setRenamingPath] = useState<string | null>(null); const [renamingPath, setRenamingPath] = useState<string | null>(null);
@ -1064,6 +1106,7 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
compact={compact} compact={compact}
dragOverPath={dragOverPath} dragOverPath={dragOverPath}
workspaceRoot={workspaceRoot}
/> />
))} ))}
</div> </div>

View File

@ -27,6 +27,8 @@ type WorkspaceSidebarProps = {
onGoHome?: () => void; onGoHome?: () => void;
/** Called when a file/folder is selected from the search dropdown. */ /** Called when a file/folder is selected from the search dropdown. */
onFileSearchSelect?: (item: SuggestItem) => void; onFileSearchSelect?: (item: SuggestItem) => void;
/** Absolute path of the workspace root folder, used to render it as a special entry in browse mode. */
workspaceRoot?: string | null;
}; };
function WorkspaceLogo() { function WorkspaceLogo() {
@ -398,6 +400,7 @@ export function WorkspaceSidebar({
onNavigateUp, onNavigateUp,
onGoHome, onGoHome,
onFileSearchSelect, onFileSearchSelect,
workspaceRoot,
}: WorkspaceSidebarProps) { }: WorkspaceSidebarProps) {
const isBrowsing = browseDir != null; const isBrowsing = browseDir != null;
@ -507,15 +510,16 @@ export function WorkspaceSidebar({
/> />
</div> </div>
) : ( ) : (
<FileManagerTree <FileManagerTree
tree={tree} tree={tree}
activePath={activePath} activePath={activePath}
onSelect={onSelect} onSelect={onSelect}
onRefresh={onRefresh} onRefresh={onRefresh}
parentDir={parentDir} parentDir={parentDir}
onNavigateUp={onNavigateUp} onNavigateUp={onNavigateUp}
browseDir={browseDir} browseDir={browseDir}
/> workspaceRoot={workspaceRoot}
/>
)} )}
</div> </div>
@ -525,12 +529,13 @@ export function WorkspaceSidebar({
style={{ borderColor: "var(--color-border)" }} style={{ borderColor: "var(--color-border)" }}
> >
<a <a
href="/" href="https://ironclaw.sh"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm" className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm"
style={{ color: "var(--color-text-muted)" }} style={{ color: "var(--color-text-muted)" }}
> >
<HomeIcon /> ironclaw.sh
Home
</a> </a>
<ThemeToggle /> <ThemeToggle />
</div> </div>

View File

@ -13,6 +13,7 @@ import { CodeViewer } from "../components/workspace/code-viewer";
import { MediaViewer, detectMediaType, type MediaType } from "../components/workspace/media-viewer"; import { MediaViewer, detectMediaType, type MediaType } from "../components/workspace/media-viewer";
import { DatabaseViewer } from "../components/workspace/database-viewer"; import { DatabaseViewer } from "../components/workspace/database-viewer";
import { Breadcrumbs } from "../components/workspace/breadcrumbs"; import { Breadcrumbs } from "../components/workspace/breadcrumbs";
import { ChatSessionsSidebar } from "../components/workspace/chat-sessions-sidebar";
import { EmptyState } from "../components/workspace/empty-state"; import { EmptyState } from "../components/workspace/empty-state";
import { ReportViewer } from "../components/charts/report-viewer"; import { ReportViewer } from "../components/charts/report-viewer";
import { ChatPanel, type ChatPanelHandle } from "../components/chat-panel"; import { ChatPanel, type ChatPanelHandle } from "../components/chat-panel";
@ -466,28 +467,14 @@ function WorkspacePageInner() {
[loadContent, router, cronJobs, browseDir, workspaceRoot, openclawDir, setBrowseDir], [loadContent, router, cronJobs, browseDir, workspaceRoot, openclawDir, setBrowseDir],
); );
// Build the enhanced tree: real tree + Chats + Cron virtual folders at the bottom // Build the enhanced tree: real tree + Cron virtual folder at the bottom
// (Chat sessions live in the right sidebar, not in the tree.)
// In browse mode, skip virtual folders (they only apply to workspace mode) // In browse mode, skip virtual folders (they only apply to workspace mode)
const enhancedTree = useMemo(() => { const enhancedTree = useMemo(() => {
if (browseDir) { if (browseDir) {
return tree; return tree;
} }
const chatChildren: TreeNode[] = sessions.map((s) => ({
name: s.title || "Untitled chat",
path: `~chats/${s.id}`,
type: "file" as const,
virtual: true,
}));
const chatsFolder: TreeNode = {
name: "Chats",
path: "~chats",
type: "folder",
virtual: true,
children: chatChildren.length > 0 ? chatChildren : undefined,
};
const cronStatusIcon = (job: CronJob) => { const cronStatusIcon = (job: CronJob) => {
if (!job.enabled) {return "\u25CB";} // circle outline if (!job.enabled) {return "\u25CB";} // circle outline
if (job.state.runningAtMs) {return "\u25CF";} // filled circle if (job.state.runningAtMs) {return "\u25CF";} // filled circle
@ -511,8 +498,8 @@ function WorkspacePageInner() {
children: cronChildren.length > 0 ? cronChildren : undefined, children: cronChildren.length > 0 ? cronChildren : undefined,
}; };
return [...tree, chatsFolder, cronFolder]; return [...tree, cronFolder];
}, [tree, sessions, cronJobs, browseDir]); }, [tree, cronJobs, browseDir]);
// Compute the effective parentDir for ".." navigation. // Compute the effective parentDir for ".." navigation.
// In browse mode: use browseParentDir from the API. // In browse mode: use browseParentDir from the API.
@ -650,12 +637,26 @@ function WorkspacePageInner() {
setContent({ kind: "none" }); setContent({ kind: "none" });
return; return;
} }
// Absolute paths (browse mode): navigate the sidebar directly.
// Intermediate parent folders aren't in the browse-mode tree, so
// resolveNode would fail — call setBrowseDir to update the sidebar.
if (isAbsolutePath(path)) {
const name = path.split("/").pop() || path;
setBrowseDir(path);
setActivePath(path);
setContent({ kind: "directory", node: { name, path, type: "folder" } });
return;
}
// Relative paths (workspace mode): resolve and navigate via handleNodeSelect
// so virtual paths, chat context, etc. are all handled properly.
const node = resolveNode(tree, path); const node = resolveNode(tree, path);
if (node) { if (node) {
void loadContent(node); handleNodeSelect(node);
} }
}, },
[tree, loadContent], [tree, handleNodeSelect, setBrowseDir],
); );
// Navigate to an object by name (used by relation links) // Navigate to an object by name (used by relation links)
@ -758,6 +759,13 @@ function WorkspacePageInner() {
router.replace("/workspace", { scroll: false }); router.replace("/workspace", { scroll: false });
}, [router]); }, [router]);
// Derive the active session's title for the header / right sidebar
const activeSessionTitle = useMemo(() => {
if (!activeSessionId) {return undefined;}
const s = sessions.find((s) => s.id === activeSessionId);
return s?.title || undefined;
}, [activeSessionId, sessions]);
// Whether to show the main ChatPanel (no file/content selected) // Whether to show the main ChatPanel (no file/content selected)
const showMainChat = !activePath || content.kind === "none"; const showMainChat = !activePath || content.kind === "none";
@ -777,6 +785,7 @@ function WorkspacePageInner() {
onNavigateUp={handleNavigateUp} onNavigateUp={handleNavigateUp}
onGoHome={handleGoHome} onGoHome={handleGoHome}
onFileSearchSelect={handleFileSearchSelect} onFileSearchSelect={handleFileSearchSelect}
workspaceRoot={workspaceRoot}
/> />
{/* Main content */} {/* Main content */}
@ -833,15 +842,32 @@ function WorkspacePageInner() {
<div className="flex-1 flex min-h-0"> <div className="flex-1 flex min-h-0">
{showMainChat ? ( {showMainChat ? (
/* Main chat view (default when no file is selected) */ /* Main chat view (default when no file is selected) */
<div className="flex-1 flex flex-col min-w-0"> <>
<ChatPanel <div className="flex-1 flex flex-col min-w-0">
ref={chatRef} <ChatPanel
onActiveSessionChange={(id) => { ref={chatRef}
setActiveSessionId(id); sessionTitle={activeSessionTitle}
onActiveSessionChange={(id) => {
setActiveSessionId(id);
}}
onSessionsChange={refreshSessions}
/>
</div>
<ChatSessionsSidebar
sessions={sessions}
activeSessionId={activeSessionId}
activeSessionTitle={activeSessionTitle}
onSelectSession={(sessionId) => {
setActiveSessionId(sessionId);
void chatRef.current?.loadSession(sessionId);
}}
onNewSession={() => {
setActiveSessionId(null);
void chatRef.current?.newSession();
router.replace("/workspace", { scroll: false });
}} }}
onSessionsChange={refreshSessions}
/> />
</div> </>
) : ( ) : (
<> <>
{/* File content area */} {/* File content area */}